Designing Stable Query Keys for React Query

Unstable query keys are the leading cause of cache thrashing in TanStack Query applications: phantom refetch loops fire on every render cycle, invalidateQueries silently misses its targets, and the UI displays stale payloads long after the server has updated. This page shows how to diagnose those symptoms and replace ad-hoc key construction with a deterministic factory pattern. It is a concrete implementation guide within Entity Mapping Strategies, which covers bridging raw API responses to normalized cache stores. If you are also dealing with pagination keys that drift across pages, see Normalizing Cursor-Based Pagination for how these same factory principles extend to cursor state.

Diagnostic checklist

Before implementing a key factory, confirm that key instability is the actual problem:

  • Network tab shows identical GET requests firing on consecutive renders with no prop or state change.
  • invalidateQueries({ queryKey: ['users'] }) succeeds (no error) but the UI does not refetch.
  • TanStack Query DevTools shows two or more cache entries for what should be the same query, with divergent dataUpdatedAt timestamps.
  • Filters or pagination return incorrect results that swap between renders, indicating a cache collision.
  • TypeScript offers no type error when you misspell a key segment, letting production key drift go undetected.

How TanStack Query matches keys

Before building the factory, it helps to know exactly what the library compares. Understanding the data normalization fundamentals behind this mechanism prevents misdiagnoses.

TanStack Query stores each query under a serialized representation of its queryKey array. The matching algorithm used by invalidateQueries, prefetchQuery, and getQueryData performs a structural deep comparison, not reference equality. This means { status: 'active' } and { status: 'active' } (two separate object literals) are treated as the same key — the reference trap is not the root cause of fragmentation.

The actual root causes are:

  1. Non-deterministic object key order. { sort: 'name', status: 'active' } and { status: 'active', sort: 'name' } serialize differently in TanStack Query’s internal hasher, producing two distinct cache entries.
  2. Non-deterministic values in the key. Date.now(), Math.random(), or component state that changes on every mount embed a unique value per render, creating an ever-growing cache.
  3. undefined vs null inconsistency. JSON.stringify({ a: undefined }) produces {} while { a: null } produces {"a":null}. Inconsistent optional parameter handling causes hash divergence across call sites.

The diagram below maps the key construction path to each failure mode:

Query key construction flow A flow diagram showing how query keys are constructed and serialized, with deterministic and non-deterministic paths leading to cache hits or cache fragmentation. Component renders useQuery({ queryKey }) Key construction inline object literal? unsorted / volatile Non-deterministic Date.now() / unsorted object entries Cache fragmentation phantom refetches / stale UI factory pattern Key factory sort entries · strip undefined TQ serializer structural deep compare Stable cache hit invalidation propagates correctly invalidateQueries prefix match against stored key array resolves to same hash

Step-by-step implementation

Step 1 — Diagnose with DevTools before changing any code

  1. Open the TanStack Query DevTools panel (the floating button at the bottom of your app in development).
  2. Search for the query family by typing a partial key prefix such as users into the filter bar.
  3. Trigger a parent component re-render (toggle an unrelated piece of UI state).
  4. Watch the fetchStatus column. If it toggles to fetching without any prop change, the key is regenerating a new structural value.
  5. Click the query entry and expand its metadata. Examine the queryKey array. Check whether it contains an object whose entries could arrive in different orders across call sites.
  6. Run queryClient.getQueryCache().getAll() in the browser console. If you see two entries with logically identical parameters but different serialized keys, object key order is the culprit.

Cache Behavior Analysis. TanStack Query v5 hashes query keys using a depth-first traversal of the array. Object entries inside the array are iterated in insertion order. Two objects with the same key-value pairs but different insertion orders produce different hash values and are stored as separate cache entries. This is why DevTools shows duplicate entries with divergent dataUpdatedAt values.


Step 2 — Replace inline literals with a typed key factory

Create a dedicated module for your entity’s keys. Co-locate it with the API service layer so there is one authoritative source for every key variant.

// src/api/users/keys.ts

type UserFilters = {
  status?: 'active' | 'inactive' | 'pending';
  role?: string;
  sort?: string;
};

// Normalize filters: remove undefined values and sort entries by key name.
function normalizeFilters(
  params: UserFilters
): Record<string, string> {
  return Object.fromEntries(
    Object.entries(params)
      .filter(([, v]) => v !== undefined)
      .sort(([a], [b]) => a.localeCompare(b))
  ) as Record<string, string>;
}

export const userKeys = {
  all: ['users'] as const,

  lists: () => [...userKeys.all, 'list'] as const,

  list: (filters: UserFilters) =>
    [...userKeys.lists(), normalizeFilters(filters)] as const,

  details: () => [...userKeys.all, 'detail'] as const,

  detail: (id: string) => [...userKeys.details(), id] as const,
} as const;

Cache Behavior Analysis. The normalizeFilters function produces an object whose entries are always in alphabetical key order. TanStack Query’s hash traversal now encounters the same insertion order every time, regardless of which call site built the original UserFilters object. The hierarchical shape — ['users']['users', 'list']['users', 'list', { ... }] — enables prefix-based invalidateQueries calls to cascade correctly: invalidating userKeys.all marks every user query stale, while userKeys.lists() targets only list variants, leaving detail queries untouched.

Trade-offs.

  • normalizeFilters adds a small allocation per render. At typical query-key generation frequency (once per component mount, not per frame) this is negligible — but avoid calling it inside a queryFn that runs at high frequency.
  • The as const assertion narrows types to literal tuples, which improves TypeScript inference in useQuery and invalidateQueries but can make factory return types verbose to annotate manually. Let TypeScript infer them.

