Build CoreOrdered learning track

Query Keys, Resource Identity, and Cache Addressing

Learn React Client-Server Communication - Part 023

A production-grade guide to query keys, resource identity, cache addressing, cache scope, invalidation target design, and key factories in React server-state architecture.

17 min read3361 words
PrevNext
Lesson 2372 lesson track14–39 Build Core
#react#client-server#query-keys#server-state+3 more

Part 023 — Query Keys, Resource Identity, and Cache Addressing

Target mental model: a query key is not a label. It is the address of a server-state replica. If the address is wrong, every other cache behavior becomes suspicious: dedupe, refetch, invalidation, optimistic update, pagination, tenant isolation, and permission safety.

In the previous part, we separated server state from client state.

Now we turn that ownership model into something concrete:

resource identity -> query key -> cache entry -> observer -> rendered view

A React app does not merely “fetch data”. It maintains many temporary replicas of remote facts.

Those replicas need addresses.

That address is usually called a query key.

The name sounds small. The consequence is large.

A weak query key design causes:

  • stale data shown under the wrong filter;
  • user A seeing data from user B after account switch;
  • detail pages not updating after list mutations;
  • duplicate requests because two teams used different key shapes;
  • over-invalidation that refetches the world;
  • under-invalidation that leaves old data visible;
  • infinite-query data colliding with normal query data;
  • impossible optimistic updates because cache entries cannot be targeted;
  • debugging sessions where DevTools shows “many queries” but nobody knows what each means.

This part is about building a cache address system that survives real application scale.


1. Query Key as Cache Address

A query key answers one question:

“Which exact remote data replica does this cache entry represent?”

Not:

  • which component uses it;
  • which screen displays it;
  • which API function called it;
  • which hook name produced it;
  • which variable happened to be nearby.

A good query key describes remote resource identity plus representation parameters.

For example:

['users', 'detail', userId]
['users', 'list', { tenantId, status, page, sort }]
['cases', 'timeline', caseId, { includeSystemEvents: true }]
['reports', 'summary', { from, to, jurisdiction }]

A bad query key describes only local code structure:

['data']
['page']
['table']
['user']
['modal-query']
['dashboard']

Those keys are not addresses. They are nicknames.

Nicknames collide.


2. A Query Key Is Part of the Contract

A backend API contract says:

GET /api/cases?status=open&assignedTo=123

A frontend cache contract must say:

['cases', 'list', { status: 'open', assignedTo: '123' }]

If the API changes but the key does not, cache behavior lies.

If the query function depends on a variable but the key does not include it, the cache can return a replica for the wrong input.

// Broken: tenantId affects the request but not the key.
function useCases(tenantId: string) {
  return useQuery({
    queryKey: ['cases'],
    queryFn: () => api.getCases({ tenantId }),
  })
}

The query function has hidden input.

The fix is to make every identity-changing input visible in the key:

function useCases(tenantId: string) {
  return useQuery({
    queryKey: ['tenants', tenantId, 'cases', 'list'],
    queryFn: () => api.getCases({ tenantId }),
  })
}

Invariant:

If changing a value changes what data should be returned, that value belongs in the query key.

There are exceptions for non-identity execution controls such as timeout, signal, tracing context, or retry metadata. Those affect execution, not resource identity.


3. Identity vs Representation vs Execution

Many query-key bugs happen because engineers mix three categories.

Identity

Identity decides which resource.

Examples:

  • tenantId
  • caseId
  • userId
  • invoiceId
  • authenticated subject, when data is personalized
  • jurisdiction, when resource namespace changes

Representation

Representation decides which view of the resource.

Examples:

  • filters;
  • sort;
  • page cursor;
  • locale;
  • selected fields;
  • includeArchived;
  • aggregation granularity;
  • time range;
  • API version or shape version.

Representation usually belongs in the key because a different representation is a different cache entry.

Execution

Execution decides how the request is performed.

Examples:

  • AbortSignal;
  • timeout;
  • retry count;
  • tracing header;
  • request priority;
  • network mode;
  • diagnostics flag that does not alter response body.

