Skip to content
Chapter 76Lesson 2

The four core TanStack Query hooks

TanStack Query v5 at the component call site: useQuery, useMutation, useInfiniteQuery, and the optimistic update.

You are building the per-invoice comment thread: live, optimistic, and infinitely scrollable. The previous lesson argued a screen like this earns the library; this one shows what you type.

The thread is built from four primitives: useQuery to read, useMutation to write, the v5 decision about how to do an optimistic update, and useInfiniteQuery for the paginated list. Around them sit query keys, the imperative cache calls, and polling, which you’ll meet as each primitive needs them.

This lesson teaches the call site: what you write inside a component once a cache already exists. Creating that cache and wiring it into the App Router is the next lesson’s job, so when an example calls useQuery with no provider in sight, the provider is one lesson away, not missing.

The read hook is the simplest of the four, and every other primitive borrows its vocabulary.

A query is a read of server state addressed by a key. You hand useQuery a key that identifies the data and a function that fetches it; it hands back the data plus 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 returns. data is the resolved value, or undefined before the first success; error is whatever the fetch threw; isPending and isFetching are unpacked 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.

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 throws, and a throw becomes the error above. It says nothing about where the data comes from.

const { data, error, isPending, isFetching } = useQuery({
queryKey: commentKeys.lists(invoiceId),
queryFn: () => fetchComments(invoiceId),
staleTime: 60_000,
});

How long cached data is treated as fresh. While it’s fresh, mounting the component or refocusing the tab will not refetch.

1 / 1

That destructure is the whole API for a read. Two flags, isPending and isFetching, are easy to confuse, and telling them apart is the difference between a clean loading state and a screen that flickers on every refocus.

They sound like synonyms but answer different questions.

isPending asks: have I ever resolved? It is true only until the first successful fetch, when there is no cached data to show. Once a query resolves even once, isPending stays false, because the cache now holds something to render.

isFetching asks: is a request in flight right now? It is true during the first fetch and during every background refetch after it: a poll, a window refocus, a manual invalidation. A query can hold cached data and still be isFetching while it checks for a fresher copy.

Moment
isPending
isFetching
What the user sees
First load cold — nothing cached yet
true
true
Skeleton
Idle, data fresh served from cache
false
false
The content, sitting still
Background refetch poll / refocus / invalidation
false
true
The content, quietly freshening

Two flags, two UI states. The skeleton is gated on isPending, a quiet in-place spinner on isFetching. They are never the same UI.

Render the skeleton while isPending, because there is nothing to show yet. Render a subtle spinner or a slight dimming while isFetching: content is already on screen and you are just freshening it, so snapping back to a skeleton would make a quiet refetch feel like a full reload.

v5 also exposes isLoading, defined as exactly isPending && isFetching: first load, no cache, request in flight. This course builds on the two underlying flags instead, since they map one-to-one onto the two UI states above.

The default staleTime is 0: data is stale the instant it arrives, so every mount and every window focus fires a refetch. For a stock ticker or live scoreboard that is exactly right. For almost every web app 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 current seconds ago.

useQuery({ queryKey, queryFn, staleTime: 0 });

Refetches on every mount and every focus. Data is stale the moment it lands. Correct for always-live data like a price ticker, but a refetch storm for a comment thread that changes a few times an hour.

You’ll set 60_000 once on the QueryClient in the next lesson, so it becomes the default for every query. At a call site you override it only when that one query needs different freshness, and you leave a comment saying why.

One last thing about data: it is not a copy, it is a direct reference into the cache. Mutate it in place with 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, and change cached data through the cache’s own write calls, covered shortly.

The two load-bearing config keys, with definitions inline:

const { data, error, isPending, isFetching } = useQuery({
queryKey: commentKeys.lists(invoiceId),
queryFn: () => fetchComments(invoiceId),
staleTime: 60_000,
});

The whole 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 hierarchical, broad on the left and specific on the right:

['comments'];
['comments', 'list', invoiceId];
['comments', 'detail', commentId];

The hierarchy is what makes invalidation ergonomic. Invalidation matches by prefix: invalidate ['comments'] and every key that starts with it refetches, the whole family at once. Invalidate the longer ['comments', 'list', invoiceId] and you narrow to one invoice’s list, leaving every other comment query untouched. How much of the key you specify is how wide you reach.

One watch-out: keys are serialized internally for lookup, so only serializable values belong in a key: strings, numbers, booleans, and plain objects. A Date, a function, a class instance, or a React component does not serialize stably, and putting one in a key quietly breaks the lookups.

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], and the invalidation silently misses: a typo’d string doesn’t throw, it just fails to match.

