Skip to content
Chapter 76Lesson 1

When TanStack Query earns its weight

Opening the course's TanStack Query unit, this lesson draws the line between the workloads that need a client-side server-state library and the ones React and Next.js defaults already cover.

You have spent a long time learning where state lives without ever installing a state library. Read state goes to Server Components. Write state goes to Server Actions. Transient UI state stays in useState. Shareable view state goes into the URL with nuqs. Those are four defaults, and between them they cover almost the entire surface of a SaaS app.

This chapter has an awkward job to do. It introduces TanStack Query, the most popular data-fetching library in the React ecosystem, and the first thing to say about it is that you will reach for it rarely. That is not because the library is bad, but because the four defaults already cover most of what you would have used it for.

This lesson teaches no TanStack Query code at all: no useQuery, no setup, no API. Its only job is to build one habit, which is looking at any SaaS feature and answering this question out loud: does this earn a client-side server-state library, or does a platform default already cover it? Get the threshold right and the rest of the chapter is mechanical. Get it wrong and you will sprinkle the library across screens that never needed it, and the cost of that mistake lands on the codebase six months from now, not on you today.

Here is the question, stated plainly. Server Components fetch and stream data. Server Actions mutate it and revalidate so a user sees their own writes. useState holds the transient stuff, such as whether the menu is open or what the user has typed so far. nuqs puts the state you want to be shareable and refreshable into the URL.

What’s left?

That is the real question this chapter answers. You have four tools that together handle reads, writes, UI state, and view state. For a client-side server-state library to be worth its weight, you have to find a workload that none of those four can do well. That band is narrow, much narrower than most developers assume.

Before naming the band, it helps to recap the ground the defaults already cover, because the size of that ground is the whole point.

The four platform defaults, the state each owns, and where the student met it
Default The state it owns Where you met it
Server Components Server data, read and streamed into the page the App Router chapters
Server Actions Mutations, with revalidation for read-your-writes the Server Actions chapter
useState Transient UI state inside one component the React chapters
nuqs (URL) Shareable, refreshable view state the URL-state chapter
The four defaults, which between them cover almost the whole surface of a SaaS app.

Sit with that table for a second. Filtering a table, submitting a form, paginating a list, reading the signed-in user: every one of those resolves to a cell above, and none of them is a TanStack Query question.

So here is the idea the entire chapter hangs on: TanStack Query is conditional, not default. It is not “how you fetch data in React.” That was the 2022 mental model, written before Server Components and Server Actions existed, and this chapter sets out to replace it. This lesson defines the threshold, and the lessons after it assume the threshold has landed and apply only to workloads that clear it.

Exactly four workloads clear it: polling, cross-view caching, optimistic mutations into a cache, and infinite scroll with reuse. We will work through each one next, but first the term that ties them together.

Everything TanStack Query manages is server-state , meaning rows that live on your server. What makes it a client-side server-state problem is that the client ends up owning the cache and refetch lifecycle of that data, because the interaction is too live, too cache-heavy, or too optimistic for a server round-trip to drive it. A client-side server-state library is the tool for exactly that situation.

Hold that against its opposite. Whether a dropdown is open, the half-typed text in a search box, the chosen theme: none of that belongs to the server at all. That is plain client state, and it is never a TanStack Query question no matter how interactive it feels. Keep this line clear in your head, because it is the seed of a check we will run later.

The four triggers that cross the threshold

Section titled “The four triggers that cross the threshold”

Here is the rule: this course accepts four justifications for reaching past the defaults, and no more. If a workload does not match one of these four, the answer is a default. Naming all four up front gives you a four-slot schema to drop each workload into.

  1. Polling and frequent refetches.
  2. Complex client-side caching across views.
  3. Optimistic mutations with rollback into a cached query.
  4. Infinite scroll with cache reuse.

Each trigger only makes sense against the default it beats. A trigger is not “a thing TanStack Query can do.” It is the precise point where one specific default stops pulling its weight. So for every trigger we run the same four-beat template: the workload, the default that almost covers it, why that default breaks, and the trigger is met. The first one is worth walking through slowly, and then the other three will read as variations on it.

The workload. A comment thread that should show a coworker’s new message. A notification badge that should light up on its own. A job-status panel that reads “exporting…” and then “done.” What these share is that the client needs to learn about a server change on a tight cadence, without the user doing anything to ask for it.

