cf.completefrontend
My progress
FUNDAMENTALS · MODULE 4

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

  1. "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.
  2. "The page makes the same request four times." The list, the badge, and two rows all asked for /api/me on mount. Nobody shared the answer.
  3. "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.
  4. "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.
  5. "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.
  6. "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.

Before and after. On the left, hand-rolled: four components, the task list, the badge, the drawer, and a task row, each send their own request to the API, giving 4 requests and 4 copies of the data, each with its own loading flag. On the right, with a query cache: the same four components subscribe to one query cache, which sends 1 request per key to the API and keeps 1 shared copy.Hand-rolledWith a query cacheTask listTask listBadgeBadgeDrawerDrawerTask rowTask rowAPIQuery cache1 entry per keyAPI4 requests, 4 copies1 request, 1 shared copy
The components don't change; what changes is who talks to the server. With a cache in the middle, components ask by key and the cache decides whether a request is needed at all.

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.

The query cache drawn as a table with four entries. Key me: status success, fresh, 2 observers. Key tasks for project 42: success, a background fetch running, 1 observer. Key tasks for project 9: success, stale, 0 observers, so inactive and waiting for garbage collection. Key task 7: pending with its first fetch running, 1 observer.hashed keystatusfreshnessobservers["me"]successfresh2 observers["tasks",{project:42}]successfetching1 observer["tasks",{project:9}]successstale0: inactive["task",7]pendingfetching1 observer
One row per key. 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, or error.
  • Is a request running right now? This is fetchStatus: fetching, idle, or paused (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 cacheTime before 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 is keepUnusedDataFor, 60 seconds by default.
A state machine for one cache entry with six states. No entry goes to fetching on mount. Fetching goes to fresh when the 200 OK arrives. Fresh goes to stale when staleTime passes. Stale goes back to fetching on a trigger. Stale goes to inactive when the last component unmounts. Inactive goes to removed when gcTime passes, or back to fetching or fresh when a component mounts again. A note lists the triggers: mount, window focus, reconnect, interval, and invalidation; fresh data ignores all except invalidation.no entryfirst visitfetchingrequest outfreshno requeststaleshown at onceinactivekept in memoryremovednext mount: missmount200 OKstaleTimetriggermountunmountgcTimeTriggers: mount, window focus, reconnect, interval, invalidation.Fresh data ignores all of them except invalidation.
Two timers drive the loop: 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.

tsx
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} />;
}
The whole data layer for the list: a key, a fetch function, and one default. The 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.

A sequence diagram between the browser tab, the task list component, the query cache, and the server. The tab fires visibilitychange to the cache. The cache sees the entry is stale and has an observer, so it tells the task list to keep showing v1 with isFetching true, and sends GET /tasks to the server. The server answers 200 with v2. The cache hands v2 to the task list, which re-renders once with the two new tasks.Browser tabTask listQuery cacheServervisibilitychangestale, 1 observerv1, isFetchingGET /tasks200: v2v2, re-render
The user never sees a spinner: the list shows the cached v1 the whole time, and 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':

  1. It marks the entry stale at once, even if its staleTime has not run out.
  2. 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.

staleTime:

Clock: 0 s. Entry: gone, no data, 0 observers. Server has v1. Requests so far: 0.

The last 0 events for one cache entry with a staleTime of 30 seconds: none yet.evententryrequestscreenno events yet
Each column is one event, oldest on the left. The box is the entry's state right after it: fetching, fresh, stale, inactive (in memory, nobody using it), or gone. A circle means a request went out; the bottom row is what the screen shows, with an arrow when old content (or a skeleton) is replaced by the answer.

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): 0 to 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, default true): a new component subscribes to a stale key.
  • Window focus (refetchOnWindowFocus, default true): the tab becomes visible again, from the visibilitychange event.
  • Reconnect (refetchOnReconnect, default true): the browser's online event fires after being offline.
  • Interval (refetchInterval, default off): poll every N ms while mounted. By default polling pauses while the tab is hidden; refetchIntervalInBackground: true keeps it going.
  • Invalidation: your code says the data changed. This one ignores staleTime.
