The four primitives the project reaches for
The four core TanStack Query v5 hooks, useQuery, useMutation, useInfiniteQuery, and the optimistic update, written at the component call site.
The screen you are building is the per-invoice comment thread: live, optimistic, and infinitely scrollable. The previous lesson argued that a screen like this earns the library. This lesson shows you what you actually type.
It is a tour of the four primitives that thread is built from: useQuery to read, useMutation to write, the v5 decision about how to do an optimistic update, and useInfiniteQuery for the paginated thread itself.
Around those four sits a small supporting cast that you’ll meet as each primitive needs it: query keys, the imperative cache calls, and polling.
One boundary is worth setting before the first line of code, because it explains every example that follows.
This lesson teaches the call site: what you write inside a component once a cache already exists.
It does not teach how that cache gets created and wired into the App Router, which is the next lesson’s whole job.
So when an example calls useQuery with no provider in sight, that is not an omission: the provider lives one lesson away, and the call site is what we’re drilling here.
By the end, all four primitives land together in one short component you could lift straight into the project.
useQuery: the read primitive
Section titled “useQuery: the read primitive”Start with the read hook, because it is the simplest of the four and every other primitive borrows its vocabulary.
A query is one idea: a read of server state, addressed by a key.
You hand useQuery a key that identifies the data and a function that fetches it. In return, it hands you back the data plus a set of flags describing where the request is in its lifecycle.
const { data, error, isPending, isFetching } = useQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: () => fetchComments(invoiceId), staleTime: 60_000,});The destructured returns. data is the resolved value, or undefined before the first success; error is whatever the fetch threw; isPending and isFetching are the two flags we unpack next.
const { data, error, isPending, isFetching } = useQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: () => fetchComments(invoiceId), staleTime: 60_000,});The query key is the cache address. Two queries with the same key share one cache entry, and everything else in the library, refetching, invalidation, and optimistic writes, is addressed through this array. The next section is entirely about it.
const { data, error, isPending, isFetching } = useQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: () => fetchComments(invoiceId), staleTime: 60_000,});The fetcher. It returns a Promise that resolves to the data shape, or it throws, and a throw becomes the error above. It says nothing about where the data comes from; that’s the section after the keys.
const { data, error, isPending, isFetching } = useQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: () => fetchComments(invoiceId), staleTime: 60_000,});How long the cached data is treated as fresh. While it’s fresh, mounting the component or refocusing the tab will not refetch. We come back to why 60_000 rather than the library’s default in a moment.
That destructure is the whole API for a read.
But two of those flags, isPending and isFetching, are the most confused pair in the library, and getting them right is the difference between a clean loading UX and a screen that flickers every time the user looks away and back.
isPending versus isFetching
Section titled “isPending versus isFetching”They sound like synonyms, but they answer two completely different questions.
isPending asks: have I ever resolved?
It is true only until the very first successful fetch, the state where there is no cached data yet and nothing to show.
Once a query has resolved even once, isPending is false from then on, because the cache now holds something to render.
isFetching asks: is a request in flight right now?
It is true during the first fetch, but also during every background refetch afterward: a poll firing, a window refocus, a manual invalidation.
A query can have data sitting in the cache and still be isFetching while it checks for a fresher copy.
That split maps cleanly onto two different things you put on screen.
Two flags, two UI states. The skeleton is gated on isPending, a quiet in-place spinner on isFetching. They are never the same UI.
The rule follows from the split.
Render the skeleton while isPending, because there is genuinely nothing to show.
Render a subtle spinner or a slight dimming while isFetching: there is content on screen and you are just freshening it, and yanking it back to a skeleton would make a quiet background refetch feel like a full reload.
There is a third flag you’ll see in the docs. v5 also exposes isLoading, defined as exactly isPending && isFetching: first load, no cache, request in flight.
It exists and it’s fine to use, but this course reaches for the two underlying flags because they map one-to-one onto the two UI states above.
Build on isPending and isFetching, and treat isLoading as a convenience alias for the cold-load corner.
Why staleTime: 60_000 and not the default
Section titled “Why staleTime: 60_000 and not the default”Now the staleTime line from the destructure, because the library’s default here is a trap for SaaS reads.
The default staleTime is 0.
Zero means data is considered stale the instant it arrives, so every mount and every window focus fires a refetch.
For a stock ticker or a live scoreboard, that is exactly right: you genuinely want the freshest number every time the component appears.
For almost every SaaS read, it is wrong, and the symptom is a refetch storm: alt-tab away to read an email, come back, and the screen refetches every query it holds, for data that was perfectly current a few seconds ago.
The senior default flips it.
useQuery({ queryKey, queryFn, staleTime: 0 });Refetches on every mount and every focus. Data is stale the moment it lands, so remounting the component or refocusing the tab fires a fresh request each time. Correct for always-live data like a price ticker, but a refetch storm for a comment thread that changes a few times an hour.
useQuery({ queryKey, queryFn, staleTime: 60_000 });Serves the cache for a minute, no thrash. For 60 seconds the cached data counts as fresh, so a remount or refocus reads straight from the cache with no network hit. The user still gets fresh data, because polling and explicit invalidation override staleness; they just stop paying for a refetch every time they glance away and back.
You’ll see 60_000 set once, on the QueryClient, in the next lesson, so it becomes the default for every query in the app rather than a line you remember to paste.
At a single call site you override it only when that one query genuinely needs different freshness, and when you do, you leave a comment saying why.
One last thing about data before we move on, because it’s easy to get wrong.
The data you destructure is not a copy; it is a direct reference into the cache.
Mutate it in place with something like data.comments.push(newOne) and you have edited the cache behind the library’s back: no re-render fires, and the store is now in a state TanStack Query doesn’t know about.
Treat data as frozen. When you need to change cached data, go through the cache’s own write calls, which we get to shortly.
Here are the two load-bearing config keys with their definitions inline:
const { data, error, isPending, isFetching } = useQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: () => fetchComments(invoiceId), staleTime: 60_000,});Query keys are the cache contract
Section titled “Query keys are the cache contract”We’ve now leaned on the query key twice and deferred it both times. It earns its own section, because the entire library hinges on key identity: refetching, invalidation, and the optimistic writes coming later are all addressed through this one array.
A query key is an array, and the convention is to make it hierarchical, broad on the left and specific on the right:
['comments'];['comments', 'list', invoiceId];['comments', 'detail', commentId];The hierarchy is what makes invalidation ergonomic.
When you tell the cache to invalidate ['comments'], it matches by prefix: every key that starts with ['comments'] is included, so the whole family refetches at once.
Hand it the longer ['comments', 'list', invoiceId] instead and you’ve narrowed the scope to one invoice’s list, leaving every other comment query untouched.
You pick how wide to reach by how much of the key you specify.
There is one real watch-out here.
Keys are serialized internally so the cache can look them up, which means only serializable values belong in a key: strings, numbers, booleans, and plain objects.
A Date, a function reference, a class instance, or a React component does not serialize stably, and putting one in a key quietly breaks the lookups that the whole library depends on.
Use primitives and plain objects, nothing else.
Centralize the keys
Section titled “Centralize the keys”Writing those arrays by hand at every call site is how a codebase drifts.
One file types ['comments', 'list', id], another types ['comment', 'list', id], the invalidation silently misses, and you spend an afternoon learning that a typo’d string in an array doesn’t throw; it just fails to match.
The fix is a single typed helper per feature, so the raw arrays are written exactly once:
export const commentKeys = { all: ['comments'] as const, lists: (invoiceId: string) => [...commentKeys.all, 'list', invoiceId] as const, detail: (id: string) => [...commentKeys.all, 'detail', id] as const,};Now every call site reads commentKeys.lists(invoiceId) instead of an inline array.
The read side and the write side reference the same function, so they cannot drift, and the as const keeps each key narrowly typed as a literal tuple rather than widening to string[].
If this shape feels familiar, it should. It is the same discipline as the cache-tag helper from the caching chapter: one source of truth for the strings that address a cache, so the code that fills the cache and the code that invalidates it can never disagree. The earlier chapter applied it to the Server Component cache; this is the identical pattern applied one layer over, to the client cache. You are not learning a new idea here, you are reusing one you already trust.
The mechanic to lock in is prefix matching, so try it.
Below are several query keys.
For each, decide whether invalidating ['comments', 'list', 'inv-1'] would refetch it, which comes down to a single question: does the key start with that exact prefix?
An invalidation of ['comments', 'list', 'inv-1'] matches a query only if its key starts with that exact prefix. Sort each key by whether it gets refetched. Drag each item into the bucket it belongs to, then press Check.
['comments', 'list', 'inv-1']['comments', 'list', 'inv-1', { sort: 'newest' }]['comments', 'list', 'inv-2']['comments', 'detail', 'cmt-9']['comments']['invoices', 'list', 'inv-1']The chip worth pausing on is ['comments'] on its own.
It is broader than the prefix you invalidated, not a descendant of it, and invalidating a child never reaches back up to the parent.
Matching only ever flows from a shorter prefix down to the longer keys beneath it.
queryFn: what the cache reads from
Section titled “queryFn: what the cache reads from”The key says which data. The queryFn says how to get it.
It is an async function that resolves to the data shape, or throws on failure, and as you saw, a throw lands in useQuery’s error, so failures become values you render rather than exceptions you chase.
The open question is what that function should actually call, and the course makes a deliberate decision.
The queryFn calls a route handler, not a Server Action.
const fetchComments = async (invoiceId: string, cursor: string | null) => { const res = await fetch(`/api/invoices/${invoiceId}/comments?cursor=${cursor ?? ''}`); if (!res.ok) throw new Error('Failed to load comments'); return commentPageSchema.parse(await res.json());};The reason is a clean division of labor. Server Actions are built for form submissions: they carry redirect and revalidation semantics, and their error shape is tuned for “show this message under that field.” A read is none of those things. It wants stable HTTP status codes, it wants to be cacheable, and it wants a shape that any HTTP client could consume. That is exactly what a route handler is for, and you built them a few chapters back. The route handler is the seam between the client cache and the database, and as a bonus it doubles as a public contract: a future mobile app or a third-party integration hits the same URL.
State the split once and it covers every case you’ll meet:
TanStack Query reads from route handlers. Mutations can go either way: a route handler when you want the contract, a Server Action when you want the in-app shortcut.
The mutation side gets settled in the project lesson; for reads, it’s always the route handler.
Parse the response with Zod
Section titled “Parse the response with Zod”Notice the last line of that fetcher: commentPageSchema.parse(...).
That is not decoration; it is the thing that makes the cached data trustworthy.
The route handler validates its response against a Zod schema, the contract from the route handlers chapter.
The queryFn parses the response it receives against that same schema.
The schema lives in /lib, and both sides import it, so there is one definition rather than two that can drift.
The payoff is that cached data is typed by construction.
If the server’s shape ever drifts from what the client expects, such as a renamed field or a missing key, the parse throws, and that throw surfaces as a useQuery error you can see and handle.
Without the parse, the same drift is silent: TypeScript believes the data matches its type because you told it the fetch returns that type, and the mismatch only shows up later as a confusing runtime crash deep in your render.
One forward pointer so you’re not surprised in the next lesson: on the server, this same read runs the database query directly instead of fetching its own URL over the network. The function branches on whether it’s running in the browser, and both branches validate against the same schema. That branch is a wiring detail the next lesson covers; here, just hold that the schema is the shared contract on both sides.
useMutation: the write primitive and its lifecycle
Section titled “useMutation: the write primitive and its lifecycle”Reads are done. Writes are the second primitive, and they are where the library gets genuinely interesting, because a write is not a single moment: it is a lifecycle with hooks you can attach behavior to.
Lead with the destructure:
const { mutate, mutateAsync, isPending, error } = useMutation({ mutationFn: (input) => postComment(invoiceId, input), onMutate, onError, onSuccess, onSettled,});The shape rhymes with useQuery: a function that does the work (mutationFn instead of queryFn), and some flags.
What’s new is the four callbacks, onMutate, onError, onSuccess, and onSettled, and they are the reason useMutation exists rather than you just calling the fetcher yourself.
The mutation lifecycle
Section titled “The mutation lifecycle”A mutation moves through a fixed sequence, and each of its four callbacks fires at one specific point in it.
The part that trips people up isn’t when each fires; it’s that onMutate and onError are connected. onMutate returns a value, and that value is handed to onError.
That handoff is the rollback channel, and it’s the piece you have to see to understand optimism.
Walk the sequence:
onMutate stashes rides forward as context to onError mutate(input) is called, and onMutate(input) fires before the request leaves. Whatever it returns becomes the context, carried forward to the callbacks below. This is the seat for an optimistic update.
onMutate stashes rides forward as context to onError The request is in flight. isPending is true, so the submit button can disable and show a spinner.
onMutate stashes rides forward as context to onError On success, onSuccess(data, input, context) fires. data is the server’s response, the real, persisted row.
onMutate stashes rides forward as context to onError On failure, onError(error, input, context) fires, and it receives the same context that onMutate returned. This is where you roll back, using the snapshot you stashed in the first step.
onMutate stashes rides forward as context to onError Either way, onSettled(data, error, input, context) runs last, the cleanup seat. Invalidation usually lives here, so the cache reconciles with the server whether the write succeeded or failed.
Read that sequence as a story about a snapshot.
onMutate runs first and can stash a value, typically a snapshot of the cache before you touch it.
That value rides along as context to onError, where you use it to put the cache back if the write failed.
onSettled cleans up at the end no matter which branch fired.
Hold that shape; the optimistic section is about to fill in exactly what goes in each callback.
mutate versus mutateAsync
Section titled “mutate versus mutateAsync”There are two ways to fire the mutation, and the choice is simple.
mutate(input) is fire-and-forget.
You call it, it kicks off the lifecycle, and the callbacks handle whatever happens: success, failure, cleanup.
It returns nothing useful, so you don’t await it.
This is the common case and the senior default.
mutateAsync(input) returns a Promise you can await.
Reach for it only when you genuinely need to compose the result into a larger async flow, such as chaining a second mutation after the first resolves, or redirecting only once the write has confirmed.
The cost is that mutateAsync rejects on failure, so you own the try/catch, whereas mutate never throws and leaves the error to onError.
Default to mutate. Escalate to mutateAsync only when you must await the outcome.
Two callbacks carry the weight of the lifecycle; here they are with their definitions inline:
const { mutate, isPending } = useMutation({ mutationFn: (input) => postComment(invoiceId, input), onMutate, onError, onSettled,});Optimistic updates: the v5 two-shape decision
Section titled “Optimistic updates: the v5 two-shape decision”Optimism is where “knows the API” and “knows the library” part ways. The reason is that v5 gives you two different shapes for an optimistic update, and choosing the right one is the actual skill. Most of the confusion in this corner of the library comes from people learning one shape and forcing it everywhere.
Frame it as a choice with a default and an escalation trigger.
See the two side by side first, then we’ll walk the harder one in detail.
(Both lean on queryClient, the imperative cache handle. We name it formally two sections from now; for the moment, read it as “the cache, called directly.”)
const { mutate, variables, isPending } = useMutation({ mutationFn: (input) => postComment(invoiceId, input), onSettled: () => queryClient.invalidateQueries({ queryKey: commentKeys.lists(invoiceId) }),});
// then, in the component's JSX:{isPending && <CommentRow comment={variables} pending />}No cache write, no rollback code. The component reads the in-flight variables and isPending straight off the mutation and renders the optimistic row inline. When the mutation settles, isPending flips to false and the inline row vanishes on its own: success refetches the real row, failure just drops it. Right for one list, one optimistic add, one rollback path.
const { mutate } = useMutation({ mutationFn: (input) => postComment(invoiceId, input), onMutate: async (input) => { await queryClient.cancelQueries({ queryKey: commentKeys.lists(invoiceId) }); const prev = queryClient.getQueryData(commentKeys.lists(invoiceId)); queryClient.setQueryData(commentKeys.lists(invoiceId), (old) => /* prepend input */); return { prev }; }, onError: (_err, _input, ctx) => queryClient.setQueryData(commentKeys.lists(invoiceId), ctx.prev), onSettled: () => queryClient.invalidateQueries({ queryKey: commentKeys.lists(invoiceId) }),});Writes the optimistic value into the cache itself. onMutate snapshots the cache, writes the optimistic shape, and returns the snapshot; onError restores it; onSettled invalidates to reconcile. More code, but the optimistic row now lives in the cache, so it flows into every query reading that key, survives navigations, and coordinates with other in-flight mutations. Right when the optimism must reach cached data, not just one component’s render.
The two tabs are doing genuinely different things.
Via variables never touches the cache. It reads the input that’s currently in flight and paints a temporary row from it, purely inside this one component’s render.
It is almost no code, and it is the right reach for the simple case: a single list, a single optimistic add, a single rollback path.
If useOptimistic from the React forms chapter is the framework-native version of this idea, “via variables” is its TanStack Query cousin: same render-local feeling, same automatic cleanup.
Cache update is the heavier shape, and it earns its weight when the optimistic value has to land in the cache rather than one render. That is the case the moment another part of the screen reads the same query, or the optimism has to survive a navigation, or it has to coexist with other mutations in flight. The cost is the cancel-snapshot-write-restore-invalidate dance you see in the tab, and that dance is order-dependent in a way worth slowing down for.
The cache-update sequence, step by step
Section titled “The cache-update sequence, step by step”Cache-update is a whole sequence rather than a single write because the order is what keeps it correct. Skip a step or reorder them and you get a subtle bug where the optimistic value flickers, gets clobbered by a stale response, or never rolls back. Walk it once, with the why of each step attached:
await queryClient.cancelQueries({ queryKey }) A background refetch is in flight. Stop it now — left running, it could resolve after your write and overwrite it with stale data.
Cancel first. Stop any in-flight refetch for this key. Without it, a poll or background fetch that resolves mid-update lands after your optimistic write and overwrites it with stale server data. This is the step people skip and the bug they then chase.
const prev = queryClient.getQueryData(key) Copy the current cached list aside. This is the value you restore if the write fails — grab it while it is still correct.
Snapshot. Grab the current cached value before you change it. This is the value you’ll restore if the write fails, so stash it now, while it’s still correct.
queryClient.setQueryData(key, (old) => /* prepend */) Replace the cached list with the new shape — the optimistic row on top. The UI re-renders from the cache at once; the user sees their comment immediately.
Write the optimism. Replace the cached value with the new shape: the comment list with the new row prepended. The UI re-renders immediately from the cache, so the user sees their comment at once.
return { prev } Return the snapshot from onMutate so it becomes context — it now rides forward to onError, the rollback channel from the lifecycle diagram.
Hand off the snapshot. Return it from onMutate so it becomes context. This is the handoff from the mutation-lifecycle diagram: the snapshot now rides along to onError.
queryClient.setQueryData(key, ctx.prev) On failure, onError writes the snapshot back — the optimistic row vanishes. The cache is exactly as it was before the user clicked.
Restore on failure. If the request failed, onError writes the snapshot back, erasing the optimistic row. The cache is exactly as it was before the user clicked.
queryClient.invalidateQueries({ queryKey }) Win or lose, invalidate so the cache refetches the server's truth — the optimistic row is swapped for the real persisted one (or the rollback is confirmed).
Reconcile. Win or lose, onSettled invalidates so the cache refetches the authoritative server state. On success this swaps your optimistic row for the real persisted one; on failure it confirms the rollback against the server.
The one step to burn in is the first.
Cancel before you write is not a nicety; it is the thing standing between your optimistic value and a stale response landing on top of it.
Picture the thread polling every ten seconds.
A poll fires, then the user posts a comment, your optimistic write lands, and then the poll’s response, which predates the new comment, resolves and overwrites the cache, erasing the optimistic row a beat after it appeared.
cancelQueries is what prevents that race. Treat it as mandatory, not optional.
Which shape, and which one the project uses
Section titled “Which shape, and which one the project uses”The senior reach is a default with an escalation:
Start with via variables. Escalate to cache update only when the simpler shape doesn’t fit.
The project’s comment thread is squarely in escalation territory, and it’s worth knowing why before the project lesson builds it.
The thread is a useInfiniteQuery, and a new comment must appear at the top of its first page the instant you hit send.
That first page lives in the cache, as data.pages[0], so making the comment appear there is a cache write, not an inline render.
Via variables can’t reach into a paginated cache structure; cache update can.
The full updater function is the project lesson’s job; here, just hold which shape (cache update) and why (the optimism has to land in a cached page).
One convention that pays off later: the optimistic comment carries the same client-generated UUID that the Server Action will receive. So when the real row comes back from the server on invalidation, it replaces the optimistic one by key instead of appearing as a second, duplicate comment. You generate the id on the client, render the optimistic row with it, send it with the write, and the persisted row inherits it: one comment throughout, never two.
Now drill the escalation trigger directly. The whole skill is knowing which situations force cache-update.
Which of these situations force the cache-update shape — the ones via-variables genuinely can’t cover? Select all that apply.
commentKeys.lists(invoiceId) query and must show the pending comment too.useInfiniteQuery page that must include it, or survival across a navigation — the value belongs in the cache, so you reach for cache-update. When the optimism is local to one render and one rollback path (the last two), via-variables or useOptimistic is the lighter, correct shape.useInfiniteQuery: the cursor-paginated cache
Section titled “useInfiniteQuery: the cursor-paginated cache”The fourth primitive is the read hook’s bigger sibling, built for the case where the data arrives in pages and the user scrolls through them. The comment thread is exactly this: hundreds of comments deep, loaded a page at a time, and the pages you’ve already fetched stay in the cache so scrolling back is instant.
Lead with the config, because it’s where all the new ideas live:
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: ({ pageParam }) => fetchComments(invoiceId, pageParam), initialPageParam: null, getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, getPreviousPageParam: (firstPage) => firstPage.prevCursor ?? undefined, maxPages: 10,});The queryFn now receives { pageParam }, the cursor for the page being fetched. Same fetcher as before, just told which slice to load.
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: ({ pageParam }) => fetchComments(invoiceId, pageParam), initialPageParam: null, getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, getPreviousPageParam: (firstPage) => firstPage.prevCursor ?? undefined, maxPages: 10,});The cursor for the very first page. Here it’s null, meaning “start from the beginning.”
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: ({ pageParam }) => fetchComments(invoiceId, pageParam), initialPageParam: null, getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, getPreviousPageParam: (firstPage) => firstPage.prevCursor ?? undefined, maxPages: 10,});Given the last page loaded, return the cursor for the next page, or undefined to signal there are no more. undefined is the stop signal, and note it must be undefined, not null, because null is a valid first param and so can’t double as “done”.
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: ({ pageParam }) => fetchComments(invoiceId, pageParam), initialPageParam: null, getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, getPreviousPageParam: (firstPage) => firstPage.prevCursor ?? undefined, maxPages: 10,});The same idea backward. It’s here because maxPages is set: a capped infinite query needs both directions so a dropped page can be re-fetched on scroll-back. (More on that below.)
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: ({ pageParam }) => fetchComments(invoiceId, pageParam), initialPageParam: null, getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, getPreviousPageParam: (firstPage) => firstPage.prevCursor ?? undefined, maxPages: 10,});Cap the number of pages kept in the cache at 10. Beyond that, the oldest page drops to bound memory.
The returns are shaped for scrolling rather than a single read.
fetchNextPage loads the following page; hasNextPage tells you whether there is one, so you can disable the button at the end; isFetchingNextPage is true while a next-page load is in flight, so you can show a spinner on the button specifically rather than the whole list.
The data.pages render shape
Section titled “The data.pages render shape”Here’s the part that catches everyone the first time.
data from an infinite query is not a flat array of comments.
It’s an array of pages, each holding one fetch’s worth of results: data.pages is [page1, page2, page3, ...], and each page is whatever your queryFn returned.
So at the render site you flatten it:
const comments = data.pages.flatMap((page) => page.comments);flatMap walks each page, pulls out its comments array, and concatenates them into the single flat list your UI actually renders.
And the load-more button reads straight from the returns:
<button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}> {isFetchingNextPage ? 'Loading…' : 'Load older comments'}</button>Disabled when there’s nothing left to load or a load is already running; the label flips while isFetchingNextPage.
That’s the whole interaction.
Why maxPages, and why both directions
Section titled “Why maxPages, and why both directions”maxPages is the senior call, and its motivation is a memory leak you’d otherwise ship.
Without a cap, a thread the user scrolls deep into accumulates pages in the cache forever: every page they’ve ever loaded, held in memory for the whole session.
On a long enough thread that’s a real leak.
maxPages: 10 bounds it: only the ten most recent pages stay cached, and when an eleventh loads, the oldest drops.
The trade is that scrolling back to a dropped page refetches it, a small network cost in exchange for bounded memory.
For a chat-style thread where re-entry to old scroll positions is common but the user rarely needs all of it at once, ten is the right number.
Leave it undefined (unbounded) only for feeds where scrolling back up is rare enough that you’d rather never refetch.
The cap has one consequence that v5 makes explicit, and it’s the reason getPreviousPageParam is in the config.
When maxPages is set, you must define getPreviousPageParam as well as getNextPageParam.
The logic is direct: if an older page can be dropped from the cache, the library needs to know how to re-fetch it when the user scrolls back up to it, and fetching backward requires the previous-page cursor.
Set the cap, define both directions. Omit one and the scroll-back refetch has no cursor to work with.
This is, in the end, a different cache contract than the server-side cursor pagination from the lists chapter. That pagination is a replace experience: each “Next” swaps in the following page and discards the one before it, so scrolling back refetches from scratch. Infinite scroll accumulates: every page you load stays in the cache (up to the cap), so moving back and forth is free. Same underlying cursors, genuinely different UX, genuinely different cache contract.
Lock the config keys by filling them in:
Fill the three blanks: the first-page cursor key, the value getNextPageParam returns to stop paging, and the page-cap key. Pick the right option from each dropdown, then press Check.
useInfiniteQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: ({ pageParam }) => fetchComments(invoiceId, pageParam), ___: null, getNextPageParam: (lastPage) => lastPage.nextCursor ?? ___, ___: 10,});The middle blank is the one that matters.
getNextPageParam returns undefined, not null and not 0, to say “no more pages.”
null can’t double as the stop signal because it’s a perfectly valid first cursor (it’s the initialPageParam right above it); undefined is reserved for “done.”
The cache trio: invalidateQueries, setQueryData, removeQueries
Section titled “The cache trio: invalidateQueries, setQueryData, removeQueries”You’ve already used these inside the optimistic shapes. Now name them properly, because they’re the imperative surface you reach for in event handlers and mutation callbacks.
They all hang off the query client, which you get with a hook:
const queryClient = useQueryClient();useQueryClient returns the one client instance the provider wired up (next lesson), the same cache every useQuery in the app reads from.
You call it inside Client Component event handlers and mutation callbacks.
It is client-only by definition: there is no query client in a Server Component, so calling useQueryClient there is a category error.
(That’s a trap the wiring lesson returns to; flag it now.)
With the client in hand, three calls cover the whole imperative surface:
invalidateQueries({ queryKey })marks matching queries stale and refetches the active ones. The default after a mutation. You changed data on the server, so you tell the cache its copy is out of date.setQueryData(key, updater)writes a value straight into the cache, no fetch. The optimistic shortcut. This is the call doing the work inside the cache-update shape.removeQueries({ queryKey })evicts matching entries entirely, gone from the cache. Rare: the tenancy-switch and sign-out sledgehammer.
That last one needs a real use case to stick, and it has a sharp one.
When a user switches their active organization, the entire client cache is now full of the wrong tenant’s data, and serving one org’s comments to another is a data-isolation bug at the cache layer.
The fix is to clear the cache on the switch, and removeQueries (or its blunter sibling queryClient.clear()) is how.
The same logic applies on sign-out.
The full treatment is the wiring lesson’s; here it’s enough that removeQueries exists for the moments the cache must be thrown away wholesale.
Map the intent to the call:
For each situation, pick the cache call you'd reach for. Drag each item into the bucket it belongs to, then press Check.
Polling: refetchInterval and the background pause
Section titled “Polling: refetchInterval and the background pause”Polling is the first of the four triggers from the previous lesson, and at the call site it is one line.
useQuery({ queryKey, queryFn, refetchInterval: 10_000 });refetchInterval: 10_000 refetches every ten seconds, on a loop, for as long as the query is mounted.
That’s the whole basic form.
Sometimes you want polling that stops itself, such as a job-status panel that polls while a job runs and goes quiet once it’s done.
For that, refetchInterval takes a function instead of a number:
useQuery({ queryKey, queryFn, refetchInterval: (query) => (query.state.data?.status === 'done' ? false : 5_000),});Return a number to keep polling at that cadence; return false to stop.
One v5 detail worth getting exactly right: the callback receives the query object, not the bare data, so you read the latest value at query.state.data, as above.
Reaching for query.data directly is the natural mistake; it’s query.state.data.
The senior pairing is one more line, and it’s a default you’re confirming rather than setting:
useQuery({ queryKey, queryFn, refetchInterval: 10_000, refetchIntervalInBackground: false });refetchIntervalInBackground: false (already the default) means polling pauses when the tab is hidden.
The user alt-tabs away and the ten-second loop quietly stops; they come back and it resumes.
That spares their battery and your database’s connection pool from a thread that polls forever in a background tab nobody’s looking at.
Ten seconds is the right cadence for a comment thread, fast enough that a coworker’s message feels live, slow enough that it isn’t hammering anything, and it clears the previous lesson’s bar of “the cadence drops below user-initiated.”
One connection back to the previous section: when you poll a useInfiniteQuery, the maxPages cap is not optional.
A polled refetch on an uncapped infinite query keeps growing the cache on every tick, so polling and maxPages travel together.
What gets a query, what doesn’t
Section titled “What gets a query, what doesn’t”Before the closing example, one discipline section, because the most common way to misuse this library is to forget what a query is for.
useQuery is not a generic state manager.
It is a read of server state with an identity.
The moment you reach for it to hold something that isn’t server data, you’ve picked the wrong tool, and the symptom is a query with a made-up key wrapping state that has no server behind it.
Sort your state into three buckets and the rule falls out:
- A query is a read of server state, addressed by a key: the comment list, the invoice, the current plan.
- A mutation is a write, or a one-shot fetch fired by a click: posting a comment, exporting to CSV.
- A derived value is a
useMemoover other queries’data: the count of unresolved comments computed from the loaded list, not its own query with a synthetic key.
And the broader reflex, the same one the previous lesson drilled: TanStack Query holds server state only.
URL state is nuqs. Form-input state is useState. Theme is the theme library. Generic global client state is Zustand, which a later chapter covers.
Reaching for useQuery to hold any of those is the misuse, and it’s the single highest-value discipline check in this whole lesson.
Run your state through it:
A query is a keyed read of server state. A mutation is a write or one-shot fetch. Everything else is useMemo or plain useState — not a query. Sort each item. Drag each item into the bucket it belongs to, then press Check.
The CSV-export chip is the instructive one: it feels like data work, but it’s a one-shot fetch on a click, a mutation rather than a query, because it has no identity to cache and re-read.
The comment thread query, end to end
Section titled “The comment thread query, end to end”Time to pull all four primitives into one component.
This is the project’s center of gravity in miniature: a Client Component that reads the thread, polls it, and pages through it, all from a single useInfiniteQuery config:
'use client';
export const CommentThread = ({ invoiceId }: { invoiceId: string }) => { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = useInfiniteQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: ({ pageParam }) => fetchComments(invoiceId, pageParam), initialPageParam: null, getNextPageParam: (last) => last.nextCursor ?? undefined, getPreviousPageParam: (first) => first.prevCursor ?? undefined, refetchInterval: 10_000, maxPages: 10, });
if (isPending) return <CommentSkeleton />;
const comments = data.pages.flatMap((page) => page.comments);
return ( <section> {comments.map((comment) => ( <CommentRow key={comment.id} comment={comment} /> ))} <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage} > {isFetchingNextPage ? 'Loading…' : 'Load older comments'} </button> </section> );};Every primitive in this lesson runs in a Client Component. The 'use client' directive marks the boundary; the next lesson decides where in the tree it goes.
'use client';
export const CommentThread = ({ invoiceId }: { invoiceId: string }) => { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = useInfiniteQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: ({ pageParam }) => fetchComments(invoiceId, pageParam), initialPageParam: null, getNextPageParam: (last) => last.nextCursor ?? undefined, getPreviousPageParam: (first) => first.prevCursor ?? undefined, refetchInterval: 10_000, maxPages: 10, });
if (isPending) return <CommentSkeleton />;
const comments = data.pages.flatMap((page) => page.comments);
return ( <section> {comments.map((comment) => ( <CommentRow key={comment.id} comment={comment} /> ))} <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage} > {isFetchingNextPage ? 'Loading…' : 'Load older comments'} </button> </section> );};The cache contract. This component and the server’s prefetch reference the exact same key helper, so they share one cache entry.
'use client';
export const CommentThread = ({ invoiceId }: { invoiceId: string }) => { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = useInfiniteQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: ({ pageParam }) => fetchComments(invoiceId, pageParam), initialPageParam: null, getNextPageParam: (last) => last.nextCursor ?? undefined, getPreviousPageParam: (first) => first.prevCursor ?? undefined, refetchInterval: 10_000, maxPages: 10, });
if (isPending) return <CommentSkeleton />;
const comments = data.pages.flatMap((page) => page.comments);
return ( <section> {comments.map((comment) => ( <CommentRow key={comment.id} comment={comment} /> ))} <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage} > {isFetchingNextPage ? 'Loading…' : 'Load older comments'} </button> </section> );};The cursor queryFn. It loads one page given a cursor; the library calls it again with the next cursor each time you page.
'use client';
export const CommentThread = ({ invoiceId }: { invoiceId: string }) => { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = useInfiniteQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: ({ pageParam }) => fetchComments(invoiceId, pageParam), initialPageParam: null, getNextPageParam: (last) => last.nextCursor ?? undefined, getPreviousPageParam: (first) => first.prevCursor ?? undefined, refetchInterval: 10_000, maxPages: 10, });
if (isPending) return <CommentSkeleton />;
const comments = data.pages.flatMap((page) => page.comments);
return ( <section> {comments.map((comment) => ( <CommentRow key={comment.id} comment={comment} /> ))} <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage} > {isFetchingNextPage ? 'Loading…' : 'Load older comments'} </button> </section> );};Returns the next cursor or undefined to stop. getPreviousPageParam is its backward twin, present because maxPages is set.
'use client';
export const CommentThread = ({ invoiceId }: { invoiceId: string }) => { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = useInfiniteQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: ({ pageParam }) => fetchComments(invoiceId, pageParam), initialPageParam: null, getNextPageParam: (last) => last.nextCursor ?? undefined, getPreviousPageParam: (first) => first.prevCursor ?? undefined, refetchInterval: 10_000, maxPages: 10, });
if (isPending) return <CommentSkeleton />;
const comments = data.pages.flatMap((page) => page.comments);
return ( <section> {comments.map((comment) => ( <CommentRow key={comment.id} comment={comment} /> ))} <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage} > {isFetchingNextPage ? 'Loading…' : 'Load older comments'} </button> </section> );};The two triggers, living in one config: poll every 10 seconds, cap the cache at 10 pages. Polling an infinite query requires that cap.
'use client';
export const CommentThread = ({ invoiceId }: { invoiceId: string }) => { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = useInfiniteQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: ({ pageParam }) => fetchComments(invoiceId, pageParam), initialPageParam: null, getNextPageParam: (last) => last.nextCursor ?? undefined, getPreviousPageParam: (first) => first.prevCursor ?? undefined, refetchInterval: 10_000, maxPages: 10, });
if (isPending) return <CommentSkeleton />;
const comments = data.pages.flatMap((page) => page.comments);
return ( <section> {comments.map((comment) => ( <CommentRow key={comment.id} comment={comment} /> ))} <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage} > {isFetchingNextPage ? 'Loading…' : 'Load older comments'} </button> </section> );};isPending gates the skeleton, the cold-load state before any page exists.
'use client';
export const CommentThread = ({ invoiceId }: { invoiceId: string }) => { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = useInfiniteQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: ({ pageParam }) => fetchComments(invoiceId, pageParam), initialPageParam: null, getNextPageParam: (last) => last.nextCursor ?? undefined, getPreviousPageParam: (first) => first.prevCursor ?? undefined, refetchInterval: 10_000, maxPages: 10, });
if (isPending) return <CommentSkeleton />;
const comments = data.pages.flatMap((page) => page.comments);
return ( <section> {comments.map((comment) => ( <CommentRow key={comment.id} comment={comment} /> ))} <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage} > {isFetchingNextPage ? 'Loading…' : 'Load older comments'} </button> </section> );};Flatten the array-of-pages into the flat comment list the UI renders.
'use client';
export const CommentThread = ({ invoiceId }: { invoiceId: string }) => { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = useInfiniteQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: ({ pageParam }) => fetchComments(invoiceId, pageParam), initialPageParam: null, getNextPageParam: (last) => last.nextCursor ?? undefined, getPreviousPageParam: (first) => first.prevCursor ?? undefined, refetchInterval: 10_000, maxPages: 10, });
if (isPending) return <CommentSkeleton />;
const comments = data.pages.flatMap((page) => page.comments);
return ( <section> {comments.map((comment) => ( <CommentRow key={comment.id} comment={comment} /> ))} <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage} > {isFetchingNextPage ? 'Loading…' : 'Load older comments'} </button> </section> );};The load-more button: fetchNextPage on click, disabled when there’s no next page or one is already loading, label flipping on isFetchingNextPage.
Now read what is deliberately missing from that component, because it is missing on purpose and the next lessons fill it in.
There is no provider.
There is no SSR prefetch and no <HydrationBoundary>.
There is no useMutation, so no optimistic add and no invalidation.
This is the read side of the screen, at the call site, and nothing more.
That staging is the point. You learned the call site first, in isolation, with no wiring to distract from it. The next lesson makes the cache real; the one after builds the whole screen around this component.
If you want to feel the isPending/isFetching split and staleTime in your hands before moving on, the sandbox below runs a tiny query against a mocked fetcher.
It’s the one editable surface in this lesson, so poke the values and watch the flags flip.
External resources
Section titled “External resources”Four bookmarks that go deeper than this lesson does on the corners that catch everyone: the defaults, the optimistic shapes, the key contract, and the infinite-query config.
Why staleTime: 0 refetches so aggressively, and the isPending / isFetching split in full.
Both shapes side by side — the via-variables pattern and the cancel-snapshot-restore cache update.
The full useInfiniteQuery config — getNextPageParam, the data.pages shape, and the maxPages limited-query rule.
TkDodo, a TanStack Query maintainer, on key hierarchy and the typed key-factory pattern this lesson centralizes.