Tanstack query best practices
Skill AlshehriAli0/agent-skills/skills/tanstack-query-best-practices
Agent skill: my custom skills
npx -y skills add AlshehriAli0/agent-skills --skill tanstack-query-best-practicesAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Production conventions for TanStack Query (React Query): a five-file feature folder (types/requests/keys/queries/mutations), queryOptions() key factories with a single all() root per feature for hierarchical invalidation, QueryConfig/MutationConfig type helpers, optimistic updates with rollback, and useInfiniteQuery patterns. Use when adding, refactoring, or reviewing server-state or data-fetching code — useQuery, useMutation, useInfiniteQuery, queryClient, .queries.ts/.mutations.ts files — even when the user doesn't name the library.
SKILL.md
30.8 KB, as published. Nobody here has run it
TanStack Query — Production Conventions
This skill captures the conventions for using TanStack Query (formerly React Query) in a real production codebase. It sits on top of the upstream rules-by-category skill written by @DeckardGer — that skill covers the what and why of each individual rule (query keys, caching, mutations, error handling, prefetching, infinite queries, SSR, parallel queries, performance, offline). This skill covers the how of organizing all of that day-to-day in an app: where each piece lives, what the file looks like, how mutations talk to queries, what the defaults are.
When working on tasks involving data fetching, server state, useQuery, useMutation, queryClient, or anything else in the TanStack Query surface area, apply these conventions by default. For anything not covered here (individual rule rationale, SSR edge cases, offline persistence config, retry tuning), read the upstream rules under references/upstream/rules/*.md.
The big picture
TanStack Query is the source of truth for server state. Local UI state belongs in useState / Zustand / context. The mental model:
- One feature, one folder. Everything about a domain (auth, posts, comments, trees, orders) lives together — types, request functions, key factory, query hooks, mutation hooks — so future-you can grep one path and see the whole shape.
- Keys are objects, not strings.
queryOptions({ queryKey, queryFn, ... })is the only place a key gets defined. Hooks consume the factory; mutations invalidate via the factory. No["users", id]literal ever appears at a call site. - Hooks call typed functions, not
fetch. A<feature>.requests.tsfile exposes typed async functions. Hooks composeuseQuery/useMutationaround them. This keeps network details, retries, and auth headers out of components. - Mutations own invalidation. After a successful write, the mutation hook invalidates the queries it touched — via the key factory, never via stringly-typed keys. Optimistic updates follow the cancel → snapshot → update → rollback-on-error pattern.
If you're tempted to call fetch inside a component, or paste ["users"] into a queryKey somewhere in a screen file, stop — those are both signals that one of the conventions below isn't being applied.
The conventions (with rationale)
1. One folder per feature, five files (+ barrel)
src/api/<feature>/
├── <feature>.types.ts # Types (preferably inferred from a Zod schema or OpenAPI)
├── <feature>.requests.ts # Typed async functions — the only place network calls live
├── <feature>.keys.ts # Query-key factory using queryOptions() / infiniteQueryOptions()
├── <feature>.queries.ts # useQuery / useInfiniteQuery hooks
├── <feature>.mutations.ts # useMutation hooks + invalidation
└── index.ts # Barrel: `export *` from each file
Why: Putting all of a domain's network surface in one folder gives you a single grep target ("show me everything we do with orders"), keeps changes local (a new endpoint touches only its feature folder), and prevents the slow drift where query keys, request functions, and types end up scattered across unrelated screen files. The split into five files matters because each file answers a different question — "what shape?" "how do I fetch?" "how do I cache?" "how do I read?" "how do I write?" — and conflating them grows monolith files that nobody wants to edit.
How to apply: When you need a new endpoint, create or extend the matching feature folder. If you're adding the third call to a feature, the folder almost always already exists. Don't create useFetchX hooks in random component folders.
2. Every key goes through a queryOptions() factory — never a literal at the call site
// ✅ <feature>.keys.ts — one `all()` root, spread into every query
import { queryOptions } from "@tanstack/react-query";
import { fetchAccount, searchAccounts } from "./auth.requests";
export const authQueries = {
// The root — every query below spreads it. `as const` lives here, on the root.
all: () => ["auth"] as const,
account: () =>
queryOptions({
queryKey: [...authQueries.all(), "account"],
queryFn: fetchAccount,
}),
searchAccounts: (term: string, verifiedOnly?: boolean) =>
queryOptions({
queryKey: [...authQueries.all(), "search", term, verifiedOnly],
queryFn: () => searchAccounts(term, verifiedOnly),
enabled: term.length > 0,
}),
};
// ✅ <feature>.queries.ts — spread the factory; extra props live in the hook, not the factory
export const useAccount = ({ queryConfig = {} }: UseAccountOptions = {}) =>
useQuery({ ...authQueries.account(), staleTime: 1000 * 60 * 15, ...queryConfig });
// ❌ never inline a key — even "just this one time"
useQuery({ queryKey: ["account"], queryFn: fetchAccount });
Why: queryOptions() makes the definition reusable everywhere TanStack accepts options — useQuery, useSuspenseQuery, prefetchQuery, ensureQueryData, getQueryData, setQueryData. Stringly-typed keys at the call site fragment the cache: one typo (["accounts"] vs ["account"]) and you have two queries instead of one. Define each key once in the factory and every consumer — hook, invalidate, prefetch, setQueryData — references the same one, with data/variables types flowing from the request function for free.
How to apply: Anywhere you'd write a literal queryKey, define it in the factory and spread the result — the hook never writes its own queryKey. Keep each factory entry to four props: queryKey, queryFn, meta, enabled (infinite queries also need the structural initialPageParam + getNextPageParam); every other option — staleTime, gcTime, placeholderData, select — and any logic lives in the hook body (rule 6). Keys stay plain — as const lives only on all() (rule 3).
3. One all() root per feature — spread it into every key, invalidate through it
Every query in a feature spreads a single all() root, so all its keys share one prefix. That gives you a hierarchy you can invalidate at any depth — the whole feature in one call, or just one sub-tree:
export const treeQueries = {
// The root — `as const` lives here and only here.
all: () => ["trees"] as const,
// Every query spreads all() + a discriminator + its own params. Keys stay plain.
tree: (id: string) =>
queryOptions({
queryKey: [...treeQueries.all(), "detail", id],
queryFn: () => fetchTree(id),
}),
trees: (filters?: string) =>
queryOptions({
queryKey: [...treeQueries.all(), "list", filters],
queryFn: () => fetchTrees(filters),
}),
infiniteTrees: (filters?: string) =>
infiniteQueryOptions({
queryKey: [...treeQueries.all(), "infinite", { filters, limit: 40 }],
queryFn: ({ pageParam }) => fetchInfiniteTrees(40, filters, pageParam),
// ...
}),
};
Invalidate at whatever depth the write touched:
queryClient.invalidateQueries({ queryKey: treeQueries.all() });
// → nukes everything under ["trees", ...]: detail, list, infinite — all of it
queryClient.invalidateQueries({ queryKey: [...treeQueries.all(), "list"] });
// → just the list variants (every filter), leaving details alone
Why: A feature has several key families — detail, list, infinite, count — and lists fan out into N cached variants (one per filter/sort/page). Sharing one all() root makes every family prefix-matchable from a single place: all() clears the whole feature after a broad change, [...all(), "list"] clears just the lists, and because the root is defined once and spread, the keys can't drift apart. as const on all() gives the root a stable tuple type; variant keys stay plain — as const isn't required for caching or invalidation there, and wouldn't propagate through the spread anyway.
How to apply: Every feature factory opens with all: () => ["<feature>"] as const. Every query spreads it and adds a discriminator ("detail", "list", "infinite", "search", …) plus its params. Mutations invalidate at the shallowest scope that covers what changed — all() for a broad write, [...all(), "<scope>"] for a targeted one.
4. Type helpers: QueryConfig and MutationConfig — define once, reuse everywhere
// src/lib/react-query.ts (or wherever your shared lib lives)
import type { UseMutationOptions } from "@tanstack/react-query";
export type QueryConfig<T extends (...args: any[]) => any> =
Omit<ReturnType<T>, "queryKey" | "queryFn">;
export type InfiniteQueryConfig<T extends (...args: any[]) => any> =
Omit<ReturnType<T>, "queryKey" | "queryFn" | "getNextPageParam" | "initialPageParam">;
export type ApiFnReturnType<Fn extends (...args: any) => Promise<any>> =
Awaited<ReturnType<Fn>>;
export type MutationConfig<Fn extends (...args: any) => Promise<any>> =
UseMutationOptions<
ApiFnReturnType<Fn>,
Error,
Parameters<Fn>["length"] extends 0 ? void : Parameters<Fn>[0]
>;
Then in every hook:
type UseAccountOptions = { queryConfig?: QueryConfig<typeof authQueries.account> };
export const useAccount = ({ queryConfig = {} }: UseAccountOptions = {}) =>
useQuery({ ...authQueries.account(), ...queryConfig });
type UseUpdateAccountOptions = { mutationConfig?: MutationConfig<typeof updateAccount> };
export const useUpdateAccount = ({ mutationConfig }: UseUpdateAccountOptions = {}) => {
const queryClient = useQueryClient();
const { onSuccess, ...rest } = mutationConfig || {};
return useMutation({
onSuccess: (...args) => {
queryClient.invalidateQueries({ queryKey: authQueries.account().queryKey });
onSuccess?.(...args);
},
...rest,
mutationFn: updateAccount,
});
};
Why: Without these helpers, every hook reinvents the same option type by hand — Omit<UseQueryOptions<...>, "queryKey" | "queryFn"> repeated dozens of times, with the generic args wrong half the time. With the helpers, the call site gets fully-typed data, variables, and error inferred from the request function's signature, and consumers can pass any normal useQuery / useMutation option (e.g. staleTime, retry, enabled, onSuccess) without leaking the request function's internals.
How to apply: Drop the four helpers into a shared lib/react-query.ts once per project. Every query hook accepts { queryConfig }; every mutation hook accepts { mutationConfig }. Don't pass loose params for things useQuery already accepts (enabled, staleTime, etc.) — they belong inside queryConfig.
5. Hooks call typed request functions — never inline fetch or axios.get
// ✅ <feature>.requests.ts
import { api } from "../../lib/axios";
import type { AccountProfile, UpdateAccountFields } from "./auth.types";
export const fetchAccount = async () => {
const res = await api.get<AccountProfile>("/account");
return res.data.data;
};
export const updateAccount = async (data: UpdateAccountFields) => {
const res = await api.patch<AccountProfile>("/account", data);
return res.data.data;
};
// ❌ never inline a network call in a hook or component
useQuery({
queryKey: ["account"],
queryFn: async () => (await fetch("/api/account")).json(),
});
Why: Inline calls bypass your axios/fetch wrapper, which is where base URLs, auth headers, error normalization, response unwrapping, retry, and logging live. They also mean the return type is any unless you remember to annotate every call site. A typed requests.ts function is grep-able, reusable across queries/mutations/prefetch, and has one source of truth for the network contract.
How to apply: Whenever you reach for fetch/axios inside a queryFn or mutationFn, stop and add a function in <feature>.requests.ts instead. Hooks should look declarative; they should not contain await fetch(...). Keep queryFn a one-line call to that function — no store writes or derived side-effects inside it (they'd run on every fetch, prefetch and SSR included); put those in the hook (a useEffect on data, or a mutation's onSuccess).
6. Hooks export named useX, accept an options object, never positional args for query config
// ✅
type UseTreeOptions = { queryConfig?: QueryConfig<typeof treeQueries.tree> };
export const useTree = (publicId: string, { queryConfig = {} }: UseTreeOptions = {}) =>
useQuery({
...treeQueries.tree(publicId),
...queryConfig,
enabled: !!publicId && queryConfig.enabled !== false,
});
// ❌
export function useTree(publicId, enabled, staleTime) { /* ... */ }
export default useTree;
Why: Required domain args (the thing you're fetching by — publicId, userId, filter) are first-class positional/destructured params. Anything that's a knob on the query itself (enabled, staleTime, refetchInterval, select) goes inside queryConfig. Mixing them produces hooks with five positional booleans nobody can read.
How to apply: When a hook needs to combine a default enabled (e.g. "only run when token exists") with a user-provided one, use enabled: !!token && queryConfig.enabled !== false — let the consumer turn it off explicitly, but never on (the precondition still has to hold).
7. Mutation onSuccess: do the default work first, then call the user's callback
export const useUpdateAccount = ({ mutationConfig }: UseUpdateAccountOptions = {}) => {
const queryClient = useQueryClient();
const { onSuccess, ...rest } = mutationConfig || {};
return useMutation({
onSuccess: (...args) => {
// 1. Default behavior owned by the hook
queryClient.invalidateQueries({ queryKey: authQueries.account().queryKey });
// 2. User-supplied callback (e.g. close a modal, show a toast)
onSuccess?.(...args);
},
...rest,
mutationFn: updateAccount,
});
};
Why: Two things happen on a successful mutation: cache reconciliation (the hook's job) and UX (the consumer's job — toast, navigation, modal close). If you spread mutationConfig raw, the consumer's onSuccess replaces yours and the cache never invalidates. Pulling onSuccess out, running yours first, then calling theirs, preserves both.
How to apply: Every mutation hook destructures { onSuccess, ...rest } from mutationConfig, runs invalidation/setQueryData, then onSuccess?.(...args). Same pattern works for onError if the hook owns rollback. Place ...rest before mutationFn so consumers can't accidentally overwrite the function.
8. Optimistic updates: cancel → snapshot → update → rollback on error → reconcile on success
type UpdateContext = { previous?: TreeProfile };
useMutation({
onMutate: async ({ publicId, data }): Promise<UpdateContext> => {
// Stop any in-flight refetches that could overwrite our optimistic write
await queryClient.cancelQueries({ queryKey: treeQueries.tree(publicId).queryKey });
// Snapshot — what we'll roll back to if the request fails
const previous = queryClient.getQueryData<TreeProfile>(treeQueries.tree(publicId).queryKey);
// Optimistic write
if (previous) {
queryClient.setQueryData<TreeProfile>(treeQueries.tree(publicId).queryKey, {
...previous,
...data,
});
}
return { previous };
},
onError: (_err, { publicId }, context) => {
if (context?.previous) {
queryClient.setQueryData(treeQueries.tree(publicId).queryKey, context.previous);
}
},
onSuccess: (server, { publicId }) => {
// Reconcile the item with the server's authoritative shape
queryClient.setQueryData(treeQueries.tree(publicId).queryKey, server);
},
onSettled: () => {
// Refresh affected lists whether the write succeeded or failed
queryClient.invalidateQueries({ queryKey: [...treeQueries.all(), "list"] });
},
mutationFn: updateTreeInfo,
});
Why: Without cancelQueries, an in-flight refetch can land after your optimistic write and overwrite it. Without a snapshot, you can't roll back on error. Without onSuccess reconciliation, the optimistic data sits in the cache even if the server returned something different (server-side timestamps, computed fields, normalized strings). Skipping any of the three steps produces UI that "looks right until you refresh" — a classic source of trust-eroding bugs.
How to apply: Reach for optimistic updates when the result is predictable (toggle, rename, reorder, like/unlike). Skip them when the server's response shape is hard to predict (server-generated IDs that the next view needs, validation that may transform input) — onSuccess invalidation is simpler and still feels instant on a fast network. See references/mutations-and-invalidation.md for invalidateQueries vs setQueryData, patching cached lists and infinite queries, cross-feature invalidation, and useMutationState.
9. Invalidate via the key factory — never with a stringly-typed prefix
// ✅
queryClient.invalidateQueries({ queryKey: authQueries.account().queryKey });
queryClient.invalidateQueries({ queryKey: [...treeQueries.all(), "infinite"] });
// ❌
queryClient.invalidateQueries({ queryKey: ["auth", "account"] });
queryClient.invalidateQueries({ queryKey: ["trees", "infinite"] });
Why: Stringly-typed prefixes break silently. Rename the feature, change a key shape, change an arg's serialization — the literal in the mutation file stays valid TypeScript and silently invalidates nothing. The factory call goes through the type system, so a rename refactor flags every call site.
How to apply: If you find yourself writing a queryKey: [...] literal anywhere outside the keys file, stop and route it through the factory. The exceptions are extremely rare (e.g. a generic logout that calls queryClient.clear() and doesn't need keys at all).
10. Sensible defaults at the QueryClient level — override per-query only when needed
// src/lib/queryClient.ts
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 min — most data is "fresh enough" for a minute
gcTime: 1000 * 60 * 10, // 10 min — inactive cache survives navigation
retry: 2,
refetchOnWindowFocus: false, // RN apps; for web, decide per app
refetchOnReconnect: true,
},
mutations: {
retry: 0, // mutations are usually not safe to retry blindly
},
},
});
Per-query overrides for the exceptions — in the hook, not the factory:
// Stable reference data — long staleTime set in the hook (meta.persist stays in the factory)
export const useSpecies = () =>
useQuery({ ...speciesQueries.list(), staleTime: 1000 * 60 * 60 });
// Filter/pagination results — don't survive unmount
export const useMapTrees = (params: MapParams) =>
useQuery({ ...treeQueries.map(params), gcTime: 0 });
// Real-time-ish data — always refetch on mount
export const useLiveCount = () =>
useQuery({ ...statsQueries.liveCount(), staleTime: 0 });
Why: App-wide defaults shape behavior for the 80% case; per-query overrides handle the long tail. With staleTime: 0 (the library default), every mount refetches — bad UX, wasted network. With staleTime: Infinity, data never updates without an explicit invalidate — also bad. A minute or so is the sweet spot for most apps, with longer values for true reference data and 0 for queries that should always refetch on mount.
How to apply: Set defaults once at the client. Override staleTime/gcTime per-query in the hook (not the factory) only when the volatility differs meaningfully from the default — with a one-line comment if it's not obvious.
11. meta: { persist: true } for queries that should survive cold start
species: () =>
queryOptions({
queryKey: ["species"],
queryFn: fetchSpecies,
meta: { persist: true },
}),
With a persister configured to filter on query.meta?.persist:
const persister = createAsyncStoragePersister({ storage: AsyncStorage });
persistQueryClient({
queryClient,
persister,
dehydrateOptions: {
shouldDehydrateQuery: (q) => q.meta?.persist === true,
},
});
Why: Persisting everything fills storage with transient data (search results, paginated views, ephemeral filters). Persisting nothing gives you a blank app for two seconds on every cold start. Opting in per-query via meta.persist keeps reference/profile/list-of-things data warm and leaves the noisy stuff out.
How to apply: Mark a query meta: { persist: true } when (a) it's data you'd want visible immediately on cold start, and (b) it's not so volatile that stale data is worse than no data. Account info, reference lists, user preferences: yes. Search results, infinite scroll pages, map viewport queries: no.
12. Prefer queryOptions() for all read paths — prefetchQuery, ensureQueryData, setQueryData
// Prefetch on hover/intent — same factory, no duplication
await queryClient.prefetchQuery(treeQueries.tree(publicId));
// Read-or-fetch — used in route loaders / async boundaries
const tree = await queryClient.ensureQueryData(treeQueries.tree(publicId));
// Read what's already cached, no fetch
const cached = queryClient.getQueryData<TreeProfile>(treeQueries.tree(publicId).queryKey);
// Write — after a mutation, or to seed from another endpoint
queryClient.setQueryData(treeQueries.tree(publicId).queryKey, fullTree);
Why: queryOptions() is the one definition of the query's identity — its key and its queryFn. Every API that takes options accepts the factory result directly, so a prefetch, a route loader, and a useQuery all target the exact same cache entry with no second key definition. (Freshness knobs like staleTime live in the hook, not here — so a bare prefetch uses the client's default staleTime; pass one to prefetchQuery if it must match a hook's override.)
How to apply: In route loaders, in onMutate (getQueryData), in mutation onSuccess (setQueryData), in hover prefetches — always reach for the factory. If you're typing queryKey: [...] by hand outside the keys file, you're doing it wrong.
13. useInfiniteQuery via infiniteQueryOptions()
infiniteTrees: (filters?: string) => {
const limit = 40;
return infiniteQueryOptions({
queryKey: [...treeQueries.all(), "infinite", { filters, limit }],
queryFn: ({ pageParam }) => fetchInfiniteTrees(limit, filters, pageParam),
initialPageParam: null as { id: number; cursor: string | number | null } | null,
getNextPageParam: (lastPage) => {
if (lastPage.length < limit) return undefined; // ← `undefined` = "no more pages" (never `null`)
const last = lastPage[lastPage.length - 1];
return { id: last.id, cursor: last.createdAt };
},
});
};
// gcTime is an extra prop → set it in the hook, not the factory:
export const useInfiniteTrees = (filters?: string) =>
useInfiniteQuery({ ...treeQueries.infiniteTrees(filters), gcTime: 0 });
Why: infiniteQueryOptions() typechecks initialPageParam ↔ getNextPageParam's return ↔ queryFn's pageParam. Two traps fail silently: returning null instead of undefined from getNextPageParam fetches forever, and forgetting gcTime: 0 (set in the hook) leaks one cache entry per filter for the whole session.
How to apply: Cursor pagination is the default — return undefined when the last page is short. See references/infinite-queries.md for offset APIs, maxPages, patching cached pages from a mutation, and FlatList/FlashList wiring.
14. One feature domain per file — never mix auth.queries.ts with order code
// ✅ auth.queries.ts contains only account/auth hooks
// ✅ order.queries.ts contains only order hooks
// ❌ "shared" queries file that drifts into a god-module
// Symptom: file > 500 lines, multiple unrelated feature names, mixed imports
Why: Domain colocation is the whole reason this folder layout works. Once a file mixes domains, refactoring one becomes a merge-conflict factory, and the file's purpose ("anything network-y") provides no useful signal.
How to apply: When unsure where a new endpoint goes, ask "what feature does this belong to?" If you'd describe it to another engineer as "the orders endpoints," it goes in order/. Cross-feature aggregations (e.g. dashboard summary) get their own feature folder (dashboard/).
15. enabled guards: always boolean, always handle the "not ready yet" case
// ✅ guard chained dependencies
export const useAccount = ({ queryConfig = {} }: UseAccountOptions = {}) => {
const token = useAuthToken();
return useQuery({
...authQueries.account(),
...queryConfig,
enabled: !!token && queryConfig.enabled !== false,
});
};
// ✅ guard on a string param
export const useTree = (publicId: string, { queryConfig = {} }: UseTreeOptions = {}) =>
useQuery({
...treeQueries.tree(publicId),
...queryConfig,
enabled: !!publicId && queryConfig.enabled !== false,
});
Why: TanStack runs a query as soon as it's mounted unless enabled is false. If you don't guard a query that depends on something (a token, an id, a filter), it fires immediately, hits an error, and retries — visible as 401s in the console and a flicker of error UI before the data is actually requested. Coercing to boolean (!!x) handles undefined / "" / 0 cleanly.
How to apply: Any query whose queryFn would fail without a particular value gets an enabled guard for that value. Combine with the consumer's queryConfig.enabled using && so they can turn it off but not on.
Default settings cheat sheet
| Setting | Default | When to override |
|---|---|---|
staleTime | 1000 * 60 (1m) | Reference data → 1000 * 60 * 15 (15m) or longer. Real-time → 0. |
gcTime | 1000 * 60 * 10 | Infinite scroll / large lists → 0 (drop on unmount). |
retry | 2 | Mutations → 0. Idempotent reads with flaky network → 3. |
refetchOnWindowFocus | false (RN) / per-app (web) | Web apps where multi-tab freshness matters → true. |
refetchOnReconnect | true | Almost never override. |
placeholderData | unset | Paginated / filtered views → keepPreviousData to avoid flicker on filter change. |
meta.persist | unset | Set true for reference data / profile / lists you want warm on cold start. |
When to read which reference
Conventions in this skill cover the how. The upstream skill covers the why at the rule level — read it for deeper detail on any one topic.
Need full rationale on a specific rule (caching, invalidation, prefetching, SSR, retry, offline)
└─ references/upstream/SKILL.md (rule index)
└─ references/upstream/rules/<rule>.md (one rule per file)
Designing or refactoring a query-key factory
└─ references/query-key-factory.md
Writing a mutation, especially with optimistic updates
└─ references/mutations-and-invalidation.md
Prefetching on hover/route transitions, ensureQueryData, SSR hydration
└─ references/prefetch-and-ssr.md
Infinite scrolling / cursor pagination
└─ references/infinite-queries.md
Looking at a real file layout end-to-end
└─ examples/auth.{requests,keys,queries,mutations}.ts
└─ examples/posts.queries.ts
Silent-failure traps (inherited from upstream)
These fail silently — valid TypeScript, no error, wrong cache. Keep them in mind even when "just" applying conventions:
- Every key is serializable and includes every dependency. A result that varies by
userId,filter, orlocalemust carry each in its key, or the cache serves the wrong data. No functions,Date,Map,Set, or class instances in a key. (qk-serializable,qk-include-dependencies) staleTime≠gcTime. Stale = "may refetch on next use"; gc = "evict after N ms unused." (cache-stale-time,cache-gc-time)- Targeted invalidation beats broad. Invalidate the smallest scope that matches what changed:
[...all(), "list"]when only lists moved,all()only for a feature-wide change. (cache-invalidation) selecttransforms, it doesn't filter — it runs after structural sharing and won't cut re-renders when the input changes. (perf-select-transform)- SSR: one
QueryClientper request — a module-level client leaks data between users. (ssr-dehydration)
Full text + the rest of the rules in references/upstream/rules/.
Before you finish — quick self-check
Each catches a real, skippable mistake:
- Does the key live in
<feature>.keys.tsviaqueryOptions()— with noqueryKey: [...]literal in the hook or at any call site? - Does the hook take
{ queryConfig }/{ mutationConfig }typed withQueryConfig<typeof keys.x>/MutationConfig<typeof requestFn>? - Is the network call a typed function in
<feature>.requests.ts, not an inlinefetch/axios? - Does the mutation invalidate through the factory (never a stringly-typed prefix) —
all()for a broad write,[...all(), "<scope>"]for a targeted one? - Did you destructure
{ onSuccess, ...rest }so the consumer's callback fires after your invalidation, with...restbeforemutationFn? - Optimistic update: cancel → snapshot → write → rollback in
onError→ reconcile inonSuccess? - Is every query with a dependency (
token,id,filter) guarded byenabled: !!dep && queryConfig.enabled !== false? - Infinite query:
gcTime: 0, andgetNextPageParamreturningundefined(notnull) at the end?
Attribution
The references/upstream/ directory contains the TanStack Query Best Practices agent skill written by @DeckardGer. Source: https://github.com/DeckardGer/tanstack-agent-skills. Bundled here so this skill works offline and so the rule-by-rule rationale travels with the conventions. Credit for that material goes to the upstream author.