Execution usually does not belong in the key.

type CaseListIdentity = {
  tenantId: string
  status?: 'open' | 'closed'
  assignedTo?: string
  cursor?: string
  sort?: 'created-desc' | 'priority-desc'
}

type CaseListExecution = {
  signal?: AbortSignal
  timeoutMs?: number
  traceId?: string
}

Keep these objects separate.

const caseKeys = {
  list: (identity: CaseListIdentity) =>
    ['cases', 'list', normalizeCaseListIdentity(identity)] as const,
}

async function fetchCases(identity: CaseListIdentity, execution: CaseListExecution = {}) {
  return api.get('/cases', {
    query: identity,
    signal: execution.signal,
    timeoutMs: execution.timeoutMs,
    headers: execution.traceId ? { 'x-trace-id': execution.traceId } : undefined,
  })
}

This separation prevents accidental cache fragmentation.


4. The Cache Is a Map

At the simplest level, a query cache behaves like this:

type QueryHash = string

type QueryCache = Map<QueryHash, QueryState>

The key is structured:

['cases', 'list', { status: 'open', page: 1 }]

The cache eventually hashes it into an internal address.

That means a key must be:

  • deterministic;
  • serializable;
  • stable across renders;
  • unique to the data it represents;
  • precise enough for invalidation;
  • broad enough for grouped invalidation.

The key is not merely for fetching. It is also used for:

  • cache lookup;
  • request deduplication;
  • observer subscription;
  • background refetch;
  • invalidation;
  • optimistic update;
  • cache hydration;
  • persistence;
  • DevTools inspection;
  • garbage collection;
  • debugging.

This is why key design belongs in architecture, not in a random hook body.


5. The Two Failure Modes: Collision and Fragmentation

Bad keys usually fail in two opposite ways.

Collision

A collision happens when different resources share one key.

useQuery({
  queryKey: ['invoice'],
  queryFn: () => getInvoice(invoiceId),
})

invoiceId is hidden. Invoice A and invoice B can occupy the same address.

Symptoms:

  • detail page flashes previous item;
  • wrong entity appears after navigation;
  • invalidation refetches unexpected data;
  • QA reports “sometimes it shows the last record I opened”.

Fragmentation

Fragmentation happens when the same resource is represented by many inconsistent keys.

['invoice', invoiceId]
['invoices', invoiceId]
['invoice-detail', { id: invoiceId }]
['billing', 'invoice', invoiceId]
['tenant', tenantId, 'invoice', invoiceId]

Symptoms:

  • mutation updates one cache entry but not another;
  • duplicate requests for the same data;
  • inconsistent UI between sidebar and detail page;
  • invalidation has to target five possible keys;
  • teams stop trusting the cache.

Collision is unsafe.

Fragmentation is expensive.

Good key design avoids both.


6. Query Key Shape: Hierarchical, Predictable, Intentional

A scalable key has a hierarchy.

['cases']
['cases', 'list']
['cases', 'list', { tenantId, status }]
['cases', 'detail', caseId]
['cases', 'detail', caseId, 'timeline']
['cases', 'detail', caseId, 'attachments']

The hierarchy gives you targeting power.

queryClient.invalidateQueries({ queryKey: ['cases'] })
queryClient.invalidateQueries({ queryKey: ['cases', 'list'] })
queryClient.invalidateQueries({ queryKey: ['cases', 'detail', caseId] })

Design the hierarchy based on invalidation operations you expect to perform.

Do not design it only around endpoint URLs.

Endpoint:

GET /api/cases/:caseId/timeline

Possible key:

['cases', 'detail', caseId, 'timeline']

This key says timeline belongs under the case detail namespace.

That lets a mutation on the case invalidate the right subtrees.


7. Key Factories

Do not write raw query keys everywhere.

Create key factories.

