Server-state libraries
TanStack Query, SWR, RTK Query, and Apollo cache server data by key, refetch stale entries in the background, and refresh them after mutations, so components stop hand-writing loading flags, races, and invalidation.
The problem: server data is not your state
Picture a project tracker. The sidebar lists your projects, the main panel lists one project's tasks, a header badge counts the tasks assigned to you, and a drawer opens one task in full. All four components show data that lives on the server.
The first version is the one most teams write: each component calls fetch in a useEffect, puts the result into a global store such as Redux, and keeps isLoading and error flags next to it. It works in the demo. Then real users arrive, and the bug reports start.
Six bug reports from the same week
- "I renamed a task and the list still shows the old name." The drawer saved the change, but nothing told the list or the badge that their copies were now out of date.
- "The page makes the same request four times." The list, the badge, and two rows all asked for
/api/meon mount. Nobody shared the answer. - "I clicked between projects quickly and saw the wrong tasks." The answer for project A arrived after the answer for project B and overwrote it: a request race.
- "I came back from lunch and the board was an hour old." The data was fetched once, on mount, and never again while the tab sat open.
- "Going back to a page shows a spinner every time." The component unmounted, its data was thrown away, and returning to it started from nothing.
- "The app gets slower the longer I use it." Every task anyone ever opened is still in the store. Nothing is ever removed.
None of these is a hard bug on its own. Each has a known fix: a shared promise, an abort controller, a focus listener, a timer that clears old entries. The trouble is that every screen needs all six fixes, and a hand-written version gets each one slightly wrong in a slightly different place.
Why server data behaves differently
The deeper cause is that server data is a different kind of state from a modal being open or a form field's value. The [state categories](/system-design/concepts/state-categories) lesson draws the line; in practice it means this:
- You don't own it. The server is the source of truth. What the browser holds is a copy, a snapshot taken at some moment.
- It changes without you. A teammate edits the task, a background job closes it, another tab of your own app renames it. Your copy goes out of date with no event in your code.
- Reading it is slow and can fail. Each read is a network trip of tens to hundreds of milliseconds that may time out or return an error.
- Many parts of the page want the same piece. The badge and the list both need your tasks; they should share one copy and one request.
Client state (is the drawer open, which tab is selected) has none of these properties. It is yours, it is correct the moment you set it, and nothing else changes it. Tools built for client state, like useState or a Redux slice, give you a place to store a value, but none of what server data needs: sharing, freshness, retries, cleanup.
What a server-state library does instead
A server-state library is a cache for server data that lives in the browser, plus rules for keeping it fresh. You tell it two things for each piece of data: a key that names it (for example ['tasks', { project: 42 }]) and a function that fetches it. The library does the rest: it shares one request between components, tracks how old each entry is, refreshes it in the background, refetches it after your writes, and removes it once nobody uses it.
The four best-known libraries are TanStack Query (formerly React Query), SWR from Vercel, RTK Query from the Redux team, and Apollo Client for GraphQL. Framework loaders in Next.js, Remix, and React Router solve part of the same problem on the server side. They differ in shape and defaults but share the same few ideas, and this lesson teaches those first.
Check your understanding · Exercise 1
Which of these belong in a server-state cache rather than in local or global client state? Select all that apply.
The mental model: a shared cache of snapshots
Think of the departure board at a train station. It shows the last information it received, and you can read it the moment you walk in. In the background it keeps checking with the control room, and when something changes, the line on the board updates. Nobody stands in front of a blank board waiting for a phone call to finish.
A server-state library works the same way. Every component reads from one shared board, the query cache, which shows what it last heard from the server at once and checks for news at sensible moments. Components never fetch for themselves; they subscribe to a line on the board and redraw when it changes.
The cache is a map from keys to entries
Inside, the cache is a plain map. The key is a stable string made from the query key you pass. The value is an entry that holds the data plus everything the library needs to manage it.
status says whether there is data; the freshness column says whether to check with the server; observers say whether anyone on screen needs it. Each column changes on its own.- data and error: the last good answer, and the last failure if there was one. A failed refetch keeps the old data, so the screen doesn't go blank.
- dataUpdatedAt: the time the data arrived. Freshness is measured from here.
- observers: how many mounted components use this entry right now. Zero observers means the entry is inactive.
- promise: the request in flight, if any. A second component that asks during the request joins this promise instead of starting another. That is [request deduplication](/system-design/concepts/request-deduplication), built in.
A key names the question, not the URL
The key is the most important thing you write. It must contain every input that changes the answer. ['tasks', { project: 42, status: 'open' }] and ['tasks', { project: 42, status: 'done' }] are different questions with different answers, so they get different entries. If you forget status in the key, both filters share one entry and the user sees open tasks under the Done tab.
TanStack Query hashes array keys in a stable way: the order of properties inside an object doesn't matter, so { a: 1, b: 2 } and { b: 2, a: 1 } are the same key. Keys are also hierarchical. ['tasks'] is a prefix of every task key, which lets you refresh all of them at once later.
Other libraries use the same idea. SWR keys are a string or an array, usually the URL. RTK Query builds the key from the endpoint name and its arguments. Apollo uses the GraphQL query plus its variables, and also splits the result into objects by __typename and id, a second layer covered in [normalization](/system-design/concepts/normalization).
Two kinds of status, not one
A hand-written slice usually has one isLoading flag. That flag mixes up two separate questions, and the mix-up is the cause of the classic bug where a list flashes a spinner on every refresh.
- Do we have data? TanStack Query calls this
status:pending(nothing yet),success, orerror. - Is a request running right now? This is
fetchStatus:fetching,idle, orpaused(the device is offline and the request waits for the network).
The two are independent. success plus fetching means "show the data, maybe with a small refresh hint". pending plus fetching is the only case that deserves a full skeleton.
SWR expresses the same split with isLoading (no data yet and a request running) and isValidating (any request running). RTK Query has isLoading and isFetching.
Fresh, stale, inactive, gone
Two timers drive each entry's life cycle, and they measure different things. Mixing them up is the most common confusion in this topic.
- staleTime is how long data counts as fresh after it arrives. While fresh, the library reads it from the cache and never refetches it on its own. After that it is stale: still shown, but refreshed at the next trigger. TanStack Query's default is
0, so data is stale the moment it lands. - gcTime (called
cacheTimebefore TanStack Query v5) starts when the last observer unmounts and the entry becomes inactive. If nobody subscribes again before it runs out, the entry is removed from memory. The default is 5 minutes. RTK Query's version iskeepUnusedDataFor, 60 seconds by default.
staleTime moves fresh to stale, gcTime moves inactive to removed. Fresh and stale entries can both become inactive; only a removed entry means a real loading state next time.So staleTime answers "should I check with the server?" and gcTime answers "should I keep this at all?". A stale entry is still in memory and still shown at once. An inactive entry is kept in memory for a while so that going back to a page is instant. Only a removed entry means a real loading state.
Check your understanding · Exercise 2
A query uses staleTime: 30_000 and gcTime: 300_000. The data arrived at 10:00:00 and the only component using it unmounted at 10:00:10. What is true at 10:01:00? Select all that apply.
How it works, step by step
Let's follow one cache entry through ten minutes of real use. The app is the project tracker from chapter 1, using TanStack Query with staleTime: 30_000 (30 seconds) and the default gcTime of 5 minutes. The key is ['tasks', { project: 42 }] and a request to /api/projects/42/tasks takes about 180 ms.
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000 } },
});
function useTasks(projectId: number) {
return useQuery({
queryKey: ["tasks", { project: projectId }],
queryFn: ({ signal }) =>
fetch(`/api/projects/${projectId}/tasks`, { signal }).then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}),
});
}
function TaskList() {
const { data, isPending, isFetching } = useTasks(42);
if (isPending) return <TaskListSkeleton />;
return <List tasks={data} refreshing={isFetching} />;
}
signal lets the library cancel a request nobody needs any more.0 s: the first mount is a miss
TaskList mounts. The cache has no entry for the key, so the library creates one with status: 'pending', starts the fetch, and stores its promise on the entry. The component renders the skeleton. About 180 ms later the answer arrives: the entry becomes success, dataUpdatedAt is set, and the list renders.
Note one detail in the fetch function: fetch only rejects on network failures, so the function throws for 4xx and 5xx answers itself. If it didn't, a 500 page would be cached as good data.
5 s: a second component reads the cache
The header badge mounts and calls useTasks(42) too. The entry is only 5 seconds old, well inside the 30-second staleTime, so the badge gets the data in its first render, with no request and no spinner. The entry now has two observers. (Had it mounted while the first request was still running, it would have joined that request.)
40 s and 3 min: stale, then a background refetch
At 30 seconds the entry quietly turns stale; nothing happens on screen, because staleness only changes what the library does at the next trigger. At 40 seconds the user switches to email, and at 3 minutes they switch back. The browser fires visibilitychange, and the library sees a stale entry with observers, so it refetches.
During the refetch status stays success and fetchStatus becomes fetching. The list keeps showing the tasks it has; isFetching can drive a thin progress bar. When the new answer arrives, the list redraws only if something changed. A teammate added two tasks while the user was away, and they simply appear.
isFetching can drive a thin progress bar. Only the final answer causes a re-render.4 min: a mutation, then invalidation
The user renames a task in the drawer. The drawer sends PATCH /api/tasks/7 through a mutation. When the server answers 200, the mutation's onSuccess calls queryClient.invalidateQueries({ queryKey: ['tasks'] }). That one call does two things to every entry whose key starts with 'tasks':
- It marks the entry stale at once, even if its
staleTimehas not run out. - If the entry has observers (it is on screen), it refetches it now. Inactive entries are only marked stale; they refetch when something mounts them again.
The list and badge show the new name one refetch, about 180 ms, after the server confirms the save. If that delay is too long for the feel you want, an [optimistic update](/system-design/concepts/optimistic-updates) writes the new name into the cache before the server answers, and the invalidation afterwards confirms it.
5 min to 10 min: inactive, then removed
At 5 minutes the user opens another project. Both observers of project 42 unmount, the entry becomes inactive, and its 5-minute gcTime timer starts. If the user goes back within those 5 minutes, the list appears instantly from memory and refetches in the background. If not, at 10 minutes the entry is removed, and the next visit starts with a skeleton again.
Try the whole story yourself. Each button is one event; the timeline shows which state the entry is in and whether a request goes out.
Clock: 0 s. Entry: gone, no data, 0 observers. Server has v1. Requests so far: 0.
Mount a component to start. The key is ['tasks', { project: 42 }], gcTime is 5 minutes.
Check your understanding · Exercise 3
With staleTime: 30_000, which of these events send a request for ['tasks', { project: 42 }]? Select all that apply.
The knobs in detail
Every library exposes the same handful of settings under different names. For each one: what it does, when to change it, and the common mistake. Names are TanStack Query's unless stated.
staleTime: how much lag you accept
staleTime is the single most important setting, and the default of 0 is chosen to be safe, not fast. With 0, every mount, focus, and reconnect refetches. That is correct for data that changes often, but it surprises people who open DevTools and see the same request on every tab switch.
- Live-ish data (a chat list, stock levels, a task board shared by a team):
0to 10 seconds. - Data that changes now and then (a user profile, project settings): 1 to 5 minutes.
- Data that almost never changes (a list of countries, feature flags for this session):
Infinity, and refresh it by invalidation if it ever does change.
The mistake: raising the global staleTime to stop "too many requests", then finding old data after another user's edit. Set a sensible default, and override it per query for data that must be fresher or can be older.
The refetch triggers
Staleness alone never sends a request. A stale entry is refetched only when one of these events happens and the entry has at least one observer:
- Mount (
refetchOnMount, defaulttrue): a new component subscribes to a stale key. - Window focus (
refetchOnWindowFocus, defaulttrue): the tab becomes visible again, from thevisibilitychangeevent. - Reconnect (
refetchOnReconnect, defaulttrue): the browser'sonlineevent fires after being offline. - Interval (
refetchInterval, default off): poll every N ms while mounted. By default polling pauses while the tab is hidden;refetchIntervalInBackground: truekeeps it going. - Invalidation: your code says the data changed. This one ignores
staleTime.
The mount, focus, and reconnect options also accept 'always', which refetches even fresh data. Use that rarely: for example refetchOnMount: 'always' on a checkout summary that must never show a cached total.
retry and retryDelay
TanStack Query retries a failed query 3 times in the browser (0 times on the server), waiting min(1000 * 2 ** attempt, 30000) ms between tries: about 1 s, 2 s, then 4 s. The [retries and backoff](/system-design/concepts/retries-and-backoff) lesson explains why the waits grow. SWR also retries with growing waits, and RTK Query only retries if you wrap the base query with its retry helper.
The mistake: retrying errors that can't succeed. A 404 or 403 fails the same way four times and makes the user wait 7 seconds for an error message. If your fetch function throws errors that carry the HTTP status, pass a function: retry: (count, error) => error.status >= 500 && count < 3.
enabled, select, and placeholderData
- enabled pauses a query until it can run.
enabled: !!userIdwaits for the user before fetching their projects: a dependent query. Each dependent step adds a round trip, so avoid long chains when you control the API. - select transforms or picks part of the cached data for one component, for example
select: (tasks) => tasks.filter((t) => t.assignee === me).lengthfor the badge. The badge then re-renders only when the count changes, not when any task does. - placeholderData shows something while a new key loads.
placeholderData: keepPreviousDatakeeps the previous page on screen while the next one loads. - initialData seeds the cache with data you already have, such as the task row from the list when opening the drawer. Unlike placeholder data it is stored in the cache as if it had just been fetched, so
staleTimecounts from now unless you also passinitialDataUpdatedAt.
The same knobs across libraries
| Setting | TanStack Query | SWR | RTK Query | Apollo Client |
|---|---|---|---|---|
| When data counts as fresh | staleTime, default 0 | No fresh window; repeats within dedupingInterval (2 s) are merged | No timer; reused until invalidated unless refetchOnMountOrArgChange is set | fetchPolicy, default cache-first |
| Unused data kept for | gcTime, default 5 min | No eviction by default | keepUnusedDataFor, default 60 s | Until evicted or reset |
| Refetch on focus | On by default | revalidateOnFocus, on | refetchOnFocus, off | No built-in focus trigger |
| Refetch on reconnect | On by default | revalidateOnReconnect, on | refetchOnReconnect, off | No built-in reconnect trigger |
| Polling | refetchInterval | refreshInterval | pollingInterval | pollInterval |
| Retry on error | 3 times, backoff | Yes, backoff | Off unless you add retry | Off unless you add RetryLink |
Check your understanding · Exercise 4
A dashboard re-requests its 400 KB analytics report every time the user switches browser tabs. The numbers only change once an hour. Which changes make sense? Select all that apply.
Mutations and invalidation
Reading is half the job. The harder half is writing: after a change, every cached copy that might show the old value has to be fixed. There are four ways to do it, and real apps use all four.
Way 1: invalidate and refetch
The simplest and safest: after the server confirms the change, tell the cache which keys it affected and let it refetch them. You don't need to know how the server computes the new list. Sorting, filtering, counts, and permissions all come back correct, because the server worked them out.
const queryClient = useQueryClient();
const rename = useMutation({
mutationFn: (input: { id: number; title: string }) =>
api.patch(`/api/tasks/${input.id}`, { title: input.title }),
onSuccess: (_data, input) =>
Promise.all([
queryClient.invalidateQueries({ queryKey: ["tasks"] }),
queryClient.invalidateQueries({ queryKey: ["task", input.id] }),
]),
});
Invalidation matches by prefix. ['tasks'] matches ['tasks', { project: 42 }], ['tasks', { project: 42, status: 'done' }], and every other task list. Add exact: true to match one key only, or a predicate function for anything more unusual.
By default only active entries refetch; inactive ones are marked stale and wait. The refetchType option changes that: 'active' (the default), 'inactive', 'all', or 'none', which only marks matches stale. Try the filters and refetch types below.
invalidateQueries({ queryKey: ['tasks'] })
1 request goes out now; 2 entries are only marked stale.
Way 2: write the server's answer into the cache
Many APIs return the updated object from the PATCH. Then you can skip the refetch for that object: queryClient.setQueryData(['task', 7], updatedTask). It saves one request, but only for keys whose new value you can work out exactly.
Writing a renamed task into a sorted, filtered list by hand means copying the server's sort and filter rules to the client, and those copies drift. A common mix: setQueryData for the detail key, invalidate the lists.
Way 3: optimistic update
Cancel in-flight refetches for the key with cancelQueries, keep a snapshot, write the expected result into the cache before the server answers, roll back on error, and invalidate when done to settle on the server's truth. It makes a like button or a checkbox feel instant. It is also the most code and the easiest to get wrong under races, which is why it has its own lesson: [optimistic updates](/system-design/concepts/optimistic-updates).
Way 4: let the library work it out
Two libraries go further and connect mutations to queries for you, in different ways.
- RTK Query uses tags. A query declares what it provides:
providesTags: [{ type: 'Task', id: 'LIST' }, { type: 'Task', id: 7 }]. A mutation declares what it invalidates:invalidatesTags: [{ type: 'Task', id: 7 }]. Every subscribed query that provides a matching tag refetches. You write the relationship once, next to the endpoints, instead of in everyonSuccess. - Apollo uses a normalized cache. Results are split into objects stored by
__typenameandid, such asTask:7. If the mutation returns{ id: 7, title: 'New name' }, Apollo updatesTask:7, and every query that includes task 7 shows the new title with no refetch. What it can't know is list membership: after creating or deleting a task, you still callrefetchQueriesor edit the list withcache.modify.
Decision How should the UI catch up after a mutation?
What we pick: Start with invalidation everywhere; it is correct by construction. Add setQueryData for detail views when the API returns the updated object, and optimistic updates only for small, frequent, rarely failing actions such as likes, checkboxes, and reordering. Use normalized caching when you are already on GraphQL.
Check your understanding · Exercise 5
A user deletes task 7 from project 42. The cache holds ['tasks', { project: 42 }] (on screen), ['tasks', { project: 9 }] (inactive), ['task', 7] (inactive), and ['me'] (on screen). The code calls invalidateQueries({ queryKey: ['tasks'] }). What happens? Select all that apply.
Pages, prefetching, Suspense, and SSR
Real screens need more than one query that loads once. Here are the four features you will reach for next, each built on the same cache.
Numbered pages: one key per page
For a table with page buttons, put the page in the key: ['tasks', { project: 42, page: 3 }]. Each page is its own entry with its own freshness, so going back to page 2 is instant while it is still in memory. placeholderData: keepPreviousData keeps page 2 on screen, dimmed, while page 3 loads, so the table doesn't collapse to a skeleton and shift the layout.
Infinite lists: one key, many pages
A feed that loads more as you scroll is different: the pages belong together. useInfiniteQuery stores a single entry whose data is { pages, pageParams }. You give it an initialPageParam and a getNextPageParam(lastPage) that returns the next cursor, or undefined when there are no more pages; fetchNextPage() appends one page. The [pagination](/system-design/concepts/pagination) lesson explains why cursors beat offsets here.
maxPages keeps that bounded.The surprise comes at refetch time: a stale infinite entry refetches every loaded page in order, from the first, because each page's cursor comes from the page before it. A user who scrolled through 20 pages triggers 20 requests on the next tab focus. Two fixes: maxPages (TanStack Query v5) caps how many pages are kept, or a longer staleTime plus a "new posts" button instead of refetching the whole history.
Prefetching: fill the cache before it's needed
Because every read goes through the cache, you can fill it early. queryClient.prefetchQuery({ queryKey, queryFn }) on hover or when a link scrolls into view means the next page finds fresh data and renders without a spinner. Prefetching respects staleTime: if the data is already fresh, it doesn't fetch again. The [prefetching](/system-design/concepts/prefetching) lesson covers when the bytes are worth spending.
Suspense: let React show the loading state
In Suspense mode the hook never returns a pending state. While there is no data it throws a promise, and the nearest Suspense boundary shows its fallback. Errors go to the nearest error boundary. TanStack Query has useSuspenseQuery, SWR has the suspense: true option, and Apollo has useSuspenseQuery.
- The gain: components read
dataas always defined, and loading UI lives in one place per region instead of anif (isPending)in every component. - The trap: two suspense queries in the same component run one after the other, because the first one suspends before the second is even called. That is a waterfall. Use
useSuspenseQueriesto start both at once, or prefetch both higher up. - Background refetches don't suspend. Once there is data, a refetch updates it in place; only a missing entry shows the fallback.
SSR: fetch on the server, hand the cache to the browser
With server rendering, you want the HTML to contain the tasks and the browser not to fetch them again. Every library uses the same pattern: fill a cache on the server, serialize it into the page, and seed the browser's cache with it before the first render.
- On the server, create a new
QueryClientfor this request andawait queryClient.prefetchQuery(...)for the data the page needs. - Render, and embed
dehydrate(queryClient)in the page as JSON. - In the browser,
HydrationBoundary(TanStack Query v5) puts those entries into the browser cache, with their originaldataUpdatedAt. - Components call
useQuerywith the same keys and find data at once. No skeleton, no request, as long as the data is still fresh.
One detail catches everyone. With the default staleTime: 0 the hydrated data is already stale, so the browser refetches everything on mount and you pay for the data twice. Set a staleTime above zero so hydrated data counts as fresh; TanStack's SSR guide recommends this, and its examples use 60 seconds.
The other libraries have the same pieces under other names: SWR passes a fallback object to SWRConfig, RTK Query uses extractRehydrationInfo, and Apollo serializes client.extract() and restores it into a new InMemoryCache.
Check your understanding · Exercise 6
A server-rendered page shows tasks in the HTML, but DevTools shows the browser fetching /api/projects/42/tasks again right after load. What could cause it? Select all that apply.
TanStack Query, SWR, RTK Query, Apollo, and loaders
All of these tools share the core from the earlier chapters. They differ in what they assume about your API, how much they do by default, and where they keep the cache.
TanStack Query
The most complete general-purpose option. It works with any async function (REST, RPC, GraphQL over fetch, even IndexedDB), and invalidation is by the key prefixes you design. It has mutations, infinite queries, Suspense hooks, SSR hydration, offline pausing, and devtools that show every entry, with adapters for React, Vue, Solid, Svelte, and Angular. The cost is more concepts to learn, and the default staleTime: 0 that surprises newcomers.
SWR
Named after the HTTP stale-while-revalidate idea: return cached data, then revalidate. It is React only, with a smaller API and bundle than the others, and the key is usually just the URL. There is no fresh window: every mount revalidates, focus revalidates at most once per 5 seconds, and repeats within dedupingInterval (2 s) are merged.
Writes use mutate(key) to revalidate, or useSWRMutation. SWR suits read-heavy apps, especially on Next.js, which comes from the same team. You write more yourself when writes and their invalidations get complex.
RTK Query
The Redux team's answer to "should I put server data in Redux?": yes, but let this do it. You define an API slice with endpoints, and it generates hooks such as useGetTasksQuery. The cache lives in the Redux store, so it shows up in Redux DevTools, and tags link mutations to queries in one place.
Its defaults are conservative: no focus or reconnect refetch until you turn them on, and unused data kept 60 seconds. Infinite queries arrived in Redux Toolkit 2.6. It is the natural pick for an app that already uses Redux Toolkit, less so for an app without Redux.
Apollo Client and Relay
GraphQL clients. Both keep a [normalized](/system-design/concepts/normalization) cache, so one edited object updates every query that contains it. Apollo is flexible and widely used; its default fetchPolicy: 'cache-first' never refetches data it already has, and cache-and-network gives you stale-while-revalidate behavior.
Relay, from Meta, adds a compiler: each component declares the fields it needs as a fragment, the compiler joins them into one query per screen, and a component re-renders only when its own fields change. Relay is strict and powerful, and works best with a server that follows its conventions, such as global ids and connections for lists. See [GraphQL](/system-design/concepts/graphql) for the API side.
Framework loaders and server components
Frameworks now solve part of the problem at the route level. React Router (and Remix before it) runs a loader for each route on navigation and re-runs the loaders on the page after an action (a form submit), which is invalidation tied to navigation. In Next.js App Router, server components await data while rendering on the server, and revalidatePath or revalidateTag after a server action refreshes it. Data reaches the browser already rendered.
Loaders handle "data for this page" very well. They don't handle data that lives longer than a page or changes while the page is open: focus refetch, polling, infinite scroll, or many widgets sharing one entry. Many apps use both: loaders or server components for the first render, and a client cache, often seeded by the loader, for the interactive parts.
How to choose
Decision Which server-state tool should a new React app use?
What we pick: With a GraphQL API, use its client (Apollo, or Relay if the server follows Relay's rules). With Redux Toolkit already in the app, use RTK Query. Otherwise use TanStack Query, or SWR for a mostly read-only app that wants the smallest option. Whatever you pick, let route loaders or server components handle the first render where your framework supports it.
Check your understanding · Exercise 7
Which pairings of app and tool are reasonable? Select all that apply.
Why hand-rolled Redux breaks, and how libraries break too
Redux, Zustand, and similar stores are good tools for client state. The trouble starts when they are used as a server cache by hand. Here is the code a team writes for one list, and the bugs it hides.
// tasksSlice.ts, the hand-rolled way
const initialState = { items: [] as Task[], isLoading: false, error: null as string | null };
export const loadTasks = createAsyncThunk("tasks/load", (projectId: number) =>
api.get(`/api/projects/${projectId}/tasks`),
);
// in the reducer
builder
.addCase(loadTasks.pending, (s) => { s.isLoading = true; })
.addCase(loadTasks.fulfilled, (s, a) => { s.items = a.payload; s.isLoading = false; })
.addCase(loadTasks.rejected, (s, a) => { s.error = a.error.message ?? ""; s.isLoading = false; });
// in the component
useEffect(() => { dispatch(loadTasks(projectId)); }, [projectId]);
What that slice gets wrong
- One slot for many questions.
itemsholds whichever project loaded last. Switch from project 42 to 9 and back, and you see 9's tasks until 42 reloads. There is no key. - Races. If 42's request is slow and 9's is fast, 42's answer lands last and wins. The fix needs request ids or an abort per key; see [request races](/system-design/concepts/request-races).
- No sharing. Two components that mount together dispatch twice and send two requests.
- No freshness. Nothing records when the data arrived, so nothing knows to refetch it on focus or reconnect.
- Spinner on every reload.
isLoadinggoestrueon each refresh, so the list blanks even when it has good data. - No cleanup. Data for every project ever viewed stays in the store until the tab closes.
- Invalidation by memory. After a rename, someone must remember to dispatch
loadTasksagain, in every place that changes tasks, forever.
Each fix is a few lines. All of them together are a small server-state library: keyed entries, timestamps, an in-flight map, focus listeners, a garbage collector, and a way to name what to refresh. The Redux maintainers reached the same conclusion, which is why RTK Query exists and why their docs recommend it over hand-written thunks for fetching.
The worst bug: copies that drift apart
The hand-rolled approach also multiplies copies. The list keeps tasks in the store, the drawer copies one task into its own useState to edit it, and the search results keep their own array from a different endpoint. Now the same task lives in three places, and a save updates at most one of them.
A server-state library fixes this by making the cache the only copy: components read from it and never keep their own. If a form needs an editable draft, the draft is client state that starts from the cached value and is thrown away on save, after which the cache is refreshed.
How apps with a library still break
A library removes the plumbing bugs but not the design bugs. These are the ones that show up in code review and in production:
| Symptom | Cause | Fix |
|---|---|---|
| Wrong data under a filter or tab | An input missing from the key | Put every variable the queryFn uses into the key; TanStack's ESLint plugin checks this |
| Request on every tab switch | Default staleTime: 0 on slow-changing data | Set staleTime per kind of data |
| Edit doesn't show in the list | Invalidated the wrong key, or a key that only shares a word | Use key factories so every key for one resource has the same prefix |
| Form resets while the user types | useState(data) copied once, or an effect that copies data into state on every refetch | Keep drafts separate; reset them only on open or save |
| Previous user's data after logout | Cache survives the session change | Call queryClient.clear() on logout, or include the user id in keys |
| Twenty requests when a feed tab regains focus | An infinite query refetching every page | Use maxPages, a longer staleTime, or a "new posts" pill |
| One user sees another's data in SSR HTML | A QueryClient shared across server requests | Create one per request |
A key factory is a small object that builds every key for a resource: taskKeys.all = ['tasks'], taskKeys.list(p) = ['tasks', 'list', p], taskKeys.detail(id) = ['tasks', 'detail', id]. With it, invalidateQueries({ queryKey: taskKeys.all }) refreshes lists and details together, and nobody types a key by hand.
Check your understanding · Exercise 8
A team keeps server data in a Redux slice with one isLoading flag. Which bugs does moving to a server-state library fix without extra code? Select all that apply.
Performance, accessibility, and the interview
Fewer re-renders, not just fewer requests
A refetch that returns the same data shouldn't redraw the screen. TanStack Query uses structural sharing: when new JSON arrives, it compares it with the old data and keeps the old object for every part that didn't change. If one task of 200 changed, 199 task objects keep their identity, so React.memo rows skip rendering. It also tracks which fields a component reads (data, isFetching, and so on) and only re-renders it when one of those changes.
Use select to narrow further. The header badge that only needs a count should select the count; then a title change in some task doesn't touch the badge at all. In Relay, fragments give the same narrowing automatically. In a hand-rolled store, every component that reads state.tasks.items re-renders whenever the array is replaced, which is on every refetch.
Accessibility: refreshes should be quiet
Background refetches change content while someone is reading it, which can confuse keyboard and screen reader users. Treat a refresh differently from a first load.
- Keep the old content on screen during a refetch. Never replace a list with a skeleton when there is data to show; this also avoids layout shift.
- Keep focus where it is. Use stable
keyprops on list rows so a refetch doesn't remount the row the user is on. - Announce only what matters. A polite
aria-liveregion saying "3 new tasks" is helpful; announcing every silent refresh is noise. - Mark a region that is loading for the first time with
aria-busy="true", and set it back tofalsewhen the data arrives. - Errors from a background refetch shouldn't wipe the data. Show a small message with a retry button next to the stale content.
Using it in a system design interview
Server state comes up in almost every frontend design question: a news feed, a chat app, a dashboard, an e-commerce cart. Bring it up when you draw the client architecture, as a box between the components and the API layer.
- Separate the kinds of state. "Server data goes in a query cache; UI state like the open drawer stays in the component; only a few truly global client values go in a store."
- Name the keys. Write two or three real keys on the whiteboard:
['feed', { cursor }],['post', id],['me']. - Give staleness numbers per kind of data. Feed: 30 s and focus refetch. Profile: 5 min. Config: forever.
- Say how writes update reads. "Liking is optimistic on
['post', id]; creating a post invalidates['feed']; a WebSocket event invalidates the same key for changes from other users." - Cover first load. "The route loader or server component prefetches the first page and hydrates the cache, so there is no spinner and no double fetch."
| Level | What a strong answer adds |
|---|---|
| Junior | Uses a library instead of useEffect plus a store, and explains loading versus refreshing states. |
| Mid-level | Designs keys, picks staleTime per data type, invalidates after mutations, handles pagination with cursors. |
| Senior | Plans SSR hydration and per-request clients, push-driven invalidation, optimistic updates with rollback, memory limits for infinite lists, and when not to use a client cache at all. |
Check your understanding · Exercise 9
In a chat app design, which statements about server state are sound? Select all that apply.
Summary
- Server data is a cache of someone else's state: you don't own it, it changes without you, and reading it is slow and can fail. Treat it differently from UI state.
- A server-state library is a keyed cache. Every input that changes the answer belongs in the key; components with the same key share one entry and one request.
- Two timers:
staleTimedecides when to check with the server again (TanStack default0),gcTimedecides how long unused data stays in memory (default 5 minutes). Stale data is still shown at once. - Stale entries refetch in the background on mount, window focus, reconnect, or an interval, and only while something on screen uses them. Separate "no data yet" from "refreshing" so refetches never blank the screen.
- After a mutation, invalidate the affected key prefixes; add
setQueryDataor optimistic updates where speed matters. RTK Query tags and Apollo's normalized cache automate part of this. - Infinite queries refetch every loaded page; SSR needs a per-request client and a staleTime above zero so hydrated data isn't fetched twice.
- Choose by API and existing stack: GraphQL client for GraphQL, RTK Query with Redux Toolkit, TanStack Query otherwise, SWR for small read-heavy apps; framework loaders for the first render.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
Why not just fetch in useEffect and store the result in Redux?
Because server data needs things a store doesn't give you: a slot per question (key), sharing one request between components, protection against out-of-order answers, a record of when the data arrived, refetching on focus and reconnect, cleanup of unused data, and a way to refresh the right entries after a write.
Hand-written, each of those is a few lines, but every screen needs all of them and they get implemented slightly differently each time. That is a server-state library, written badly. The Redux team ships RTK Query for exactly this reason.
Question 2
Explain staleTime and gcTime. What happens with the defaults?
staleTime is how long data counts as fresh after it arrives; fresh data is served from cache without any request. gcTime starts when the last component using an entry unmounts, and removes the entry when it runs out.
With TanStack Query's defaults (staleTime 0, gcTime 5 minutes) data is stale immediately, so every mount, focus, and reconnect refetches it in the background while still showing the cached copy. Unused entries stay 5 minutes, so going back to a page is instant.
In practice I set staleTime per kind of data: seconds for collaborative lists, minutes for profiles, Infinity for reference data that I refresh by invalidation.
Question 3
A user renames a task in a detail drawer. How does the list update?
The rename is a mutation. When the server confirms, I invalidate the task key prefix, for example ['tasks'], which marks every task list stale and refetches the ones on screen. If the PATCH returns the updated task, I also write it into the detail key with setQueryData so the drawer updates without a request.
If the rename must feel instant, I make it optimistic: cancel in-flight queries for those keys, snapshot, write the new title, roll back on error, and invalidate on settle. Other users' tabs learn about it on their next focus refetch, or immediately if we push an invalidation over a WebSocket.
Question 4
How do you server-render a page that uses a query cache without fetching twice?
Create a new QueryClient per request on the server, prefetch the queries the page needs, render, and serialize the cache with dehydrate into the HTML. In the browser, HydrationBoundary puts those entries into the client cache before components render, so useQuery finds data immediately.
Two details matter: a staleTime above zero, or the browser refetches everything on mount; and never a module-level client on the server, which would share data between users.
Question 5
TanStack Query, SWR, RTK Query, or Apollo: how do you choose?
Start from the API and the existing stack. GraphQL: use its client, Apollo or Relay, because the normalized cache updates every view of an object from one mutation result. An app already on Redux Toolkit: RTK Query, which keeps the cache in the store and links mutations to queries with tags.
Otherwise TanStack Query, for its infinite queries, mutations, SSR hydration, and devtools; SWR if the app mostly reads and I want the smallest, simplest option. With Next.js or React Router I still use loaders or server components for the first render and seed the client cache from them.
Practice it
These design problems lean on server-state libraries.
References
- TanStack Query: Important Defaults staleTime 0, gcTime 5 minutes, refetch triggers, retries.
- TanStack Query: Query Invalidation Prefix matching and what happens to active and inactive queries.
- TanStack Query: Server Rendering and Hydration Per-request clients, dehydrate, and staleTime for hydrated data.
- SWR documentation Keys, revalidation, mutation, and options such as dedupingInterval.
- Redux Toolkit: RTK Query overview Why the Redux team built a data-fetching layer, and how tags work.
- Apollo Client: Caching overview How the normalized InMemoryCache stores objects by type and id.
- RFC 5861: HTTP Cache-Control Extensions for Stale Content Where the stale-while-revalidate idea, and SWR's name, come from.
Read next
- Kinds of stateServer data, UI toggles, the URL, form drafts, and live streams are five different kinds of state, each with its own home: knowing which is which is the first step of every frontend design.
- Deduplication and batchingWhen many parts of a page ask for the same data at once, send one request and share the answer; when they ask for many small pieces at once, collect them into one request. Two small ideas that remove most of the wasted traffic in a component-based app.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Normalizing stateStoring each entity once, in a table keyed by its id, and having lists and relationships point at ids instead of holding copies, so one change updates every view that shows it.
- Prefetching and preloading dataStarting the fetch for the data and code of the next screen before the user asks for it, on hover, on sight, or on a guess, so the click feels instant without wasting the user's data plan.
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.