Skip to content
NewFrom Mongoose to SQL6 min read

TanStack 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.

Create one HttpQuerier for the network and one QueryClient for the cache:

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());
}

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:

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 listener to skip, since TanStack Query tracks loading itself.

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.

$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:

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:

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
});

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

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 }[].

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

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).