export const caseKeys = {
  all: ['cases'] as const,

  lists: () => [...caseKeys.all, 'list'] as const,
  list: (params: CaseListParams) =>
    [...caseKeys.lists(), normalizeCaseListParams(params)] as const,

  details: () => [...caseKeys.all, 'detail'] as const,
  detail: (caseId: string) => [...caseKeys.details(), caseId] as const,

  timeline: (caseId: string, params?: TimelineParams) =>
    [...caseKeys.detail(caseId), 'timeline', normalizeTimelineParams(params)] as const,

  attachments: (caseId: string) =>
    [...caseKeys.detail(caseId), 'attachments'] as const,
}

Then use the factory everywhere:

function useCase(caseId: string) {
  return useQuery({
    queryKey: caseKeys.detail(caseId),
    queryFn: ({ signal }) => caseApi.getCase(caseId, { signal }),
  })
}

And invalidation becomes consistent:

await queryClient.invalidateQueries({
  queryKey: caseKeys.detail(caseId),
})

A key factory is not ceremony. It is a naming registry for your cache address space.


8. Normalize Key Parameters

A query key should be stable for equivalent inputs.

These two filters are semantically equivalent:

{ status: 'open', assignedTo: undefined }
{ status: 'open' }

If they create different keys, you get fragmentation.

Create normalizers.

function compactObject<T extends Record<string, unknown>>(input: T) {
  return Object.fromEntries(
    Object.entries(input).filter(([, value]) => value !== undefined && value !== null && value !== '')
  )
}

function normalizeCaseListParams(params: CaseListParams) {
  return compactObject({
    tenantId: params.tenantId,
    status: params.status ?? 'open',
    assignedTo: params.assignedTo,
    sort: params.sort ?? 'created-desc',
    pageSize: params.pageSize ?? 50,
    cursor: params.cursor,
  })
}

But be careful: normalization must match backend semantics.

If backend treats missing status differently from status=open, do not normalize them into the same key.

Key normalization is a semantic decision, not just a cleanup utility.


9. Avoid Non-Serializable Values

Query keys should not contain:

  • functions;
  • class instances;
  • DOM nodes;
  • AbortSignal;
  • mutable Date objects without normalization;
  • Map/Set unless converted;
  • objects with unstable prototypes;
  • values that change identity every render while meaning the same thing.

Prefer primitives and plain JSON-like objects.

// Better than Date object in a key.
const key = ['reports', 'summary', { from: from.toISOString(), to: to.toISOString() }]

For money, use minor units and currency:

['pricing', 'quote', { amountMinor: 125000, currency: 'IDR' }]

For locale:

['content', 'detail', articleId, { locale: 'id-ID' }]

For arrays where order is not meaningful, sort them.

function normalizeTags(tags: string[]) {
  return [...new Set(tags)].sort()
}

['cases', 'list', { tags: normalizeTags(tags) }]

If order is meaningful, do not sort.


10. Tenant, Auth, and Permission Scope

A cache key must respect data boundaries.

In multi-tenant or role-sensitive apps, the same endpoint and resource id may not mean the same data for every subject.

['cases', 'detail', caseId]

This key may be unsafe if caseId is only unique within a tenant or if response shape depends on role.

Safer:

['tenants', tenantId, 'cases', 'detail', caseId]

If the same user switches tenant, cache entries remain separated.

For authorization-sensitive projections:

['tenants', tenantId, 'cases', 'detail', caseId, { permissionView: permissionVersion }]

But do not put raw permission arrays in every key by default. That creates large keys and high fragmentation.

Prefer a stable permission context version when needed.

type SessionCacheScope = {
  userId: string
  tenantId: string
  permissionVersion: string
}

Then key critical personalized data under the scope:

const scopeKeys = {
  root: (scope: SessionCacheScope) =>
    ['session-scope', scope.userId, scope.tenantId, scope.permissionVersion] as const,
}

const caseKeys = {
  detail: (scope: SessionCacheScope, caseId: string) =>
    [...scopeKeys.root(scope), 'cases', 'detail', caseId] as const,
}

This is not always necessary for public data. It is often necessary for regulated or tenant-aware systems.

Rule:

If two users are not allowed to share a cached response, their keys must not collide.