The default that almost covers it. You already have a tool for “go get the fresh server data”: router.refresh(). It re-runs the current route segment on the server and streams the updated render back. Wire it to a button and the user can pull fresh data; wire it to a setInterval and it even repeats on a cadence. So far it looks like it covers the workload.

Why it breaks. router.refresh() re-runs the entire route segment, meaning every data fetch on the page and the whole server render, streamed back, just to discover whether one new comment arrived. For an occasional manual refresh that is fine. On a five-second interval it is far too heavy, because you are re-rendering the whole screen on a loop to check one small thing. The moment the cadence drops below user-initiated, that shape is wrong.

The trigger is met. Live data on a cadence wants a tool that refetches just that slice and updates just that part of the tree, which is exactly what a client-side cache with a refetch interval does. This is the most intuitive of the four, which is why it comes first. The next three follow the same template.

The workload. A detail page and a list page that read the same row. A sidebar that mounts and unmounts the same data as the user opens and closes it. A tab strip that flips between views of one underlying query. Moving between these should not hit the network again for data you already have.

The default that almost covers it. Next.js already caches. The Router Cache keeps prefetched route segments around so soft navigations don’t refetch, and use cache handles caching on the server.

Why it breaks. Those caches are scoped to navigations, that is, moving between routes. They do nothing for a deeply interactive client tree that repeatedly mounts and unmounts the same data within a single view, with no navigation happening at all. For that, an explicit client cache keyed by the query is the right tool, because every mount reads the cached value instead of refetching.

The trigger is met, but keep in mind that this is the weakest of the four. It is real, yet it is rarely the sole reason to bring the library in. If this is your only justification, look harder, because you are usually one navigation away from the Router Cache handling it.

Optimistic mutations with rollback into a cached query

Section titled “Optimistic mutations with rollback into a cached query”

The workload. An action that must show instantly, roll back cleanly on failure, and whose optimistic value has to land inside one or more cached queries. The new comment has to appear at the top of a cached, paginated list the moment you hit send, before the server has confirmed anything.

The default that almost covers it. React 19 gave you useOptimistic for exactly this feeling. It shows an optimistic value immediately and rolls it back automatically if the action fails, which handles the single-list, instant-feedback case cleanly.

Why it breaks. useOptimistic is scoped to one component’s render. It does not write into a separate cache, it does not survive a navigation, and it does not coordinate with other in-flight mutations. The instant the target of your optimistic update is a cached query, that is, a list TanStack Query is holding that other parts of the screen read from, you need the cache’s own optimistic machinery rather than a render-local value.

The trigger is met. Be precise about what changed here, because it is easy to misread. The distinction is not “optimism,” since useOptimistic does optimism perfectly well. The distinction is optimism into a separate cache.

The workload. A long comment thread, a feed, a chat. Scroll down and more loads. Scroll back up and the pages you already loaded are still there, instantly, with no refetch.

The default that almost covers it. You have already built paging: the cursor pagination from the lists chapter. It produces the right opaque-cursor URL and a correct hasNext flag.

Why it breaks. Cursor pagination is a replace experience. Each “Next” navigates to the following page and the previous one is gone, so scrolling back refetches. Infinite scroll is an accumulate experience with a different cache contract: every page you load stays in the client cache for the whole session, so scrolling back is free. It is the same data, but a genuinely different UX and a genuinely different contract.

The trigger is met.

That gives you four triggers, each against the default it beats. Here they are on one card.

  • Polling / frequent refetches

    Learn about server changes on a cadence, with no user action.

    instead of router.refresh() on an interval

  • Optimistic into a cache

    An optimistic value must land inside a cached query.

    instead of useOptimistic

  • Infinite scroll with reuse

    Accumulate pages, then scroll back without a refetch.

    instead of cursor pagination

  • Cross-view caching

    Re-mount the same data across views without refetching.

    instead of the Router Cache

    weakest trigger
The four-slot schema: each trigger is the point where one specific default stops pulling its weight.

Why the bar is high: the cost of a second cache

Section titled “Why the bar is high: the cost of a second cache”

