> Every UQL docs page, as Markdown: https://uql-orm.dev/llms.txt
> The same docs over MCP: https://uql-orm.dev/mcp
> Before writing UQL code, read the skill: https://uql-orm.dev/.well-known/agent-skills/uql-orm/SKILL.md

# TanStack Query

> Use UQL queries as TanStack Query cache keys, with query options, pagination, mutations, optimistic updates and server rendering.

Source: https://uql-orm.dev/react-query

A UQL query is a plain JSON object, so it can be the `queryKey` itself. Components asking for the same data share one cache entry, with no key strings to keep in sync. TanStack sorts object keys when hashing, so an inline literal stays stable without `useMemo`.

The examples use React and TanStack Query v5; the keys and fetchers work the same with the Vue, Svelte and Solid adapters.

## Setup

Create one [`HttpQuerier`](https://uql-orm.dev/browser.md) for the network and one `QueryClient` for the cache:

```ts title="src/lib/queryClient.ts"
import {
  QueryClient,
  defaultShouldDehydrateQuery,
  environmentManager,
} from '@tanstack/react-query';
import { HttpQuerier, RequestError } from 'uql-orm/browser';

export const querier = new HttpQuerier('/api');

const makeQueryClient = () =>
  new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60_000,
        // a 4xx won't succeed on retry; add 429 back if your API rate-limits
        retry: (count, err) =>
          count < 3 && !(err instanceof RequestError && err.status < 500),
      },
      // keep pending queries too, so a server prefetch can stream in
      dehydrate: {
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === 'pending',
      },
    },
  });

let browserQueryClient: QueryClient | undefined;

// a fresh client per server render, one for the browser tab
export function getQueryClient() {
  if (environmentManager.isServer()) return makeQueryClient();
  return (browserQueryClient ??= makeQueryClient());
}
```

## The key and the fetcher

`queryOptions` ties a key to its fetcher, so a component, a prefetch and an invalidation always agree. Declare the UQL query beside it, and both come from one literal:

```ts title="src/lib/userQueries.ts"
import { queryOptions } from '@tanstack/react-query';
import type { Query } from 'uql-orm';
import { User } from './models.js';
import { querier } from './queryClient.js';

export const activeUsers = {
  $select: { id: true, name: true, email: true },
  $where: { status: 'active' },
  $limit: 20,
} satisfies Query<User>;

export const activeUsersOptions = queryOptions({
  queryKey: [User.name, activeUsers],
  queryFn: async ({ signal }) =>
    (await querier.findMany(User, activeUsers, { signal, silent: true })).data,
});
```

`signal` cancels the request when the component unmounts, and `silent: true` marks it for a [notification](https://uql-orm.dev/browser.md#request-notifications) listener to skip, since TanStack Query tracks loading itself.

```tsx
const { data: users = [] } = useQuery(activeUsersOptions);
```

`users` is `{ id, name, email }[]`, so `user.status` is a compile error. That narrowing needs `findMany` to see the literal: a generic `useFindMany(entity, q: Query<E>)` widens every row back to a whole `User`. See [Type Safety](https://uql-orm.dev/type-safety.md).

## Pagination

`$skip` and `$limit` are part of the query, so each page gets its own entry. `keepPreviousData` keeps the current page on screen while the next loads, and `findManyAndCount` brings the total in the same request:

```ts
import { keepPreviousData } from '@tanstack/react-query';

export const usersPage = (page: number) => {
  const q = {
    $sort: { createdAt: 'desc' },
    $limit: 20,
    $skip: page * 20,
  } satisfies Query<User>;
  return queryOptions({
    queryKey: [User.name, q],
    queryFn: async ({ signal }) => {
      const { data, count } = await querier.findManyAndCount(User, q, {
        signal,
        silent: true,
      });
      return { rows: data, total: count };
    },
    placeholderData: keepPreviousData,
  });
};
```

For an infinite list, leave `$skip` out of the key so every page of the list shares one entry. That entry holds `{ pages, pageParams }`, not rows, so mark the key (`'infinite'`) to keep it apart from a plain query on the same filter:

```ts
import { infiniteQueryOptions } from '@tanstack/react-query';

const feed = { $where: { status: 'active' }, $limit: 20 } satisfies Query<User>;

export const userFeedOptions = infiniteQueryOptions({
  queryKey: [User.name, 'infinite', feed],
  initialPageParam: 0,
  queryFn: async ({ pageParam, signal }) =>
    (
      await querier.findMany(
        User,
        { ...feed, $skip: pageParam },
        { signal, silent: true },
      )
    ).data,
  getNextPageParam: (lastPage, allPages) =>
    lastPage.length < feed.$limit ? undefined : allPages.length * feed.$limit,
  maxPages: 5, // refetching a long feed costs one request per page
});
```

## Mutations

Keys match by prefix, so `[User.name]` invalidates every query for the entity, infinite lists included, while `activeUsersOptions.queryKey` invalidates one:

```ts
import { useMutation, useQueryClient } from '@tanstack/react-query';

export function useInsertUser() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (payload: EntityData<User>) => querier.insertOne(User, payload),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: [User.name] }),
  });
}
```

For optimistic updates, read and write the cache through `activeUsersOptions.queryKey`. `queryOptions` types that key, so `setQueryData` checks your updater against `{ id, name, email }[]`.

## Server rendering

Prefetch on the server with the **pool** instead of `HttpQuerier`, reusing the same options, then hydrate:

```tsx title="app/users/page.tsx"
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { pool } from '@/lib/uql.config';

export default function Page() {
  const queryClient = getQueryClient();
  void queryClient
    .query({
      ...activeUsersOptions,
      queryFn: () => pool.findMany(User, activeUsers),
    })
    .catch(() => {});

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <ActiveUsers />
    </HydrationBoundary>
  );
}
```

Nothing is awaited: the page streams, and the pending entry fills in when the database answers. A failed prefetch is swallowed so the client retries it. The pool returns rows while `HttpQuerier` returns `{ data }`, which is why the client fetcher unwraps `data`: both fill the entry with the same shape.

For per-request auth during server rendering, create a scoped `HttpQuerier` instead of the module-level one ([options](https://uql-orm.dev/browser.md#options)).
