Skip to content

TanStack Query

A UQL query is a plain JSON object, which makes it a perfect structural queryKey: two components asking for the same data share one cache entry, with no hand-maintained key strings.

The examples are React (still v5), but nothing here is React-specific: uql-orm/browser is a plain client, so the same key and fetcher work through @tanstack/vue-query, @tanstack/svelte-query and @tanstack/solid-query.

src/lib/uql-query.ts
import { useQuery } from '@tanstack/react-query';
import { getQuerier } from 'uql-orm/browser';
import type { Query, Type } from 'uql-orm/type';
const querier = getQuerier();
export function useFindMany<E extends object>(entity: Type<E>, q: Query<E>) {
return useQuery({
queryKey: [entity.name, q], // the serializable query IS the cache key
queryFn: async ({ signal }) => {
const { data } = await querier.findMany(entity, q, { signal, silent: true });
return data;
},
});
}

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

const { data: users = [] } = useFindMany(User, { $where: { status: 'active' }, $limit: 20 });

users is typed User[], relations included: add $populate and the shape follows.

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

const query = { $sort: { createdAt: 'desc' }, $limit: 20, $skip: page * 20 } satisfies Query<User>;
const { data } = useQuery({
queryKey: [User.name, query],
queryFn: ({ signal }) => querier.findManyAndCount(User, query, { signal }),
placeholderData: keepPreviousData,
});
// data.data is User[]; data.count is the unpaged total

For an infinite list the page param is the offset. Keep $skip out of the key and everything else in it, so the pages of one list share an entry and a different filter gets its own:

const feed = { $where: { status: 'active' }, $sort: { createdAt: 'desc' }, $limit: 20 } satisfies Query<User>;
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: [User.name, feed],
initialPageParam: 0,
queryFn: async ({ pageParam, signal }) => {
const { data } = await querier.findMany(User, { ...feed, $skip: pageParam }, { signal });
return data;
},
getNextPageParam: (lastPage, allPages) => (lastPage.length < feed.$limit ? undefined : allPages.length * feed.$limit),
});

Invalidate at whatever granularity you need: [entity.name] drops every query for that entity, [entity.name, q] targets exactly one.

export function useInsert<E extends object>(entity: Type<E>) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: E) => querier.insertOne(entity, payload),
onSuccess: () => queryClient.invalidateQueries({ queryKey: [entity.name] }),
});
}

Prefetch with the server pool and hydrate into the client cache. Both sides pass the same query object, so the key matches and the client does not refetch what the server already loaded:

app/users/page.tsx
import { HydrationBoundary, QueryClient, dehydrate } from '@tanstack/react-query';
import type { Query } from 'uql-orm/type';
import { pool } from '@/lib/uql'; // the server pool, not HttpQuerier
import { User } from '@/lib/models';
export default async function Page() {
const query = { $where: { status: 'active' }, $limit: 20 } satisfies Query<User>;
const queryClient = new QueryClient();
await queryClient.prefetchQuery({ queryKey: [User.name, query], queryFn: () => pool.findMany(User, query) });
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ActiveUsers />
</HydrationBoundary>
);
}

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