Workbench

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:

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:

use-blog-by-slug-query.tsx (deleted)
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:

src/lib/query-options/blogs.query-options.ts
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:

WhereHow it uses the same options
Client componentuseQuery(options) or useSuspenseQuery(options)
Server componentqueryClient.prefetchQuery(options)
generateMetadata, notFound() checksawait queryClient.fetchQuery(options)
After a mutationinvalidateQueries({ 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

On this page