Step 3 — Wire the factory into queries and mutations

// src/features/users/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { userKeys } from '../../api/users/keys';
import { fetchUsers, fetchUser, updateUser } from '../../api/users/service';

export function useUserList(filters: { status?: string; sort?: string }) {
  return useQuery({
    queryKey: userKeys.list(filters),
    queryFn: () => fetchUsers(filters),
    staleTime: 30_000,   // serve from cache for 30 s before background refetch
    gcTime: 5 * 60_000,  // keep unused entries for 5 min before garbage collection
  });
}

export function useUserDetail(id: string) {
  return useQuery({
    queryKey: userKeys.detail(id),
    queryFn: () => fetchUser(id),
    staleTime: 60_000,
  });
}

export function useUpdateUser() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: updateUser,
    onSuccess: (updated) => {
      // Update the detail entry immediately without a network round-trip.
      qc.setQueryData(userKeys.detail(updated.id), updated);
      // Mark all list variants stale so the next render triggers a background refetch.
      qc.invalidateQueries({ queryKey: userKeys.lists() });
    },
  });
}

Cache Behavior Analysis. setQueryData(userKeys.detail(updated.id), updated) writes directly to the cache under the exact key the useUserDetail hook uses, so any mounted detail view updates synchronously without a network call. invalidateQueries({ queryKey: userKeys.lists() }) marks stale all entries whose stored key starts with ['users', 'list'] — the prefix match works because the factory produces the same ['users', 'list'] prefix regardless of filter arguments. If the factory had used unsorted inline objects, the stored keys would not share a consistent prefix and invalidation would silently miss them.

Trade-offs.

  • Setting staleTime on list queries to a lower value than gcTime lets React Query serve a cached list instantly on navigation back, then refetch in the background — controlled by the staleTime window. Choose a staleTime that matches your data’s real-world update frequency.
  • structuralSharing (enabled by default) means React Query only triggers re-renders when the returned data reference changes. Combined with stable keys, components that receive structurally identical payloads across refetches will not re-render at all.

Edge cases and gotchas

Undefined filter parameters cause key divergence across environments

Different call sites may omit optional filter keys entirely or pass them as undefined. In JavaScript, { status: undefined } and {} are not equal when traversed by TanStack Query’s hasher because the former has a key with an undefined value. The normalizeFilters helper in Step 2 strips these explicitly with .filter(([, v]) => v !== undefined). Verify this by adding a test:

import { userKeys } from './keys';

test('omitting vs passing undefined produces the same key', () => {
  expect(userKeys.list({})).toEqual(
    userKeys.list({ status: undefined })
  );
});

Boolean and number parameters serialize differently than strings

TanStack Query’s hasher preserves type: { page: 1 } and { page: '1' } are distinct. If your API layer coerces numeric query-string parameters to strings at the boundary, your key factory must coerce them too. Apply coercion inside normalizeFilters rather than at each call site.

Keys containing class instances or Dates are never stable

new Date() produces a new object reference on every call and its structural hash includes the millisecond timestamp. If your filter type allows Date fields, convert them to ISO strings (date.toISOString()) inside normalizeFilters before including them in the key. See how flattening deeply nested GraphQL responses handles analogous type-coercion at the normalization boundary.


Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
invalidateQueries runs without error but the UI does not refetch The stored key’s object entries are in a different order than the invalidation prefix, so the prefix match fails silently Log queryClient.getQueryCache().getAll().map(q => q.queryKey) before and after invalidation; compare serialized forms with JSON.stringify; add canonical sort to the factory
Infinite refetch loop on route transitions The queryKey array contains a value derived from component state or Date.now() that changes on every mount Move the volatile value into the queryFn body or a select transform; keep queryKey strictly derived from stable, serializable props
Two users with the same filters see each other’s cached data Session or tenant context is not included in the key, so queries from different sessions share one cache entry Add the tenant or user ID as the second array segment: ['users', tenantId, 'list', filters]

Frequently Asked Questions

Should I use objects or arrays for React Query keys?

Always use arrays as the top-level structure. TanStack Query’s invalidation and matching APIs (invalidateQueries, resetQueries, prefetchQuery) rely on array prefix matching — only the array form supports the hierarchical all → lists → detail pattern. Objects within the array are acceptable, but they must have deterministic entry order. A factory function that sorts entries alphabetically is the safest approach and the only way to guarantee correctness across call sites.

How do I debug a query that keeps refetching unexpectedly?

Open TanStack Query DevTools and filter to the relevant query family. Trigger a parent re-render and watch whether fetchStatus toggles to fetching without a prop change. If it does, add console.log(JSON.stringify(queryKey)) to the component and compare the output across renders — any difference in the stringified output confirms an unstable value in the key. Common culprits: inline object literals with volatile entries, new Date() calls, or spread of an array that gets rebuilt on every render. Move the stable version of each value into a useMemo or, better, remove it from the key entirely and derive it inside queryFn.

What is the performance cost of deeply nested query keys?

Negligible for reads. TanStack Query traverses the key once on storage and once on lookup; depth adds constant-factor cost, not algorithmic complexity. The larger concern is invalidation scan time, which is proportional to the number of active queries in the cache — controlled by gcTime. Flatten identifiers into strings where possible (e.g. ['users', 'detail', id] instead of ['users', { type: 'detail', id }]) to simplify prefix-based invalidation and manual DevTools inspection without sacrificing correctness.