11. Personalization Is Resource Identity

Consider:

GET /api/me/dashboard

The endpoint has no path parameter.

But the response depends on the authenticated user.

This key is weak:

['dashboard']

This key is safer:

['users', userId, 'dashboard']

A common objection:

“But when the user changes, we clear the cache anyway.”

That can work, but it is an operational dependency. If logout, account switch, tab restore, or persisted cache is imperfect, the key collision becomes a data leak.

Key scoping is defense in depth.


12. Query Key and URL State

Bookmarkable server data is often driven by URL state.

/cases?status=open&assignedTo=me&sort=priority-desc

The URL query params should parse into the same canonical object used by the query key.

function parseCaseSearchParams(searchParams: URLSearchParams): CaseListParams {
  return normalizeCaseListParams({
    status: searchParams.get('status') ?? undefined,
    assignedTo: searchParams.get('assignedTo') ?? undefined,
    sort: searchParams.get('sort') ?? undefined,
    cursor: searchParams.get('cursor') ?? undefined,
  })
}

function useCaseListFromUrl() {
  const [searchParams] = useSearchParams()
  const params = parseCaseSearchParams(searchParams)

  return useQuery({
    queryKey: caseKeys.list(params),
    queryFn: ({ signal }) => caseApi.list(params, { signal }),
  })
}

Invariant:

If URL state identifies remote data, URL parsing and query-key construction should share canonicalization rules.

Do not let one component parse defaults differently from another.


13. Detail Key vs List Key

Detail and list queries represent different cache entries.

['cases', 'detail', caseId]
['cases', 'list', { status: 'open' }]

A list may contain a partial case representation.

A detail query may contain a full representation.

Do not pretend they are the same shape unless the API guarantees it.

Bad:

queryClient.setQueryData(['cases', 'detail', updated.id], updated)
queryClient.setQueryData(['cases', 'list'], updated) // wrong shape

Better:

queryClient.setQueryData(caseKeys.detail(updated.id), updated)

queryClient.setQueriesData(
  { queryKey: caseKeys.lists() },
  (old: CaseListPage | undefined) => {
    if (!old) return old
    return {
      ...old,
      items: old.items.map((item) =>
        item.id === updated.id ? { ...item, status: updated.status, title: updated.title } : item
      ),
    }
  }
)

You update each cache entry according to its representation.


14. List Key Design

List queries are tricky because list identity includes filters, sort, pagination, search, and projection.

['cases', 'list', {
  tenantId,
  status,
  assignedTo,
  q,
  sort,
  pageSize,
  cursor,
}]

Ask these questions:

  1. Does changing this value change the returned items?
  2. Does changing this value change item order?
  3. Does changing this value change item fields?
  4. Does changing this value change authorization visibility?
  5. Does changing this value change page boundaries?

If yes, it belongs in the key.

Search input nuance

Raw input should not always be the key.

const [draft, setDraft] = useState('')
const debounced = useDebouncedValue(draft, 300)

const query = useQuery({
  queryKey: caseKeys.list({ q: debounced }),
  queryFn: ({ signal }) => caseApi.search({ q: debounced }, { signal }),
})

The draft is client state.

The debounced value is server-state identity.


15. Infinite Query Keys

An infinite query has a different data structure than a normal query.

Normal query:

Case[]

Infinite query:

{
  pages: CasePage[]
  pageParams: unknown[]
}

Do not reuse the same key for both.

Bad:

useQuery({ queryKey: ['cases'], queryFn: fetchCases })
useInfiniteQuery({ queryKey: ['cases'], queryFn: fetchCasePages })

Better:

const caseKeys = {
  list: (params: CaseListParams) => ['cases', 'list', normalizeCaseListParams(params)] as const,
  infiniteList: (params: CaseListParams) => ['cases', 'infinite-list', normalizeCaseListParams(params)] as const,
}

The key must encode the representation shape.


16. Pagination: Page Number vs Cursor

Page-number pagination:

['cases', 'list', { status: 'open', page: 3, pageSize: 50 }]