A flowchart for one cache entry when an event fires, such as window focus, mount, or reconnect. First: is anyone watching it? If no, do nothing; the inactive entry waits. If yes: is it stale or invalidated? If no, read the cache with no request. If yes: is a request already running? If yes, join it, so there is still one request. If no, refetch in the background while the old data stays on screen.event firesfocus, mount, reconnectanyone watching?stale or invalidated?already fetching?refetchold data stays on screenyesyesnonodo nothinginactive: waitnoread cacheno requestyesjoin itone request
Three questions decide whether a trigger becomes a request. Most triggers end at the first two boxes on the right, which is why a sensible staleTime saves so many requests.

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: !!userId waits 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).length for 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: keepPreviousData keeps 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 staleTime counts from now unless you also pass initialDataUpdatedAt.

The same knobs across libraries

RTK Query's focus and reconnect triggers also need setupListeners(store.dispatch) once at startup.
SettingTanStack QuerySWRRTK QueryApollo Client
When data counts as freshstaleTime, default 0No fresh window; repeats within dedupingInterval (2 s) are mergedNo timer; reused until invalidated unless refetchOnMountOrArgChange is setfetchPolicy, default cache-first
Unused data kept forgcTime, default 5 minNo eviction by defaultkeepUnusedDataFor, default 60 sUntil evicted or reset
Refetch on focusOn by defaultrevalidateOnFocus, onrefetchOnFocus, offNo built-in focus trigger
Refetch on reconnectOn by defaultrevalidateOnReconnect, onrefetchOnReconnect, offNo built-in reconnect trigger
PollingrefetchIntervalrefreshIntervalpollingIntervalpollInterval
Retry on error3 times, backoffYes, backoffOff unless you add retryOff 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.

tsx
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] }),
    ]),
});
Returning the promise from onSuccess keeps the mutation pending until the refetch finishes, so a Save button can stay disabled until the list is up to date.

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.

queryKey:
refetchType:

invalidateQueries({ queryKey: ['tasks'] })

Six cache entries after invalidateQueries({ queryKey: ['tasks'] }): ['tasks', { project: 42 }], active (on screen), refetch now; ['tasks', { project: 42, status: 'done' }], inactive, marked stale; ['tasks', { project: 9 }], inactive, marked stale; ['task', 7], active (on screen), untouched; ['me'], active (on screen), untouched; ['projects'], active (on screen), untouched. 1 requests.cache keystateresult['tasks', {project: 42}]activerefetch['tasks', {project: 42, status: 'done'}]inactivestale['tasks', {project: 9}]inactivestale['task', 7]activeuntouched['me']activeuntouched['projects']activeuntouched
Each row is one cache entry. Matches that the refetch type covers refetch now (by default, the active ones); other matches are only marked stale and refetch when next mounted; the rest are left alone.

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 every onSuccess.
  • Apollo uses a normalized cache. Results are split into objects stored by __typename and id, such as Task:7. If the mutation returns { id: 7, title: 'New name' }, Apollo updates Task: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 call refetchQueries or edit the list with cache.modify.

Decision How should the UI catch up after a mutation?

Invalidate and refetch

invalidateQueries with a key prefix, or invalidatesTags in RTK Query.

Pros

  • Always ends up exactly matching the server.
  • No client copy of server rules.
  • One line per mutation.

Cons

  • One extra round trip per affected list.
  • Broad prefixes can refetch more than needed.

Write the response into the cache

setQueryData with the object the mutation returned.

Pros

  • No extra request for that key.
  • The detail view updates in the same render as the save.

Cons

  • Only correct for keys whose new value you know exactly.
  • Lists with sorting or filters drift.

Optimistic update

Snapshot, write the guess, roll back on error, invalidate on settle.

Pros

  • Feels instant even on a slow network.

Cons

  • Most code.
  • Needs care with races and rollbacks.
  • Wrong for actions that often fail.