Four triggers, and everything else is a default. Why so strict? Because bringing TanStack Query in is not free, and that cost is what raises the bar. Read it as a ledger of what you take on.

  • A second cache to reason about. The Server Component cache does not go away. Now two caches hold your data, and “where does this actually live” has two possible answers on every screen.
  • A second invalidation surface. Server-side data is invalidated with updateTag / revalidateTag. The client cache is invalidated with invalidateQueries. They are separate on purpose, so a mutation that touches data both layers hold has to invalidate both. Miss one and you get the canonical bug: the list paints fresh while the detail stays stale. The chapter’s later lessons show this in code; here it is just a line in the cost column.
  • A second mental model for “when does this refetch.” Stale time, garbage-collection time, refetch-on-focus: knobs the Server Component world never made you think about. Now you own them.
  • A runtime dependency. It is well-maintained and widely used, but it is still a dependency that ships in your client bundle and that every future maintainer has to understand.
  • A forced Client Component boundary. Every useQuery lives in a Client Component, which pulls its whole subtree out of Server-Component land. The library does not just add a cache; it changes where the boundary sits.

Read that ledger the way an experienced engineer does: every line shrinks the surface you should be willing to apply the library on. The library is genuinely right for the four triggers and genuinely wrong for everything else, not because it is a bad library, but because the cost is only worth paying where a default has actually failed.

This is what makes the decision architectural rather than a preference. The cost does not land on your screen; it lands on the codebase’s future. Drop TanStack Query into one screen because it was marginally convenient, and the next developer who opens the file assumes it is the house pattern. Six months later there are forty useQuerys doing work Server Components would have done faster and simpler, and every reviewer now has to reason about two caches on every pull request. Setting the threshold, that is, deciding what doesn’t get the library, is the senior contribution here. It gets decided before any code is written, and it keeps the system changeable afterward.

You will recognize a non-trigger far more often than a trigger, so this is the skill worth drilling hardest. There is a clean procedure for it: enumerate which default the workload would otherwise use, and only if every default is wrong does TanStack Query earn its weight. The key move is that you do not ask “is this a default?” in the abstract; you name which default, specifically. Here is the procedure run against four workloads that look like they might want a fetching library but don’t.

  • A list view with filter, sort, search, and pagination. The default is the URL’s job: the state has to be shareable and survive a refresh, which means nuqs driving Server Components, exactly as the lists chapter built it. This is not a client cache.
  • A form that submits and shows field errors. The default is a Server Action holding the mutation contract, with useActionState holding the pending and error state. The Server Actions chapter owns this end to end.
  • A single optimistic toggle: a star, a favorite, a checkbox that flips instantly. The default is useOptimistic: one component, one optimistic value, one rollback path. No separate cache is involved, so no library.
  • Reading the current user. This is server-state, but the default reads it once on the server with auth() inside a Server Component. Server-state does not automatically mean client-cached server-state.

Before you even start that enumeration, run one cheaper check first: do we even own this on the client? Plenty of state that feels live or interactive does not belong to the server at all, such as URL state, form inputs, theme preference, or whether a panel is open. None of that is server-state, so none of it is a TanStack Query question regardless of how dynamic it feels. This catches the most common mistake of all, which is using useQuery as a generic state manager. It is not one. That is what useState and, for global client state, Zustand are for, which a later chapter covers. Even the optimistic-into-a-cache trigger only earns its weight when the rollback target is server-state; an optimistic theme switch is still just client state.

Now you can use the procedure. The exercise below gives you SaaS workloads, one per chip. For each one, run the enumeration by naming the default it would use, and only sort it into “earns it” if every default genuinely fails.

For each workload, name the default it would otherwise use — `nuqs`, a Server Action, `useOptimistic`, `auth()`, `useState`. Only if every default is wrong does it land in 'TanStack Query earns it'. Drag each item into the bucket it belongs to, then press Check.

TanStack Query earns it Is it live, optimistic-into-a-cache, or accumulate-and-reuse?
A default already covers it Which default — nuqs, a Server Action, useOptimistic, auth(), or useState?
A job-status panel that polls every 5s until the export finishes
A chat thread the user scrolls deep into, then scrolls back up with no refetch
Posting a comment that must appear instantly at the top of a cached, paginated thread
A notification badge that lights up without anyone clicking
An invoices table with filter, sort, and a shareable URL
An edit-invoice form that submits and shows field errors
A single “mark as favorite” star with instant feedback
Showing the signed-in user’s name in the header
A dark-mode toggle saved to localStorage