Cursor pagination:

['cases', 'list', { status: 'open', cursor: 'eyJpZCI6...' }]

Infinite queries often keep the base filter in the key and page cursor in page params.

useInfiniteQuery({
  queryKey: caseKeys.infiniteList({ status: 'open', pageSize: 50 }),
  initialPageParam: null as string | null,
  queryFn: ({ pageParam, signal }) =>
    caseApi.list({ status: 'open', pageSize: 50, cursor: pageParam }, { signal }),
  getNextPageParam: (lastPage) => lastPage.nextCursor,
})

Here, cursor is execution state managed by the infinite query, not part of the base key.

But for normal page-specific queries, page/cursor is part of the key.

Design depends on cache representation.


17. Projection and Field Selection

If your API supports field selection, projection belongs in the key.

GET /api/users/123?fields=id,name,email
GET /api/users/123?fields=id,name,avatarUrl

These are different representations.

['users', 'detail', userId, { fields: ['avatarUrl', 'id', 'name'] }]

Normalize fields if order is not meaningful.

function normalizeFields(fields: string[]) {
  return [...new Set(fields)].sort()
}

Do not cache a partial projection as if it were full detail.

Bad:

['users', 'detail', userId]

for both:

{ id, name }

and:

{ id, name, email, permissions, auditMetadata }

That creates accidental partial data.


18. Locale, Time Zone, and Formatting

Some data changes by locale or timezone.

Examples:

  • server-formatted currency;
  • localized content;
  • date buckets based on local timezone;
  • business-day calculations;
  • jurisdiction-specific terminology;
  • translated enum labels.

If the server response changes with locale or timezone, include it.

['reports', 'daily-summary', { from, to, timeZone: 'Asia/Jakarta' }]
['content', 'article', articleId, { locale: 'id-ID' }]

If formatting is purely local and derived from canonical server data, do not include formatting preferences in the server-state key.

Example:

// Server returns amountMinor + currency. UI formats locally.
['invoice', 'detail', invoiceId]

Do not include displayCurrencyFormat unless it changes the fetched response.


19. Versioned Data and Contract Versions

Sometimes the same logical resource has multiple contract shapes.

['cases', 'detail', caseId, { contractVersion: 2 }]

Use this sparingly.

Better long-term options:

  • endpoint versioning;
  • schema migration layer in API client;
  • backwards-compatible response fields;
  • normalized domain DTO in frontend.

But during migration, key versioning can prevent shape collision.

const caseKeys = {
  detailV1: (id: string) => ['cases', 'detail', id, { v: 1 }] as const,
  detailV2: (id: string) => ['cases', 'detail', id, { v: 2 }] as const,
}

Use a temporary version marker when old and new query functions return incompatible shapes under the same conceptual resource.


20. Cache Key and ETag Are Different

An HTTP ETag identifies a version of a representation on the server.

A query key identifies a cache entry in the client.

They are related but not the same.

Query key: ['cases', 'detail', 'case-123']
ETag: "v42-a9d3"

A cache entry can store the ETag as metadata or part of the response.

type CaseDetailCacheValue = {
  data: CaseDetail
  etag?: string
}

Do not put ETag into the query key by default.

If you do:

['cases', 'detail', caseId, { etag }]

you create a new cache entry for every version, which usually defeats the purpose.

ETag is for validation and concurrency.

Query key is for addressing the current replica.


21. Invalidation Starts at Key Design

Invalidation is not an afterthought.

You should know how a mutation will invalidate related queries before shipping the key.

Mutation:

POST /api/cases/:id/assign

Likely affected cache entries:

  • case detail;
  • case list where assigned user/filter might include/exclude it;
  • assignee workload summary;
  • case timeline;
  • dashboard counters;
  • notifications.

A usable key design makes this expressible:

await Promise.all([
  queryClient.invalidateQueries({ queryKey: caseKeys.detail(caseId) }),
  queryClient.invalidateQueries({ queryKey: caseKeys.lists() }),
  queryClient.invalidateQueries({ queryKey: workloadKeys.byUser(assigneeId) }),
  queryClient.invalidateQueries({ queryKey: dashboardKeys.counters(scope) }),
])

