Query keys and options
One key factory with shared prefixes, normalised inputs, a separate key for infinite queries, and query options defined once.
From: all three projects. The key examples come from the e-commerce platform; the query options come from the resort website and the company website with its CMS.
A key factory in one file
Query keys are never written inline. They come from one object in
src/configs/query-keys.ts:
export const queryKeys = {
orderHistory: {
all: ['order-history'] as const,
list: (page: number) => [...queryKeys.orderHistory.all, 'list', page] as const,
infinite: () => [...queryKeys.orderHistory.all, 'infinite'] as const,
},
product: {
suggestions: (query: string) => ['product', 'suggestions', query] as const,
},
} as const;Shared prefixes make invalidation simple
TanStack Query invalidates by prefix. Because every order history key starts
with ['order-history'], one call refreshes the desktop table and the mobile
list together:
queryClient.invalidateQueries({ queryKey: queryKeys.orderHistory.all });After checkout, that's the only line needed. Nobody has to remember which screens show orders.
Same endpoint, different key, for infinite queries
Never share a key between useQuery and useInfiniteQuery
useQuery stores one page. useInfiniteQuery stores
{ pages, pageParams }. If both use the same key, one hook reads data in
the other one's shape.
The desktop table and the mobile list call the same API, but list(page) and
infinite() are different keys. They still share the prefix, so invalidation
works for both.
Normalise inputs before they go in the key
const normalizedQuery = query.trim();
// queryKey: queryKeys.product.suggestions(normalizedQuery)"nike" and "nike " are the same search. Trimming before building the key gives them one cache entry instead of two.
Filters in the key reset lists for free
Put filters, search text and sort order into the key:
const blogParams = { search: debouncedSearch.trim() || undefined, dir };
// key: ['blogs', 'list', blogParams, 'infinite']When a filter changes, the key changes, and TanStack Query starts a fresh
list from initialPageParam. No code has to reset a page counter by hand.
Query options instead of custom hooks
Every GET gets a queryOptions() factory in
src/lib/query-options/<feature>.query-options.ts. The key, fetcher and
staleTime are defined once, and every caller reuses them.
In the resort website's admin dashboard, the blog editor started with a custom hook:
export function useBlogBySlugQuery(slug: string) {
return useQuery({
queryKey: QUERY_KEYS.blogs.detail(slug),
queryFn: () => getBlogBySlug(slug),
enabled: !!slug,
});
}I replaced it with query options next to the blog list's:
export const blogBySlugQueryOptions = (slug: string) =>
queryOptions({
queryKey: QUERY_KEYS.blogs.detail(slug),
queryFn: () => getBlogBySlug(slug),
enabled: !!slug,
});const { data, isLoading } = useQuery(blogBySlugQueryOptions(slug));A hook only works inside a component. Query options work everywhere a query is needed:
| Where | How it uses the same options |
|---|---|
| Client component | useQuery(options) or useSuspenseQuery(options) |
| Server component | queryClient.prefetchQuery(options) |
generateMetadata, notFound() checks | await queryClient.fetchQuery(options) |
| After a mutation | invalidateQueries({ queryKey: options.queryKey }) |
queryOptions() also carries the return type, so useQuery(options).data and
queryClient.getQueryData(options.queryKey) are typed without generics.
Same options on the server and the client
If the server prefetches with one set of params and the client reads with another, the keys differ and the client fetches again. See Server prefetching.
Last updated on