03 · Server State with TanStack Query¶
In Level 2 you wrote useFetch and listed what it lacked: caching, deduplication,
background refresh, and mutation handling. TanStack Query (formerly React Query) is a
library that provides exactly those things. It treats server data as a cache that
can go stale, not as state you own.
Examples here follow TanStack Query v5's API (object-argument syntax). Older articles use v3/v4 syntax with positional arguments; the concepts carry over.
Setup¶
// main.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
createRoot(document.getElementById('root')).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
)
A separate @tanstack/react-query-devtools package adds an in-app panel showing every
cache entry and its state — very useful while learning.
Your first query¶
import { useQuery } from '@tanstack/react-query'
async function fetchTodos() {
const res = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=10')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
function Todos() {
const { data, isPending, isError, error, isFetching } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
if (isPending) return <p>Loading…</p>
if (isError) return <p role="alert">{error.message}</p>
return (
<>
{isFetching && <small>Refreshing…</small>}
<ul>{data.map(t => <li key={t.id}>{t.title}</li>)}</ul>
</>
)
}
queryKeyidentifies the data in the cache. Any component using['todos']shares one cache entry and one request.queryFnreturns a promise; it must throw on failure (hence theres.okcheck).isPendingmeans "no data yet";isFetchingmeans "a request is in flight", including background refetches while old data is shown.
Keys with parameters¶
function useTodo(id) {
return useQuery({
queryKey: ['todos', id],
queryFn: async ({ signal }) => {
const res = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, { signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
},
enabled: id != null,
})
}
The key includes every variable the query depends on — think of it as the dependency
array. Changing id switches to a different cache entry; switching back is instant if
that entry is still cached. The signal lets the library cancel requests that are no
longer needed. enabled: false pauses a query until its inputs exist.
Staleness and caching: the two timers¶
staleTime(default0): how long fetched data is considered fresh. Fresh data is served from cache without refetching. Stale data is still shown, but a background refetch is triggered on certain events: a new component mounting with that key, the window regaining focus, or the network reconnecting.gcTime(default 5 minutes): how long an unused cache entry (no components subscribed) is kept before being garbage-collected.
For data that rarely changes, a long staleTime avoids pointless refetches. For
fast-changing data, the default is sensible. Choosing staleTime per query is the main
tuning decision you make.
Mutations and invalidation¶
import { useMutation, useQueryClient } from '@tanstack/react-query'
function AddTodo() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: async title => {
const res = await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, completed: false, userId: 1 }),
})
if (!res.ok) throw new Error('Could not save')
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
return (
<form onSubmit={e => {
e.preventDefault()
mutation.mutate(new FormData(e.currentTarget).get('title'))
e.currentTarget.reset()
}}>
<input name="title" required />
<button disabled={mutation.isPending}>{mutation.isPending ? 'Saving…' : 'Add'}</button>
{mutation.isError && <p role="alert">{mutation.error.message}</p>}
</form>
)
}
invalidateQueries({ queryKey: ['todos'] }) marks every query whose key starts with
['todos'] as stale and refetches the ones currently on screen. You don't manually edit
the list; you tell the cache it's out of date.
Note: JSONPlaceholder accepts POSTs and returns a fake result but doesn't persist
anything, so after invalidation the refetched list won't contain your item. Use it to
see the request flow; use a real or local backend (e.g. json-server) to see the data
change.
Worked example: optimistic toggle with rollback¶
function useToggleTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async todo => {
const res = await fetch(`/api/todos/${todo.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !todo.completed }),
})
if (!res.ok) throw new Error('Update failed')
return res.json()
},
onMutate: async todo => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previous = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], old =>
old.map(t => (t.id === todo.id ? { ...t, completed: !t.completed } : t)),
)
return { previous }
},
onError: (_err, _todo, context) => {
queryClient.setQueryData(['todos'], context.previous)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
The checkbox flips instantly (onMutate writes to the cache), in-flight refetches are
cancelled so they can't overwrite the optimistic value, a failure restores the snapshot,
and either way the list is re-synced with the server afterwards. /api/todos stands in
for your own backend.
How It Actually Works¶
The QueryClient holds a QueryCache: a map from a hashed query key (keys are
serialised deterministically, so ['todos', { page: 1, sort: 'asc' }] equals the same
object with its properties in a different order) to a Query object. Each Query
stores the data, error, status, timestamps and a list of observers.
Each useQuery call creates a QueryObserver that subscribes to its Query and plugs
into React via useSyncExternalStore (the same mechanism as the stores in lesson 2).
When a query's state changes, observers compute their result and notify their
components. Observers track which result fields your component actually read
(data, isFetching, …) and only trigger re-renders when those change — so a component
that never reads isFetching isn't re-rendered by background refetches starting.
When the first observer mounts, the Query checks its data's age against staleTime.
Fresh → do nothing. Stale or empty → call queryFn. If a fetch for that key is already
in flight, new observers attach to the same promise instead of starting another —
that's deduplication. Window focus and reconnect events are wired globally by a focus
manager and online manager, which tell stale active queries to refetch.
When the last observer unsubscribes, a timer of gcTime starts. If nothing subscribes
again before it fires, the query is removed from the cache.
Failed queries retry (by default 3 times with exponential backoff) before reaching the
error state — which is why an error UI appears only after a few seconds. Set
retry: false in tests.
Common mistakes¶
- Leaving variables out of the key → different inputs share one cache entry.
queryFnthat doesn't throw on HTTP errors → error responses cached as data.- Copying query data into
useState→ your copy goes stale when the cache updates. Read from the query directly. - Creating the
QueryClientinside a component → a new, empty cache on every render. Create it once, outside (or in auseStateinitialiser). - Tests failing slowly because of default retries. Use a fresh
QueryClientper test withretry: false.
Exercise¶
Using json-server (npx json-server db.json) with a db.json containing projects
and tasks:
- Show a project list and a selected project's tasks (
['projects'],['projects', id, 'tasks']). - Add a task (mutation + invalidation of just that project's tasks).
- Toggle a task optimistically with rollback; stop the server mid-demo to watch the rollback happen.
- Set
staleTimeto 30 seconds for projects and explain, with DevTools open, what happens when you switch browser tabs and come back before and after 30 seconds. - Prefetch a project's tasks on hover of its link with
queryClient.prefetchQuery.