skip to content

In TanStack Query v5, a todo list keyed ['todos'] has a queryFn reading a status filter; why does a filter change show the wrong list?

level: juniorimportance: must knowfreq 70%

answer

  1. the key is the cache identity
  2. same key, same entry
  3. no key change, no fetch
  4. every variable the queryFn reads
  5. a lint rule catches it

basics

~20 s

The key is the cache identity, and ['todos'] never changes, so every filter shares one entry and a filter change starts no fetch. Put every variable the queryFn uses into the key, such as ['todos', 'list', { status }].

solid answer

~50 s

TanStack Query identifies cached data only by the query key, and the docs describe the key as the query function's dependency list. With `['todos']` the key is identical for every filter, so the hook stays on the same cache entry. Switching from 'open' to 'done' starts no fetch and keeps showing the list fetched for the old filter. When that entry is refetched later, it runs whichever `queryFn` the latest render supplied and writes the result into the one shared entry. A second screen reading `['todos']` with another filter then shows the wrong list too. The fix is to include every variable the `queryFn` reads: `['todos', 'list', { status }]`. Each filter then has its own entry, a filter change is a key change that fetches or hits the cache, and switching back is instant. The `@tanstack/query/exhaustive-deps` ESLint rule reports the omission.

code

tsx · 23 lines
tsx
import { useQuery } from '@tanstack/react-query'

type Status = 'open' | 'done'
type Todo = { id: number; title: string }

async function fetchTodos(status: Status): Promise<Todo[]> {
  const res = await fetch(`/api/todos?status=${status}`)
  if (!res.ok) throw new Error('Failed to load todos')
  return res.json()
}

// Bug: status changes the data but not the key.
export function useTodosBroken(status: Status) {
  return useQuery({ queryKey: ['todos'], queryFn: () => fetchTodos(status) })
}

// Fix: every variable the queryFn reads is part of the key.
export function useTodos(status: Status) {
  return useQuery({
    queryKey: ['todos', 'list', { status }],
    queryFn: () => fetchTodos(status),
  })
}

go deeper

for a junior

Remember the rule: every value the query function uses to fetch goes into the query key. A filter left out of the key means one shared cache entry for every filter.

for a middle

Explain why no fetch happens when a non-key variable changes, how the latest closure still runs on the next refetch, and how that crosses data between screens.

for a senior

Recognize the symptom in production, data that depends on which screen fetched last, and enforce the rule with the exhaustive-deps lint rule and a key factory.

for a principal

Make key discipline a team convention: a lint rule in CI and one factory per feature, so that cache identity is reviewed like an API contract rather than left to each component.

## The key is identity and dependency list In TanStack Query every cached result lives under a **query key**, an array such as `['todos']`. The key does two jobs at once: - It is the **cache identity**. Two hooks with equal keys read and write the same cache entry, and nothing else distinguishes entries. - It acts as the **dependency list of the query function**. The docs say that query keys act as dependencies for your query functions: when a value in the key changes, the hook moves to a different entry and fetches it if needed. The query function (`queryFn`) is not inspected. TanStack Query does not know, and cannot know, which variables the function closes over. If a variable affects the data but is not in the key, the library cannot see it change. ## What happens with ['todos'] Take a list whose `queryFn` calls `fetchTodos(status)` while the key stays `['todos']`: 1. The page renders with `status = 'open'`. The key `['todos']` has no entry yet, so the hook fetches the open todos and caches them under `['todos']`. 2. The user picks 'done'. The component re-renders, but the key is still `['todos']`, so the hook is still on **the same entry**. A change of options on the same entry does not start a fetch, and the open todos stay on screen under a 'done' heading. 3. Later something refetches that entry, such as a window-focus refetch or an invalidation after a mutation. The observer passed the **latest** options to the query on each render, so this fetch runs the newest closure and loads the done todos into `['todos']`. 4. The data now depends on **when** the last fetch ran, not on what the user selected. ## Why two screens get crossed The damage spreads when a second component uses the same key: - A sidebar showing "open todos" and a report showing "done todos", both keyed `['todos']`, share one entry. Whichever refetched last decides what **both** display. - A cache write under `['todos']` after a mutation overwrites data that one of the screens was never meant to show. - Switching the filter back and forth never gets the benefit of the cache, because there is only ever one slot. ## The fix: every variable in the key | Filter change | Key `['todos']` | Key `['todos', 'list', { status }]` | |---|---|---| | 'open' to 'done' | same entry, no fetch, stale list | new entry, fetched | | 'done' back to 'open' | same entry, no fetch | cache hit, shown at once | | Two screens, two filters | one shared, clobbered entry | two independent entries | Put **every value the `queryFn` uses that can change** into the key, in the same form the function uses it. An object for named parameters (`{ status, page }`) keeps the key readable and, because object properties are hashed in sorted order, insensitive to the order you write them in. ## Catching it early The official ESLint plugin ships a rule, **`@tanstack/query/exhaustive-deps`**, that compares the variables a `queryFn` references with the contents of `queryKey`. Its documentation spells out the boundaries: - A **function call target** such as `fetchTodoById` is not a key dependency; the `todoId` passed to it is. - Values referenced inside **nested callbacks**, such as `promise.then(() => todoId)`, are still dependencies. - An `allowlist` option can exempt stable values by variable name (`allowlist.variables`) or by TypeScript type name (`allowlist.types`), such as an API client or a configuration object. Key factories and `queryOptions()` help as well, because they place the key and the function in one spot where the omission is visible. ## What does not belong in the key Only values that change **what data you get** belong in the key. The `queryClient`, the fetch helper, and constants that never change identify nothing. Values that change every render but not the data, such as a timestamp for "now", are a separate trap: they create a new entry on each render.

  • Why not keep the key as ['todos'] and call refetch() whenever the filter changes?
    Every filter would still write into one entry. You lose per-filter caching, so switching back always waits for the network. Other screens reading `['todos']` still show whichever filter ran last, and overlapping refetches race to write the same slot. Putting the filter in the key lets the cache do this bookkeeping for you.
  • Does an inline { status } object in the key cause a refetch on every render because it is a new object?
    No. Keys are hashed by value with a deterministic `JSON.stringify` that sorts plain-object properties, so a fresh object with the same contents produces the same hash and the same entry. Only a change in the serialized contents creates a new key.
  • What does the exhaustive-deps rule deliberately not require in the key?
    Function call targets such as `fetchTodoById` or `api.getTodo`: the arguments passed to them are dependencies, the functions are not. Its `allowlist` option can also exempt stable values or types you name, such as a configuration object. Values used inside nested callbacks are still required.

saying these in an interview costs you the question

  • The queryFn re-runs whenever a variable it closes over changes.
  • Calling refetch() on every filter change is the right fix.
  • Keys only need the resource name; parameters belong in the queryFn.
  • The fetch function itself should be part of the query key.
  • A missing key variable only matters when two components mount together.