Normalized cache

Apollo, Relay, or urql's Graphcache: mutation returns id and fields.

Pros

  • Field edits show everywhere with zero code.

Cons

  • Adds and removes from lists still need manual work.
  • Needs ids on everything and a schema-aware client.

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.

One infinite query entry under the key feed. Its data has two arrays: pages, holding posts 1 to 20, 21 to 40, and 41 to 60; and pageParams, holding null, c20, and c40, the cursor each page was fetched with. Below, a refetch of this entry sends three requests in order: the first page, then cursor c20, then cursor c40, because each cursor comes from the page before it. A note says maxPages caps how many pages are kept.["feed"]entry.datapagespageParamsposts 1-20nullposts 21-40c20posts 41-60c40on refetch: one request per page, in order1. GET first2. GET c203. GET c40maxPages: 3 keeps memory and refetches bounded
One key holds the whole scroll history. A refetch walks it from the top, one page at a time, so a long scroll means many requests; 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 data as always defined, and loading UI lives in one place per region instead of an if (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 useSuspenseQueries to 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.

  1. On the server, create a new QueryClient for this request and await queryClient.prefetchQuery(...) for the data the page needs.
  2. Render, and embed dehydrate(queryClient) in the page as JSON.
  3. In the browser, HydrationBoundary (TanStack Query v5) puts those entries into the browser cache, with their original dataUpdatedAt.
  4. Components call useQuery with the same keys and find data at once. No skeleton, no request, as long as the data is still fresh.
Three layers. On the server, for one request: create a new QueryClient, await prefetchQuery for the page's data, then dehydrate the cache. In the HTML sent to the browser: the rendered tasks, and the dehydrated cache as JSON. In the browser: HydrationBoundary puts the JSON into the client cache, and useQuery finds the data fresh, with no request. A note says the staleTime must be above 0, or the browser refetches at once.Server, per requestnew QueryClientnever sharedprefetchQueryawait the datadehydratecache to JSONHTMLrendered tasksdehydrated JSONBrowserHydrationHydrationBoundaryclient cachewarm at startuseQueryfresh: no requeststaleTime above 0, or the browser refetches every query at once.
The page arrives with both the markup and the data that made it. The browser cache starts warm, so the first render matches the server's and sends nothing, as long as the hydrated entries are 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.

A comparison matrix of TanStack Query, SWR, RTK Query, Apollo Client, and framework loaders. any API: TanStack yes, SWR yes, RTK Query yes, Apollo GraphQL, Loaders yes. focus refetch on: TanStack yes, SWR yes, RTK Query opt-in, Apollo no, Loaders no. mutation tools: TanStack yes, SWR basic, RTK Query tags, Apollo yes, Loaders actions. infinite lists: TanStack yes, SWR yes, RTK Query yes, Apollo manual, Loaders no. normalized: TanStack no, SWR no, RTK Query no, Apollo yes, Loaders no. cache across pages: TanStack yes, SWR yes, RTK Query yes, Apollo yes, Loaders no.TanStackSWRRTK QueryApolloLoadersany APIyesyesyesGraphQLyesfocus refetch onyesyesopt-innonomutation toolsyesbasictagsyesactionsinfinite listsyesyesyesmanualnonormalizednononoyesnocache across pagesyesyesyesyesno
Green is built in, hatched is partly or with extra setup, faded is missing. Loaders lose on the rows about long-lived data, which is why many apps pair them with a client cache.

How to choose

Decision Which server-state tool should a new React app use?

TanStack Query

QueryClientProvider at the root; useQuery and useMutation with designed keys.

Pros

  • Works with any API.
  • Most features: infinite, Suspense, SSR, offline, devtools.
  • Not tied to React only.

Cons

  • Most to learn.
  • Invalidation is by key, so keys must be designed with care.

SWR

useSWR(url, fetcher); mutate(key) after writes.

Pros

  • Smallest API and bundle.
  • Good defaults for read-heavy screens.

Cons

  • No fresh window; revalidates often.
  • Fewer tools for complex writes.

RTK Query

createApi with endpoints and tags; generated hooks.

Pros

  • Fits an existing Redux app.
  • Tags keep invalidation rules in one place.
  • Can generate endpoints from OpenAPI.

Cons

  • Brings Redux if you don't have it.
  • Conservative defaults need turning on.

Apollo or Relay

GraphQL queries and fragments over a normalized cache.

Pros

  • Edits to one object show everywhere.
  • Relay keeps re-renders very narrow.

Cons

  • GraphQL only.
  • List changes after mutations are still manual.
  • Larger runtime.

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.

A decision flowchart. Is the API GraphQL? If yes, use Apollo or Relay. If no: is Redux Toolkit already in the app? If yes, use RTK Query. If no: is the app small and mostly reads? If yes, use SWR. If no, use TanStack Query. A note says that in every case, route loaders or server components can handle the first render.GraphQL API?yesApollo or RelaynoRedux Toolkit already?yesRTK Querynosmall, mostly reads?yesSWRnoTanStack QueryEither way: loaders or server components for the first render.
Two questions about what you already have, one about what the app does. The last box is where most REST apps end up.

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.

ts
// 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]);
About 15 lines, and it looks finished. Count what it is missing in the list below.

What that slice gets wrong

  • One slot for many questions. items holds 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. isLoading goes true on 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 loadTasks again, 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 graph. At the top, task 7 on the server at version v2, after a save. Below, three copies of it in the browser. The task list in the Redux store was refetched after the save and has v2. The drawer's useState copy was made when the drawer opened and still has v1. The search results came from a different endpoint and still have v1. One save, three copies, one updated.task 7 on the serverv2v2list in storerefetched on savev1drawer useStatecopied on openv1search resultsother endpointone save, three copies, one updated
Every extra copy is one more place a save must reach. With a query cache, the components read the same entry instead of keeping their own, so there is nothing to forget.

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:

SymptomCauseFix
Wrong data under a filter or tabAn input missing from the keyPut every variable the queryFn uses into the key; TanStack's ESLint plugin checks this
Request on every tab switchDefault staleTime: 0 on slow-changing dataSet staleTime per kind of data
Edit doesn't show in the listInvalidated the wrong key, or a key that only shares a wordUse key factories so every key for one resource has the same prefix
Form resets while the user typesuseState(data) copied once, or an effect that copies data into state on every refetchKeep drafts separate; reset them only on open or save
Previous user's data after logoutCache survives the session changeCall queryClient.clear() on logout, or include the user id in keys
Twenty requests when a feed tab regains focusAn infinite query refetching every pageUse maxPages, a longer staleTime, or a "new posts" pill
One user sees another's data in SSR HTMLA QueryClient shared across server requestsCreate 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 key props on list rows so a refetch doesn't remount the row the user is on.
  • Announce only what matters. A polite aria-live region 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 to false when 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.

  1. 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."
  2. Name the keys. Write two or three real keys on the whiteboard: ['feed', { cursor }], ['post', id], ['me'].
  3. Give staleness numbers per kind of data. Feed: 30 s and focus refetch. Profile: 5 min. Config: forever.
  4. 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."
  5. 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."
LevelWhat a strong answer adds
JuniorUses a library instead of useEffect plus a store, and explains loading versus refreshing states.
Mid-levelDesigns keys, picks staleTime per data type, invalidates after mutations, handles pagination with cursors.
SeniorPlans 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: staleTime decides when to check with the server again (TanStack default 0), gcTime decides 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 setQueryData or 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?

Question 2

Explain staleTime and gcTime. What happens with the defaults?

Question 3

A user renames a task in a detail drawer. How does the list update?

Question 4

How do you server-render a page that uses a query cache without fetching twice?

Question 5

TanStack Query, SWR, RTK Query, or Apollo: how do you choose?

Practice it

These design problems lean on server-state libraries.

References

Read next