The fix is a single typed helper per feature, so the raw arrays are written 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). The read and write sides reference the same function, so they cannot drift, and as const keeps each key a literal tuple rather than widening to string[].

This is the same discipline as the cache-tag helper from the caching chapter, one source of truth for the strings that address a cache, applied here to the client cache instead of the Server Component cache.

Prefix matching is the mechanic to lock in, so try it. For each key below, decide whether invalidating ['comments', 'list', 'inv-1'] would refetch it: 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.

Refetched Key starts with ['comments', 'list', 'inv-1']
Untouched Different prefix — no match
['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 matching only flows from a shorter prefix down to the longer keys beneath it, never from a child back up to its parent.

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 a throw lands in useQuery’s error, so failures become values you render.

What should that function call?

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());
};

It comes down to a 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 wants none of that. It wants stable HTTP status codes, cacheability, and a shape any HTTP client can consume. That is 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 it doubles as a public contract: a future mobile app or third-party integration hits the same URL.

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.

Mutations get settled in the design-review lesson; for reads, it’s always the route handler.

The last line of the fetcher, commentPageSchema.parse(...), is what makes the cached data trustworthy.

The route handler validates its response against this Zod schema, the contract from the route handlers chapter, and the queryFn parses what it receives against the 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 drifts from what the client expects, a renamed field or a missing key, the parse throws, and that surfaces as a useQuery error you can handle. Without the parse, the same drift is silent: TypeScript trusts the data because you told it fetch returns that type, and the mismatch surfaces later as a confusing crash deep in your render.

One pointer for the next lesson: on the server, this same read runs the database query directly instead of fetching its own URL. The function branches on where it runs, and both branches validate against the same schema.

useMutation: the write primitive and its lifecycle

Section titled “useMutation: the write primitive and its lifecycle”

Writes are the second primitive, and a write is not a single moment: it is a lifecycle you can attach behavior to.

The destructure:

const { mutate, mutateAsync, isPending, error } = useMutation({
mutationFn: (input) => postComment(invoiceId, input),
onMutate,
onError,
onSuccess,
onSettled,
});

The shape mirrors useQuery: a function that does the work (mutationFn) and some flags. What’s new is the four callbacks, onMutate, onError, onSuccess, and onSettled, and they are the reason to reach for useMutation rather than call the fetcher yourself.

A mutation moves through a fixed sequence, each callback firing at one point in it. The piece people miss is that onMutate and onError are connected: whatever onMutate returns is handed to onError. That handoff is the rollback channel, the key to understanding optimism.

mutate(input) onMutate request in flight
onSuccess
onError
onSettled
onMutate context onError the rollback channel — the snapshot onMutate stashes rides forward as context to onError

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.

mutate(input) onMutate request in flight
onSuccess
onError
onSettled
onMutate context onError the rollback channel — the snapshot 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.

mutate(input) onMutate request in flight
onSuccess
onError
onSettled
onMutate context onError the rollback channel — the snapshot 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.

mutate(input) onMutate request in flight
onSuccess
onError
onSettled
onMutate context onError the rollback channel — the snapshot onMutate stashes rides forward as context to onError

On failure, onError(error, input, context) fires with the same context onMutate returned. This is where you roll back, using the snapshot from the first step.

mutate(input) onMutate request in flight
onSuccess
onError
onSettled
onMutate context onError the rollback channel — the snapshot onMutate stashes rides forward as context to onError

Either way, onSettled(data, error, input, context) runs last. Invalidation usually lives here, so the cache reconciles with the server whether the write succeeded or failed.

The shape is a story about a snapshot: onMutate stashes the pre-write cache as context, onError uses it to put the cache back on failure, and onSettled cleans up either way.

There are two ways to fire the mutation.

mutate(input) is fire-and-forget. You call it, it kicks off the lifecycle, and the callbacks handle success, failure, and cleanup. It returns nothing useful, so you don’t await it. This is the default.

mutateAsync(input) returns a Promise you can await. Reach for it only when you need to compose the result into a larger async flow, such as chaining a second mutation after the first resolves, or redirecting once the write confirms. 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.

The two callbacks that carry the lifecycle, with 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”

v5 gives you two different shapes for an optimistic update, and choosing the right one is the skill. See them side by side first, then we’ll walk the harder one in detail. (Both use queryClient, the imperative cache handle; for now, read it as “the cache, called directly.” We name it formally two sections from now.)

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 off the mutation and renders the optimistic row inline. When the mutation settles, isPending flips to false and the row vanishes: success refetches the real row, failure just drops it. Right for one list, one optimistic add, one rollback path.