The toggle chip is the one worth dwelling on: a theme preference is not server-state, so it never reaches the four triggers at all. It falls out at the “do we even own this on the client?” check, one step before the enumeration even starts.

The funnel a senior runs before reaching for the library

Section titled “The funnel a senior runs before reaching for the library”

Everything so far folds into one ordered decision procedure. The order matters more than any single answer, because the common mistake is to pattern-match “this feels like a fetch” and skip straight to the library. A senior asks the gates in sequence instead.

Walk the funnel below one branch at a time. Each gate you answer “no” to drops you to the next. The first gate your workload genuinely needs sends you to the matching tool, and if you fall through all of them, you land on the default, which is where most workloads land.

Does this workload earn TanStack Query?

That funnel is the gate the whole chapter sits behind. The lessons after it, covering the primitives, the wiring, and the worked screen, only matter for a workload that reaches one of the TanStack leaves. Most of the time you will land on the default leaf, and landing there is a correct answer, not a failure to find the fancier tool.

Server Components own the first paint, TanStack owns the live cache

Section titled “Server Components own the first paint, TanStack owns the live cache”

There is one thing to clear up before you picture a workload that does clear the bar, because the wrong picture is hard to shake. Crossing the threshold does not turn the page into a client-only app that fetches everything with a spinner on load.

The shape this course insists on is layered. Even when a feature earns the library, the page stays a Server Component that prefetches the same data the client will read, then hands it across a hydration boundary, so the first paint is server-rendered and the client cache starts out already populated. No useQuery fires a cold request on initial load, because that network round-trip happened inside the server render. Only the live parts of the screen, meaning the polling, the optimistic mutations, and scrolling to the next page, use the client cache after that. The two systems own different phases of the same screen, so they do not compete. The payoff is that you pay TanStack Query’s price only for the parts of the surface that actually need it, and everything else stays Server-Component-fast.

This is a preview that a later lesson wires up, but it is worth seeing now so the threshold has a concrete landing.

  1. Initial paint Server Component prefetch → hydrate
  2. Live interactions TanStack client cache poll · mutate · scroll

first paint over time

Two systems, two phases of the same screen: the Server Component owns the first paint, and the TanStack cache owns the live interactions after it.

You may know there is another library in this space, so let’s settle the choice deliberately rather than by default. SWR is the older, smaller alternative from the Vercel team, built around the stale-while-revalidate pattern, and for simple read-and-revalidate cases it is perfectly good. This course picks TanStack Query as its canonical client-side server-state library because the four triggers need its richer surface: a real mutation lifecycle with first-class optimistic patterns, infinite queries with a maxPages cap on memory, shared mutation state across components, stronger devtools, and broader ecosystem reach. SWR does not have the mutation and infinite-query story the chapter’s worked screen depends on.

One honest caveat: if a codebase already runs SWR for these workloads, that is completely fine. Everything in this lesson, meaning the threshold, the four triggers, and the two-cache reality, transfers unchanged. The only thing that does not is the specific API, which this course does not teach for SWR. The decision is the durable part, and the library is the replaceable part.

Let’s make the threshold concrete by pointing at the one surface in our own app that clears it: a per-invoice comment thread on every invoice detail page, which you read, scroll back through, and add to. The product reason is the mundane SaaS standard: disputes, internal notes, and a record of customer correspondence attached to the invoice it concerns.

Run the funnel against it and watch it clear the bar honestly. It needs polling, since a coworker posts from another session and you should see it without refreshing. It needs optimism into a cache, since your new comment appears instantly at the top of the cached thread and rolls back if the server returns a 500. It needs infinite scroll with reuse, since you can be hundreds of comments deep and scroll back up with no refetch. There is even a weak fourth case, the same thread peeked from a recent-activity sidebar. That is three strong triggers met on one surface, which makes it the strongest case in the entire course for the library being the right call.

And yet, holding onto the half of the mental model that is easiest to lose, most of that page is still Server Components. The header, the customer card, the line items, and the totals are all server-rendered, with none of them touching TanStack Query. Only the comment thread crosses into the client cache. That is the shape to keep: not “the page is now a client app,” but “the page is a Server Component with one live leaf.”

From here the chapter goes deeper. The next lesson teaches the handful of primitives this screen reaches for. The lesson after wires them against the App Router without leaking the cache. The last lesson runs the full funnel against this exact screen and sets up the project that builds it end to end. All of it sits behind the gate you just learned to operate.