If all keys are ['data'], invalidation is impossible.

If every list has a bespoke key shape, invalidation is brittle.


22. Targeted Update vs Invalidation

After a mutation, you can:

  1. invalidate and refetch;
  2. directly update cache;
  3. optimistically update then reconcile;
  4. remove cache entries;
  5. do nothing if mutation response is unrelated.

Key design affects all of them.

Invalidate

queryClient.invalidateQueries({ queryKey: caseKeys.detail(caseId) })

Good when server has complex derived changes.

Direct update

queryClient.setQueryData(caseKeys.detail(caseId), updatedCase)

Good when mutation response is authoritative for that cache shape.

queryClient.setQueriesData(
  { queryKey: caseKeys.lists() },
  updateCaseInAnyList(updatedCase)
)

Good when list projections can be safely patched.

Remove

queryClient.removeQueries({ queryKey: caseKeys.detail(deletedCaseId) })

Good when resource no longer exists.

The key hierarchy defines your control surface.


23. Query Key Factories with TypeScript

Key factories should produce typed, immutable tuples.

type TenantId = string & { readonly __brand: 'TenantId' }
type CaseId = string & { readonly __brand: 'CaseId' }

export const tenantKeys = {
  all: ['tenants'] as const,
  tenant: (tenantId: TenantId) => [...tenantKeys.all, tenantId] as const,
}

export const caseKeys = {
  all: (tenantId: TenantId) =>
    [...tenantKeys.tenant(tenantId), 'cases'] as const,

  lists: (tenantId: TenantId) =>
    [...caseKeys.all(tenantId), 'list'] as const,

  list: (tenantId: TenantId, params: CaseListParams) =>
    [...caseKeys.lists(tenantId), normalizeCaseListParams(params)] as const,

  detail: (tenantId: TenantId, caseId: CaseId) =>
    [...caseKeys.all(tenantId), 'detail', caseId] as const,
}

This prevents accidentally passing userId where tenantId belongs if you use branded types consistently.

Even without branded types, centralizing key creation prevents drift.


24. Query Key Factories Should Live Near Domain API Modules

Avoid scattering keys in component folders.

A better structure:

src/
  domains/
    cases/
      case.api.ts
      case.keys.ts
      case.queries.ts
      case.mutations.ts
      case.types.ts

Example:

// case.keys.ts
export const caseKeys = { ... }

// case.api.ts
export async function getCase(tenantId: string, caseId: string, options?: RequestOptions) { ... }

// case.queries.ts
export function useCase(tenantId: string, caseId: string) {
  return useQuery({
    queryKey: caseKeys.detail(tenantId, caseId),
    queryFn: ({ signal }) => getCase(tenantId, caseId, { signal }),
  })
}

Components should consume domain hooks.

function CaseHeader({ tenantId, caseId }: Props) {
  const caseQuery = useCase(tenantId, caseId)
  // render
}

Components should rarely invent cache addresses.


25. Resource Identity Is Not Endpoint Identity

Sometimes multiple endpoints represent the same resource.

GET /api/users/123
GET /api/me

If authenticated user is 123, these can overlap.

Should they share a key?

Maybe.

Ask:

  1. Are the response shapes identical?
  2. Are the authorization semantics identical?
  3. Are freshness requirements identical?
  4. Are invalidation rules identical?
  5. Will a mutation response update both safely?

If yes, you can map them to the same domain cache representation.

If not, keep separate keys.

['users', 'detail', userId]
['session', 'me']

Then coordinate invalidation explicitly after profile changes.

Endpoint identity is an implementation detail.

Cache identity is a client data model decision.


26. Aggregate Queries

Dashboards, summaries, counts, and reports are not lists.

['dashboard', 'summary', { tenantId, from, to }]
['cases', 'counts', { tenantId, statusGroup: 'queue' }]
['reports', 'sla-breach-rate', { tenantId, month }]