The two tabs differ in one thing: where the optimistic value lives. Via variables never touches the cache; it paints a temporary row from the in-flight input, inside this one component’s render, and is the framework-agnostic cousin of useOptimistic from the React forms chapter. Cache update earns its extra code the moment the value has to outlive that render: another part of the screen reads the same query, the optimism must survive a navigation, or it has to coexist with other in-flight mutations. That heavier shape runs a cancel-snapshot-write-restore-invalidate sequence, and the order is what keeps it correct.

Skip or reorder a step and the optimistic value flickers, gets clobbered by a stale response, or never rolls back. The order, with the why of each step:

onMutate cancelQueries
onMutate getQueryData
onMutate setQueryData
onMutate return { prev }
onError setQueryData
onSettled invalidateQueries
The call
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.

The cache
['comments', 'list', invoiceId] background refetch

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.

onMutate cancelQueries
onMutate getQueryData
onMutate setQueryData
onMutate return { prev }
onError setQueryData
onSettled invalidateQueries
The call
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.

The cache
['comments', 'list', invoiceId]
prev — snapshot held

Snapshot. Grab the current cached value while it’s still correct. This is what you’ll restore if the write fails.

onMutate cancelQueries
onMutate getQueryData
onMutate setQueryData
onMutate return { prev }
onError setQueryData
onSettled invalidateQueries
The call
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.

The cache
['comments', 'list', invoiceId]
optimistic
prev — snapshot held

Write the optimism. Replace the cached value with the new shape: the comment list with the new row prepended. The UI re-renders from the cache at once, so the user sees their comment immediately.

onMutate cancelQueries
onMutate getQueryData
onMutate setQueryData
onMutate return { prev }
onError setQueryData
onSettled invalidateQueries
The call
return { prev }

Return the snapshot from onMutate so it becomes context — it now rides forward to onError, the rollback channel from the lifecycle diagram.

The cache
['comments', 'list', invoiceId]
optimistic
prev — snapshot held

Hand off the snapshot. Return it from onMutate so it becomes context and rides along to onError.

onMutate cancelQueries
onMutate getQueryData
onMutate setQueryData
onMutate return { prev }
onError setQueryData
onSettled invalidateQueries
The call
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.

The cache
['comments', 'list', invoiceId]
prev — snapshot held

Restore on failure. onError writes the snapshot back, erasing the optimistic row. The cache is exactly as it was before the user clicked.

onMutate cancelQueries
onMutate getQueryData
onMutate setQueryData
onMutate return { prev }
onError setQueryData
onSettled invalidateQueries
The call
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).

The cache
['comments', 'list', invoiceId]
persisted

Reconcile. Win or lose, onSettled invalidates so the cache refetches authoritative server state: on success this swaps your optimistic row for the real persisted one; on failure it confirms the rollback.

The step to burn in is the first. Cancel before you write stands between your optimistic value and a stale response landing on top of it. Picture the thread polling every ten seconds: a poll fires, 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 prevents that race. Treat it as mandatory.

Which shape, and which one the project uses

Section titled “Which shape, and which one the project uses”

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. 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, and only cache update can reach into a paginated cache structure. The full updater function is the next chapter’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 the Server Action will receive. When the real row comes back on invalidation, it replaces the optimistic one by key instead of appearing as a duplicate. 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.

Which of these situations force the cache-update shape — the ones via-variables genuinely can’t cover? Select all that apply.

A second panel on the same screen reads the same commentKeys.lists(invoiceId) query and must show the pending comment too.
The pending comment has to land at the top of an infinite thread whose first page is already in the cache.
One comment box adds a row inline and removes it again if the POST fails — nothing else on the page reads that list.
A “Resolve” switch on a single row flips instantly and snaps back on a failed request.

useInfiniteQuery: the cursor-paginated cache

Section titled “useInfiniteQuery: the cursor-paginated cache”

The fourth primitive extends the read hook for data that arrives in pages the user scrolls through. The comment thread is exactly this: hundreds of comments deep, loaded a page at a time, with already-fetched pages kept in the cache so scrolling back is instant.

