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: ({ signal }) =>
service.getExtension(controllerFromSignal(signal), target.namespace, target.extension)
});
};
- The
queryFn is a one-liner because the service method rejects on failure — see "Errors belong to the service" below. Never re-check isError in a hook.
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.
- The
mutationFn forwards the service call directly; the service rejects on failure, so mutateAsync callers get their catch / onError path for free.
- Invalidate or remove affected queries in
onSuccess.
Errors belong to the service, not the hook
The registry answers some failures with a 200 carrying an { error: '…' } body instead of a non-2xx status. sendStrictRequest (server-request.ts) is the single place that normalises this: it is non-retriable and rejects on such a body, so a migrated service method resolves with data or rejects — never both.
// extension-registry-service.ts — migrated methods
async getNamespace(abortController: AbortController, name: string): Promise<Readonly<Namespace>> {
return sendStrictRequest({ abortController, credentials: true, endpoint: /* … */ });
}
- A migrated method's return type drops
| ErrorResult — that union is what forced the isError check on every caller.
- Hooks therefore never contain
if (isError(result)) throw result. If you find yourself writing one, the service method still needs migrating.
sendRequest / sendNonRetriableRequest keep resolving error bodies; legacy, un-migrated consumers check isError themselves. Don't change their behaviour.
- Same rule in tests: stub a failing service method with
mockRejectedValue({ error: '…' }), not mockResolvedValue.
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 sendStrictRequest (fetch-retry disabled for non-429 responses), so TanStack should remain the only layer retrying network/5xx.
- 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.
1---2name: tanstack-query-conventions3description: 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.4---56# TanStack Query conventions78## Hooks, not raw queries910- Never call `useQuery` / `useMutation` / `useInfiniteQuery` directly in a component — wrap each endpoint in a `use*` hook.11- The hook returns the **react-query result object unchanged**. Don't return just `data` or a hand-picked subset; consumers destructure what they need.12- Co-locate a new hook in the feature's folder; promote to `src/hooks/` only when a second place needs it (see AGENTS.md).1314## Query hook shape1516```ts17export const useUserExtension = (target: UserExtensionTarget) => {18 const { service } = useContext(MainContext);19 return useQuery({20 queryKey: ['user', 'extension', target.namespace, target.extension],21 queryFn: ({ signal }) =>22 service.getExtension(controllerFromSignal(signal), target.namespace, target.extension)23 });24};25```2627- The `queryFn` is a one-liner because the service method rejects on failure — see "Errors belong to the service" below. Never re-check `isError` in a hook.28- `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.29- `useQuery` forbids `undefined`; normalise a "no result" case to `null`.30- Query keys are hierarchical arrays (`['admin', 'namespace', name]`). When a key is reused for invalidation, export a small `*Keys` helper next to the hook.3132## Mutation hook shape3334```ts35export const useCreateNamespace = () => {36 const { service } = useContext(MainContext);37 const queryClient = useQueryClient();38 return useMutation({39 mutationFn: (name: string) => service.admin.createNamespace({ name }),40 onSuccess: (_result, name) => queryClient.invalidateQueries({ queryKey: namespaceAdminKeys.detail(name) })41 });42};43```4445- **No `AbortController` / signal in mutations** — we don't abort writes anymore.46- Mutations don't retry (TanStack's default `retry: 0`), which is correct for non-idempotent writes.47- The `mutationFn` forwards the service call directly; the service rejects on failure, so `mutateAsync` callers get their `catch` / `onError` path for free.48- Invalidate or remove affected queries in `onSuccess`.4950## Errors belong to the service, not the hook5152The registry answers some failures with a `200` carrying an `{ error: '…' }` body instead of a non-2xx status. `sendStrictRequest` (`server-request.ts`) is the single place that normalises this: it is non-retriable *and* rejects on such a body, so a migrated service method resolves with data or rejects — never both.5354```ts55// extension-registry-service.ts — migrated methods56async getNamespace(abortController: AbortController, name: string): Promise<Readonly<Namespace>> {57 return sendStrictRequest({ abortController, credentials: true, endpoint: /* … */ });58}59```6061- A migrated method's return type **drops `| ErrorResult`** — that union is what forced the `isError` check on every caller.62- Hooks therefore never contain `if (isError(result)) throw result`. If you find yourself writing one, the service method still needs migrating.63- `sendRequest` / `sendNonRetriableRequest` keep resolving error bodies; legacy, un-migrated consumers check `isError` themselves. Don't change their behaviour.64- Same rule in tests: stub a failing service method with `mockRejectedValue({ error: '…' })`, not `mockResolvedValue`.6566## Retries and caching are owned by the shared client6768- One singleton `queryClient` (`query-client.ts`) retries network/5xx with backoff, never 4xx; 429s are waited out inside `sendRequest`. Migrated service methods use `sendStrictRequest` (fetch-retry disabled for non-429 responses), so TanStack should remain the only layer retrying network/5xx.69- 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.7071## Options objects, not positional flags7273When a hook needs optional behaviour, take an options object that forwards TanStack's options — never a bare positional flag:7475```ts76export const useThing = (id: string, options?: Omit<UseQueryOptions<Thing>, 'queryKey' | 'queryFn'>) =>77 useQuery({ queryKey: ['thing', id], queryFn: /* … */, ...options });78```7980## Naming8182- `use<Thing>`, `useCreate*`, `useDelete*`, `useChange*`; infinite scroll → `useInfinite*` backed by `useInfiniteQuery`.83- Don't prefix admin hooks with "admin" — the `service.admin.*` namespace and the `admin-dashboard/` folder already convey it.8485## Consumer side — always destructure and rename8687```ts88const { data: user, error: userError, isFetching } = useUserExtension(target);89const { mutateAsync: createNamespace, isPending: creating } = useCreateNamespace();90```9192Never 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.9394The 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.9596## Best practices9798- **`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.)99- **`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).100- **Pagination** keeps the previous page with `placeholderData: keepPreviousData` (as `use-infinite-search` does), never `initialData`.101- **`enabled` for dependent queries** — gate a query through the options object (`{ enabled: !!id }`), never by calling the hook conditionally.102- **`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".103- **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.104- **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.105- **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`.106- **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.107108## Common pitfalls109110- **`initialData` when you mean `placeholderData`** — `initialData` is treated as fresh (subject to `staleTime`) and written to cache; `placeholderData` is display-only.111- **Infinite query without `initialPageParam`** — required in v5.112- **Conditional hook calls** — gate with `enabled`, never wrap the hook in an `if`.113- **Optimistic update without cancelling first** — an in-flight refetch lands after your write and clobbers it; `cancelQueries` in `onMutate`.114- **`staleTime` > `gcTime`** — data is garbage-collected while still "fresh", causing surprise refetches.115- **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.