Aggregates often depend on many underlying resources.

When a case changes, should you invalidate the dashboard summary?

Maybe yes.

But do not assume every case mutation must refetch every aggregate. That can create expensive cascades.

Create an invalidation matrix.

MutationDetailListsCountsReportsTimeline
assign caseyesyesmaybenoyes
close caseyesyesyesmaybeyes
add internal notemaybenononoyes
update titleyesyesnonoyes

This matrix should be owned by the domain module.


27. Query Keys and Normalized Entity Stores

Some client architectures use normalized stores.

entities.cases[caseId]
queries.caseList[filterHash] -> [caseId]

TanStack Query and normalized GraphQL caches make different trade-offs.

A query key cache stores response-shaped entries.

A normalized cache stores entity-shaped entries and query references.

You can combine them, but be honest about ownership.

For many REST/React apps, response-shaped query cache is simpler and safer.

For highly interconnected graphs with fragment-level updates, normalized caching can be valuable.

The key principle remains:

Every replica needs a deterministic identity.

Whether that identity is a query key, entity id, fragment key, or document cache key, the same collision/fragmentation issues apply.


28. Cache Scope and QueryClient Lifetime

A QueryClient is a cache container.

Its lifetime matters.

Common scopes:

  • per browser tab;
  • per app shell;
  • per authenticated session;
  • per tenant session;
  • per SSR request;
  • per test case.

Do not accidentally share one server-side query client across users during SSR.

For browser apps, decide what happens on:

  • logout;
  • tenant switch;
  • permission refresh;
  • impersonation start/end;
  • role elevation;
  • token refresh failure.

Options:

queryClient.clear()

or targeted removal:

queryClient.removeQueries({ queryKey: ['session-scope', oldUserId] })

or scope-sensitive keys:

['session-scope', userId, tenantId, 'cases', 'list', params]

A secure design often uses both: scoped keys plus cache clear on logout.


29. Hydration and Persistence

When you hydrate cache from server-rendered data or persisted storage, key stability becomes even more important.

If server renders:

caseKeys.detail(tenantId, caseId)

but client reads:

['cases', caseId]

hydration misses and the client refetches.

If persisted cache survives deployment but key shapes change, old entries can become unusable or unsafe.

Use versioned persistence boundaries.

const PERSISTED_CACHE_VERSION = 3

When key semantics change incompatibly, bump the persistence buster.

For sensitive applications, avoid persisting protected server state unless you have a clear storage, encryption, expiry, and logout policy.


30. Query Key Review Checklist

For every query, ask:

  1. What remote resource or representation does this cache entry represent?
  2. Which inputs alter the returned data?
  3. Are all those inputs present in the key?
  4. Are execution-only controls excluded?
  5. Is the key serializable and deterministic?
  6. Is tenant/user/permission scope represented when required?
  7. Is this key consistent with the domain key factory?
  8. Can mutations invalidate or update it precisely?
  9. Does list/detail/aggregate representation have distinct key space?
  10. Does infinite query use a different key from normal query?
  11. Are equivalent filter objects normalized consistently?
  12. Is the key stable across SSR hydration and client render?
  13. Is persisted cache versioning considered?
  14. Can DevTools inspection tell humans what this entry means?

A query key that passes this checklist is boring.

Boring keys are good.


31. Build From Scratch: Tiny Query-Key Cache

To make the model concrete, build a tiny cache.

This is not production code. It is a mental model.

type QueryKey = readonly unknown[]
type QueryHash = string

type QueryState<T> = {
  data?: T
  error?: unknown
  updatedAt: number
  promise?: Promise<T>
}

function stableHash(value: unknown): string {
  return JSON.stringify(value, (_, v) => {
    if (v && typeof v === 'object' && !Array.isArray(v)) {
      return Object.keys(v as Record<string, unknown>)
        .sort()
        .reduce<Record<string, unknown>>((acc, key) => {
          acc[key] = (v as Record<string, unknown>)[key]
          return acc
        }, {})
    }
    return v
  })
}

