On this page
A query cache is a model of remote information, not merely a way to avoid another fetch. Its keys decide which results are interchangeable. Its freshness policy decides when another observation is useful. Its invalidation rules decide what the interface believes after a mutation. Get those contracts right before tuning request counts.
Model the resource and its identity
Start with the exact inputs that change the server response: account, filters, pagination, locale, and permissions where relevant. Query identity must include the changing inputs used by the query function. Otherwise two different views can accidentally share one cache entry.
Inspect sanitized responses in the JSON Formatter. Compare list and detail shapes instead of assuming they contain identical records. A list may omit fields, include aggregates, or reflect a different permission scope. Those differences influence whether direct cache updates are safe.
TanStack Query is intended for server state. Keep transient menu state and unsaved drafts elsewhere. A query response is a snapshot from an external authority; an editor draft is a local proposal. Copying every response into a global client store creates two caches with no clear reconciliation policy.
Build complete and stable keys
Use arrays with a consistent hierarchy. Put the broad resource first, then its scope and parameters. A key factory can make related queries easier to invalidate without duplicating string conventions across components. Include every value that changes the response, but do not include incidental object identity or a callback.
import { useQuery } from '@tanstack/react-query';
const projectKeys = {
list: (accountId: string, page: number) =>
['projects', accountId, { page }] as const,
};
export function useProjects(accountId: string, page: number) {
return useQuery({
queryKey: projectKeys.list(accountId, page),
queryFn: async ({ signal }) => {
const params = new URLSearchParams({ accountId, page: String(page) });
const response = await fetch('/api/projects?' + params, { signal });
if (!response.ok) throw new Error('Project request failed');
return response.json();
},
staleTime: 30_000,
});
}The server still needs to authorize account access. A cache key is not an access-control mechanism. The response also needs runtime validation when its shape cannot be trusted; response.json alone does not provide it. Complete keys prevent data collisions, not malicious access.
The TanStack Query documentation describes key hashing and supported values. Prefer a small, serializable parameter object. Normalize equivalent filters deliberately: an omitted status and an explicit all status should either share identity or intentionally remain separate.
Separate freshness from retention
staleTime describes how long cached data is considered fresh. gcTime describes how long an inactive query can remain before garbage collection. They solve different problems. A long retention window does not guarantee that a query will avoid refetching when it becomes active again.
Choose freshness budgets from the product contract. A country list may tolerate a much longer interval than a shared task board. A balance display may need a deliberate refresh after a transaction regardless of the normal stale interval. No single number is correct for every endpoint.
Stale does not mean the interface must discard its existing data. It means the data is eligible for refetch under the library's configured triggers. Window focus, reconnecting, and mounting can influence that behavior. Consult the important defaults guide before interpreting a development request as a duplicate bug.
Avoid turning every automatic trigger off at the first unexpected request. First identify which trigger fired and whether the query key changed. Disabling all refresh behavior can make a shared application silently outdated. Document the unusual cases where a manual-only policy is intentional.
Invalidate according to domain consequences
After creating a project, invalidate lists that can now include it. After changing its title, consider detail views, search results, and summary widgets. Mutation consequences rarely stop at the button that submitted the request. Write down affected query families before choosing direct cache manipulation.
Invalidation marks matching queries stale and ordinarily refetches active matches according to the API and options. It is not the same as removing every cached record. Narrow matching avoids unnecessary work, while broad resource-level invalidation is a reasonable starting point when correctness is more important than fine tuning.
Directly setting cached data can be effective when the mutation returns an authoritative record. It is harder for paginated, filtered, or sorted lists: a title change may move a record out of one result set and into another. Do not append a result to every cached list unless those inclusion rules are actually known.
Use the invalidation guide for version-specific behavior. Await invalidation when the interaction must remain pending until affected views are refreshed. Otherwise be explicit that the operation is accepted while related data may update afterward. Neither choice should happen accidentally because a promise was forgotten.
Test cache behavior as user behavior
Exercise two accounts, several filter combinations, and back navigation. Confirm that a result for one scope never appears in another, including during transitions. Clear or replace relevant client caches when authentication identity changes. Session isolation includes cached browser data, not only server authorization.
Test a list, a detail view, and a mutation together. Assert that the updated record is visible after navigating back and that removed records do not reappear from an old cache entry. Use a new QueryClient for independent tests so earlier fixtures do not influence later expectations.
Inspect request timing rather than only the final screen. A component may look correct while fetching sequentially or refetching because its parameters are unstable. The development collection and React collection provide related implementation references, but the cache policy should remain documented beside the resource hooks.
Finally, keep a distinction between acceptable staleness and incorrect identity. A record that is thirty seconds old may satisfy the product contract. A current record from another account never does. Correct scope comes before reducing network traffic or extending freshness intervals.
Trace a mutation across two filtered views
Imagine an issue detail view and two lists: open issues and issues assigned to you. Closing an issue changes its detail status and removes it from the open list, but may leave it in the assigned list with different summary counts. Updating the detail cache alone cannot maintain all three contracts.
For a first implementation, write the canonical mutation response into the exact detail entry when its shape matches, then invalidate the affected list family. Let the server recompute membership, ordering, and totals. This costs requests but avoids reproducing every server filtering rule in browser code. Narrow invalidation later if measurements justify the complexity.
Test closing the last issue on a paginated page. A page can become empty even though earlier pages still contain records; the interface may need to move back one page after the refreshed result arrives. Also switch filters while the mutation is pending. Completion should target the mutated resource's keys, not whichever filter happens to be visible when the promise resolves.
Key takeaways
- Include all response-changing inputs in hierarchical query keys.
- Choose freshness separately from inactive retention and understand automatic refetch triggers.
- Invalidate according to domain consequences, and test account changes, navigation, filters, and mutations together.
Reach designers & developers at work
Put your product next to the tools people use every day. One sponsor per placement, no tracking pixels.
Become a sponsor