| name | tanstack-query-conventions |
| description | How TanStack Query is used in the webui — hook shape, query keys, retries, mutations, options objects, and how consumers read results. Use when writing or reviewing any useQuery/useMutation/useInfiniteQuery code. |
TanStack Query conventions
Hooks, not raw queries
- Never call
useQuery / useMutation / useInfiniteQuery directly in a component — wrap each endpoint in a use* hook.
- The hook returns the react-query result object unchanged. Don't return just
data or a hand-picked subset; consumers destructure what they need.
- Co-locate a new hook in the feature's folder; promote to
src/hooks/ only when a second place needs it (see AGENTS.md).
Query hook shape
export const useUserExtension = (target: UserExtensionTarget) => {
const { service } = useContext(MainContext);
return useQuery({
queryKey: ['user', 'extension', target.namespace, target.extension],
queryFn: async ({ signal }) => {
const result = await service.getExtension(controllerFromSignal(signal), target.namespace, target.extension);
if (isError(result)) throw result;
return result;
}
});
};
controllerFromSignal(signal) (query-client.ts) bridges TanStack's AbortSignal to the AbortController the service expects — service signatures stay untouched, component-level AbortController refs go away.
useQuery forbids undefined; normalise a "no result" case to null.
- Query keys are hierarchical arrays (
['admin', 'namespace', name]). When a key is reused for invalidation, export a small *Keys helper next to the hook.
Mutation hook shape
export const useCreateNamespace = () => {
const { service } = useContext(MainContext);
const queryClient = useQueryClient();
return useMutation({
mutationFn: (name: string) => service.admin.createNamespace({ name }),
onSuccess: (_result, name) => queryClient.invalidateQueries({ queryKey: namespaceAdminKeys.detail(name) })
});
};
- No
AbortController / signal in mutations — we don't abort writes anymore.
- Mutations don't retry (TanStack's default
retry: 0), which is correct for non-idempotent writes.
throw on an error result when the caller relies on a catch / onError path.
- Invalidate or remove affected queries in
onSuccess.
Retries and caching are owned by the shared client
- One singleton
queryClient (query-client.ts) retries network/5xx with backoff, never 4xx; 429s are waited out inside sendRequest. Migrated service methods use sendNonRetriableRequest, so this is the only retry layer.
- Defaults:
refetchOnWindowFocus: false, staleTime: 60s. Override per hook only with reason — staleTime: 0 / gcTime: 0 when data must always be fresh (right after publish/delete), retry: false to let a 404 surface immediately.
Options objects, not positional flags
When a hook needs optional behaviour, take an options object that forwards TanStack's options — never a bare positional flag:
export const useThing = (id: string, options?: Omit<UseQueryOptions<Thing>, 'queryKey' | 'queryFn'>) =>
useQuery({ queryKey: ['thing', id], queryFn: , ...options });
Naming
use<Thing>, useCreate*, useDelete*, useChange*; infinite scroll → useInfinite* backed by useInfiniteQuery.
- Don't prefix admin hooks with "admin" — the
service.admin.* namespace and the admin-dashboard/ folder already convey it.
Consumer side — always destructure and rename
const { data: user, error: userError, isFetching } = useUserExtension(target);
const { mutateAsync: createNamespace, isPending: creating } = useCreateNamespace();
Never read result.data or call result.mutate off an undestructured object. Destructure and give the fields meaningful names — especially for mutations, where a bare mutate / isPending says nothing at the call site.
The one exception: if you need to forward the query result to another function or component, pass the whole result object — don't destructure and re-pass individual fields, which hands over a point-in-time snapshot instead of the live result.
Best practices
queryOptions for shared queries — when the same query is read from more than one place, define it once with TanStack's queryOptions({ queryKey, queryFn }) and spread it into the hook, for type-safe reuse. (Not used yet — adopt when a query gains a second reader.)
staleTime — the shared client sets 60s; raise it for rarely-changing data, drop to 0 for reads that must always refetch on mount. Keep staleTime <= gcTime (see pitfalls).
- Pagination keeps the previous page with
placeholderData: keepPreviousData (as use-infinite-search does), never initialData.
enabled for dependent queries — gate a query through the options object ({ enabled: !!id }), never by calling the hook conditionally.
select for derived data — transform in select inside the query config, not in the component. The hook still returns the whole result (only data's value changes), so this doesn't break "return the result unchanged".
- Pure
queryFn — fetch and normalise only, no side effects. Deliberate exception: the extension-icon query produces an object URL that query-client.ts revokes on cache eviction.
- Suspense — only when a component sits under a Suspense boundary,
useSuspenseQuery drops the loading/undefined branches. Our hooks default to plain useQuery with explicit loading/error handling; don't switch unless the boundary exists.
- Mutations invalidate, they don't rely on optimism — always invalidate affected queries after a mutation (see the mutation shape above); optimistic updates are an addition, not a replacement. If you do optimistic updates,
cancelQueries in onMutate before writing and snapshot for rollback in onError.
- Tests — render through the
test-providers harness: a fresh client per render with retry: false and gcTime: Infinity (so a query isn't collected mid-assertion). That covers "retry: false in tests", "gcTime: Infinity in tests", and "never share one client across tests"; don't hand-roll a client.
Common pitfalls
initialData when you mean placeholderData — initialData is treated as fresh (subject to staleTime) and written to cache; placeholderData is display-only.
- Infinite query without
initialPageParam — required in v5.
- Conditional hook calls — gate with
enabled, never wrap the hook in an if.
- Optimistic update without cancelling first — an in-flight refetch lands after your write and clobbers it;
cancelQueries in onMutate.
staleTime > gcTime — data is garbage-collected while still "fresh", causing surprise refetches.
- Fire-and-forget invalidation when order matters — the mutation-shape example doesn't await its
invalidateQueries; return the promise from onSuccess when the mutation should stay pending until the refetch completes.