tanstack-query-conventions
GitHub定义 TanStack Query 在 WebUI 中的编码规范,涵盖 Hook 封装、查询键结构、缓存重试及错误处理策略。用于指导编写或审查 useQuery/useMutation 代码,确保数据获取逻辑一致且符合最佳实践。
Trigger Scenarios
Install
npx skills add eclipse-openvsx/openvsx --skill tanstack-query-conventions -g -y
SKILL.md
Frontmatter
{
"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/useInfiniteQuerydirectly in a component — wrap each endpoint in ause*hook. - The hook returns the react-query result object unchanged. Don't return just
dataor 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; // let errors reach TanStack
return result;
}
});
};
controllerFromSignal(signal)(query-client.ts) bridges TanStack'sAbortSignalto theAbortControllerthe service expects — service signatures stay untouched, component-levelAbortControllerrefs go away.useQueryforbidsundefined; normalise a "no result" case tonull.- Query keys are hierarchical arrays (
['admin', 'namespace', name]). When a key is reused for invalidation, export a small*Keyshelper 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. throwon an error result when the caller relies on acatch/onErrorpath.- 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 insidesendRequest. Migrated service methods usesendNonRetriableRequest, so this is the only retry layer. - Defaults:
refetchOnWindowFocus: false,staleTime: 60s. Override per hook only with reason —staleTime: 0/gcTime: 0when data must always be fresh (right after publish/delete),retry: falseto 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 byuseInfiniteQuery.- Don't prefix admin hooks with "admin" — the
service.admin.*namespace and theadmin-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
queryOptionsfor shared queries — when the same query is read from more than one place, define it once with TanStack'squeryOptions({ 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 sets60s; raise it for rarely-changing data, drop to0for reads that must always refetch on mount. KeepstaleTime <= gcTime(see pitfalls).- Pagination keeps the previous page with
placeholderData: keepPreviousData(asuse-infinite-searchdoes), neverinitialData. enabledfor dependent queries — gate a query through the options object ({ enabled: !!id }), never by calling the hook conditionally.selectfor derived data — transform inselectinside the query config, not in the component. The hook still returns the whole result (onlydata'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 thatquery-client.tsrevokes on cache eviction. - Suspense — only when a component sits under a Suspense boundary,
useSuspenseQuerydrops the loading/undefinedbranches. Our hooks default to plainuseQuerywith 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,
cancelQueriesinonMutatebefore writing and snapshot for rollback inonError. - Tests — render through the
test-providersharness: a fresh client per render withretry: falseandgcTime: 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
initialDatawhen you meanplaceholderData—initialDatais treated as fresh (subject tostaleTime) and written to cache;placeholderDatais display-only.- Infinite query without
initialPageParam— required in v5. - Conditional hook calls — gate with
enabled, never wrap the hook in anif. - Optimistic update without cancelling first — an in-flight refetch lands after your write and clobbers it;
cancelQueriesinonMutate. 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 fromonSuccesswhen the mutation should stay pending until the refetch completes.
Version History
- 4c33308 Current 2026-08-20 15:09


