Skip to content
NewComposite primary keys5 min read

TanStack Query

A UQL query is a plain JSON object, which makes it a structural queryKey: two components asking for the same data share one cache entry, and there are no key strings to keep in sync. TanStack hashes keys with object keys sorted, so an inline literal is stable across renders without useMemo.

The examples below are React with v5, but none of it is React-specific: uql-orm/browser is a plain client, so the same keys and fetchers work through the Vue, Svelte and Solid adapters.

One client for the wire, one for the cache.

src/lib/uql.ts
import { HttpQuerier } from 'uql-orm/browser';
export const querier = new HttpQuerier('/api');
src/lib/queryClient.ts
import {
QueryClient,
defaultShouldDehydrateQuery,
environmentManager,
} from '@tanstack/react-query';
import { RequestError } from 'uql-orm/browser';
const makeQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
retry: (count, err) =>
count < 3 && !(err instanceof RequestError && err.status < 500),
},
// Dehydrate queries that are still pending, so a server prefetch can
// stream in rather than hold the page back. See Server rendering below.
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
},
},
});
let browserQueryClient: QueryClient | undefined;
export function getQueryClient() {
if (environmentManager.isServer()) {
return makeQueryClient();
}
browserQueryClient ??= makeQueryClient();
return browserQueryClient;
}

RequestError carries the HTTP status, so a 4xx spends no retries; put 429 back in the retryable set if your API rate-limits. environmentManager.isServer() replaced the isServer boolean in v5.101; it is what gives every server render its own client and the browser tab a single one.

queryOptions ties a key to the fetcher that fills it, so a component, a prefetch and an invalidation cannot disagree about either. Write the UQL query beside it and both come from one literal:

src/lib/userQueries.ts
import { queryOptions } from '@tanstack/react-query';
import type { Query } from 'uql-orm/type';
import { User } from './models.js';
import { querier } from './uql.js';
export const activeUsers = {
$select: { id: true, name: true, email: true },
$where: { status: 'active' },
$sort: { createdAt: 'desc' },
$limit: 20,
} satisfies Query<User>;
export const activeUsersOptions = queryOptions({
queryKey: [User.name, activeUsers],
queryFn: async ({ signal }) => {
const { data } = await querier.findMany(User, activeUsers, {
signal,
silent: true,
});
return data;
},
});

signal aborts the request with the component that started it, and silent: true skips UQL’s notification bus, since React Query already owns loading and error state.

const { data: users = [] } = useQuery(activeUsersOptions);
// or, inside a Suspense boundary, where `data` is never undefined
const { data: users } = useSuspenseQuery(activeUsersOptions);

users is { id, name, email }[]: the query asked for three columns, so user.status is a compile error. That lasts only while findMany can see the literal. The obvious next step, a generic useFindMany(entity, q: Query<E>), quietly undoes it: Query<E> is the unprojected type, so every row widens back to a whole User. See Type Safety.

$skip and $limit live in the query, so they live in the key. Each page caches separately, and keepPreviousData holds the current one on screen while the next loads:

src/lib/userQueries.ts
import { keepPreviousData, queryOptions } 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,
});
};

findManyAndCount brings the rows and the unpaged total back in one round trip; naming them in the fetcher keeps data.data out of your components.

An infinite list is the same query with the offset lifted out of the key, so the pages of one list share an entry and a different filter gets its own. That entry holds { pages, pageParams } rather than rows, so it needs its own marker in the key, or a plain useQuery on the same filter would collide with it.

src/lib/userQueries.ts
import { infiniteQueryOptions } from '@tanstack/react-query';
const feed = {
$where: { status: 'active' },
$sort: { createdAt: 'desc' },
$limit: 20,
} satisfies Query<User>;
export const userFeedOptions = infiniteQueryOptions({
queryKey: [User.name, 'infinite', feed],
initialPageParam: 0,
queryFn: async ({ pageParam, signal }) => {
const { data } = await querier.findMany(
User,
{ ...feed, $skip: pageParam },
{ signal, silent: true },
);
return data;
},
getNextPageParam: (lastPage, allPages) =>
lastPage.length < feed.$limit ? undefined : allPages.length * feed.$limit,
getPreviousPageParam: (_firstPage, _allPages, firstPageParam) =>
firstPageParam > 0 ? firstPageParam - feed.$limit : undefined,
// Cap what the cache holds: refetching a long feed is one request per page.
maxPages: 5,
});
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery(userFeedOptions);

Keys match by prefix, so invalidation picks its own granularity: [User.name] drops every query for that entity, infinite lists included, while activeUsersOptions.queryKey drops exactly one.

import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { EntityData } from 'uql-orm/type';
import { User } from './models.js';
import { querier } from './uql.js';
export function useInsertUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: EntityData<User>) => querier.insertOne(User, payload),
onSuccess: () => queryClient.invalidateQueries({ queryKey: [User.name] }),
});
}

To move the list before the server answers, read and write it through the same options object: queryOptions types its own queryKey, so the updater is checked against { id, name, email }[].

export function useRenameUser() {
const queryClient = useQueryClient();
const { queryKey } = activeUsersOptions;
return useMutation({
mutationFn: ({ id, name }: { id: number; name: string }) =>
querier.updateOneById(User, id, { name }),
onMutate: async ({ id, name }) => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData(queryKey);
queryClient.setQueryData(queryKey, (rows = []) =>
rows.map((row) => (row.id === id ? { ...row, name } : row)),
);
return { previous };
},
onError: (_err, _vars, context) =>
queryClient.setQueryData(queryKey, context?.previous),
onSettled: () => queryClient.invalidateQueries({ queryKey }),
});
}

Prefetch with the server pool, hydrate into the client cache. Only the transport changes: the key and the literal are the ones the component already imports, so the browser wakes up holding the entry it was about to ask for.

app/users/page.tsx
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { pool } from '@/lib/uql.config'; // the server pool, not HttpQuerier
import { getQueryClient } from '@/lib/queryClient';
import { activeUsers, activeUsersOptions } from '@/lib/userQueries';
import { User } from '@/lib/models';
export default function Page() {
const queryClient = getQueryClient();
void queryClient
.query({
...activeUsersOptions,
queryFn: () => pool.findMany(User, activeUsers),
})
.catch(() => {});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ActiveUsers />
</HydrationBoundary>
);
}

queryClient.query() is what prefetchQuery became: it resolves with the data or throws, so a prefetch swallows the rejection and leaves the retry to the client. Nothing is awaited, which is what shouldDehydrateQuery bought: the page streams, and the pending entry lands in the hydrated cache when the database answers.

ActiveUsers then calls useQuery(activeUsersOptions) and finds it there. The pool returns User[] while HttpQuerier wraps it in { data }, so the client fetcher unwraps to keep both halves the same shape.

For per-request auth, scope a client instead of reusing the module-level one: new HttpQuerier('/api', { headers }). See the Browser extension.