class TinyQueryCache {
  private entries = new Map<QueryHash, QueryState<unknown>>()

  get<T>(key: QueryKey): QueryState<T> | undefined {
    return this.entries.get(stableHash(key)) as QueryState<T> | undefined
  }

  set<T>(key: QueryKey, state: QueryState<T>) {
    this.entries.set(stableHash(key), state)
  }

  invalidate(prefix: QueryKey) {
    const prefixHashStart = JSON.stringify(prefix).slice(0, -1)

    for (const [hash, state] of this.entries) {
      if (hash.startsWith(prefixHashStart)) {
        state.updatedAt = 0
      }
    }
  }
}

This simple implementation is intentionally incomplete.

It shows why key structure matters.

If your key hierarchy has no prefix structure, invalidation becomes a full-cache scan with application-specific guesses.


32. Production Anti-Patterns

Anti-pattern: component-owned key fragments

function CaseSidebar() {
  return useQuery({ queryKey: ['sidebar-cases'], queryFn: fetchCases })
}

function CaseTable() {
  return useQuery({ queryKey: ['case-table'], queryFn: fetchCases })
}

Same remote data, two component names.

Fix: domain key.

caseKeys.list(params)

Anti-pattern: key missing filter

useQuery({
  queryKey: ['cases', 'list'],
  queryFn: () => fetchCases({ status }),
})

Fix:

queryKey: caseKeys.list({ status })

Anti-pattern: raw form draft as key

queryKey: ['cases', 'search', draft]

This fetches on every keystroke.

Fix: debounce, submit, or URL-commit the search term.

queryKey: ['cases', 'search', committedSearch]

Anti-pattern: over-scoped public data

['users', userId, 'countries']

If country list is public and identical for all users, this fragments cache.

Fix:

['reference-data', 'countries']

Anti-pattern: under-scoped private data

['dashboard']

If dashboard is personalized, this collides.

Fix:

['users', userId, 'dashboard']

33. Naming Conventions

Use nouns and resource hierarchy.

Prefer:

['cases', 'detail', caseId]
['cases', 'list', params]
['cases', 'timeline', caseId]
['reports', 'summary', params]

Avoid vague verbs:

['getCases']
['fetchUser']
['loadData']

The query key is not the function name.

Use detail, list, infinite-list, summary, counts, timeline, attachments, search, lookup, reference-data consistently.

Consistency is more valuable than cleverness.


34. Query Key Decision Matrix

Input TypeInclude in Key?Reason
resource idyeschanges resource identity
tenant idyes for tenant-scoped dataprevents cross-tenant collision
user idyes for personalized dataprevents cross-user collision
role/permission versionsometimesinclude when response differs by permission context
filtersyeschanges list membership
sortyeschanges order/page boundaries
page numberyes for page queryidentifies page representation
cursoryes for page query, no for base infinite keydepends on cache representation
localeyes if server response changeslocalized server data differs
timezoneyes if server buckets by timezonedate grouping changes
fields/projectionyesresponse shape differs
AbortSignalnoexecution control
timeoutnoexecution control
retry countnoexecution control
trace idnoobservability metadata
component namenonot resource identity
raw form draftusually nocommit/debounce first
feature flagsometimesinclude if response shape/semantics differ
API versionsometimesinclude during incompatible migrations

35. What You Should Now Be Able To Do

After this part, you should be able to:

  • explain query keys as cache addresses, not labels;
  • distinguish identity, representation, and execution controls;
  • design hierarchical query key factories;
  • prevent collision and fragmentation;
  • normalize query parameters safely;
  • include tenant/user/permission scope when required;
  • design list, detail, aggregate, and infinite-query key spaces;
  • connect URL state to cache identity;
  • reason about invalidation before writing mutation code;
  • review query keys for production readiness.

In the next part, we move from single-query identity to composing data requirements. Real screens rarely need one resource. They need graphs, joins, parallel queries, dependent queries, summaries, permissions, and progressive rendering.


References

Lesson Recap

You just completed lesson 23 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.