The config is where 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, 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 first page. Here null means “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. It must be undefined, not null: null is a valid first param, so it 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. Required because maxPages is set: a capped query needs both directions so a dropped page can be re-fetched on scroll-back. (More 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 cache at 10 pages. Beyond that, the oldest page drops to bound memory.

1 / 1

The returns are shaped for scrolling rather than a single read. fetchNextPage loads the following page; hasNextPage tells you whether one exists, so you can disable the button at the end; isFetchingNextPage is true while a next-page load runs, so you can show a spinner on the button rather than the whole list.

data from an infinite query is not a flat array of comments. It’s an array of pages, each holding one fetch’s results: data.pages is [page1, page2, page3, ...], where 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 flat list your UI renders. The load-more button reads straight from the returns:

<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading…' : 'Load older comments'}
</button>

It’s disabled when there’s nothing left to load or a load is already running, and the label flips while a load runs.

Without a cap, a thread the user scrolls deep into accumulates pages in the cache forever, every page held in memory for the whole session. On a long 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 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 is why getPreviousPageParam is in the config. When maxPages is set, you must define getPreviousPageParam as well as getNextPageParam. If an older page can be dropped, the library needs to know how to re-fetch it when the user scrolls back up, and fetching backward requires the previous-page cursor.

This is a different cache contract than the server-side cursor pagination from the lists chapter. That pagination replaces: each “Next” swaps in the following page and discards the one before, so scrolling back refetches from scratch. Infinite scroll accumulates: every page you load stays cached (up to the cap), so moving back and forth is free. Same underlying cursors, different UX and cache contract.

One config detail is worth fixing in memory: 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 valid first cursor (the initialPageParam 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. They are 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 the provider wired up, the same cache every useQuery reads from. It is client-only: a Server Component has no query client, so calling useQueryClient there is an error.

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 from the cache entirely. Rare: the tenancy-switch and sign-out sledgehammer.

That last one has a sharp use case. When a user switches their active organization, the cache is full of the wrong tenant’s data, and serving one org’s comments to another is a data-isolation bug at the cache layer. removeQueries (or its blunter sibling queryClient.clear()) clears it on the switch, and the same applies on sign-out: these are 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.

invalidateQueries Mark stale and refetch
setQueryData Write straight into the cache
removeQueries / clear Evict entries entirely
After posting a comment, pull the freshly persisted list from the server
A coworker may have posted — refresh the thread to pick up their comment
Show the new comment in the list before the server has confirmed it
The user switched active org — the cache holds the previous org’s data
The user signed out — drop everything the cache was holding

Polling: refetchInterval and the background pause

Section titled “Polling: refetchInterval and the background pause”

Polling is the first of the previous lesson’s four triggers, 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.

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:

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. The v5 detail to get right: the callback receives the query object, not the bare data, so read the latest value at query.state.data, not query.data.

One more line confirms a default rather than setting one:

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 stops; they come back and it resumes. That spares their battery and your database’s connection pool from a loop polling forever in a tab nobody’s watching. Ten seconds suits a comment thread: fast enough that a coworker’s message feels live, slow enough that it isn’t hammering anything.

Polling a useInfiniteQuery makes the maxPages cap mandatory, because a polled refetch on an uncapped infinite query grows the cache on every tick.

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. Reach for it to hold something with no server behind it and you get the telltale symptom: a query with a made-up key.

Sort your state into three buckets and the rule falls out:

  • A query is a keyed read of server state: 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 useMemo over other queries’ data: the count of unresolved comments computed from the loaded list, not its own query with a synthetic key.

The broader reflex from the previous lesson: 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, covered in a later chapter.

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.

Gets a useQuery A keyed read of server state
A mutation A write or a one-shot fetch on a click
useMemo or useState Derived, or plain client state — no query
The list of comments on an invoice
Posting a new comment
Exporting the whole thread to CSV when the user clicks Export
The count of unresolved comments, derived from the already-loaded list
The draft text in the comment box before the user submits

The CSV export chip is the instructive one: it feels like data work, but it has no identity to cache and re-read, so it’s a mutation, not a query.

The read side pulls into one Client Component: it reads the thread, polls it, and pages through it 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 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 queryFn 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>
);
};

Two triggers 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.

1 / 1

What’s missing is missing on purpose. There is no provider, no SSR prefetch, no <HydrationBoundary>, and no useMutation, so no optimistic add and no invalidation. This is the read side at the call site, nothing more.

The sandbox below runs a tiny query against a mocked fetcher so you can feel the isPending/isFetching split and staleTime first-hand. Poke the values and watch the flags flip.

Four bookmarks that go deeper on the corners that catch everyone: the defaults, the optimistic shapes, the key contract, and the infinite-query config.