Skip to content
Chapter 76Lesson 4

A worked TanStack Query design review

Run the four-trigger funnel against one real screen, then split TanStack Query's cached reads from the Server Action that owns the writes.

TanStack Query has been abstract so far: a threshold for when to reach for it, four primitives at the call site, the App Router wiring that keeps its cache from leaking across requests. This lesson aims all of it at one real screen, the per-invoice comment thread, and reviews the design before any code is written.

You learned that TanStack Query earns its place only when the platform defaults stop pulling their weight. This screen meets that condition; the next chapter is where you build it.

The screen: a comment thread on every invoice

Section titled “The screen: a comment thread on every invoice”

Picture the invoice detail page at /invoices/[id]. On the left is the static summary: who it’s billed to, the line items, the total, the status. On the right runs a comment thread, where coworkers hash out a dispute, leave an internal note, or paste in what a customer said on the phone.

This is the standard “activity on a record” pattern, the same shape as comments under a support ticket or the timeline on a CRM deal. Build it here and you can build it on any record in any product.

Invoice summary
Invoice #INV-1042 Due Jun 30, 2026
Billed to Acme Corp billing@acme.example · Net 30
Line items
Design review · 2 rounds $4,800
Implementation · 40h $6,000
Project management $1,200
Total $12,000
Status: Sent · v3
Comment thread
Dana · 2h

Customer disputes the design-review line — says it was quoted at half this.

Priya · 1h

Pulled the original SOW. The quote was for one round; this invoice covers two.

Dana · 40m

Got it. I’ll reply and attach the SOW so they can see the second round.

You · just now

Sent. Marking this resolved once they confirm.

Post a comment…
The invoice detail page: a static summary on the left, a live comment thread on the right. Every decision in this lesson is about which half gets which tool.

A user does three things on this thread, and each becomes a decision later: read the latest comments, since someone may have just posted, scroll back through the history, and post a new comment.

Running the four-trigger funnel on the comment thread

Section titled “Running the four-trigger funnel on the comment thread”

An inexperienced engineer learns a powerful library, sees a screen that looks “live and interactive,” and reaches for it on instinct. The experienced move is to run the funnel. A trigger only counts if you can name the cheaper default it beats: “this is real-time-ish” is not a trigger, “router.refresh() re-renders the whole route segment every ten seconds and that’s too heavy” is.

The four triggers from the start of this chapter: polling (server changes the client must learn on a tight cadence, no user action), optimistic mutations that land in a cached query, infinite scroll that reuses loaded pages, and cross-view caching across mounts. Hold each against the thread and name the default it beats.

Polling. A coworker posts from their own session, and the user expects the comment to appear without refreshing, every ten seconds or so while the tab is focused. The cheap default, a setInterval calling router.refresh(), re-renders the entire route segment every tick: summary, line items, banner, all to surface maybe one comment. Too heavy, too jarring. Met.

Optimistic into a cached query. When the user posts, the comment should appear instantly and a 500 should roll it back with an error. useOptimistic does that for a local list, but the comment has to land at the top of the first page of the cached infinite query, the same cache the thread reads and the poll refreshes. useOptimistic has no idea that cache exists, let alone its pages shape. Met.

Infinite scroll with reuse. A busy invoice can have hundreds of comments; the user scrolls back to an old exchange, then forward to the bottom. The Server-Component cursor pagination from the list-view chapter produces the right URLs but refetches every page on each navigation. useInfiniteQuery with maxPages keeps loaded pages in the client cache all session, so scrolling back is instant. Met.

Cross-view caching. A “recent activity” sidebar peeks at the same thread as it mounts and unmounts, and a populated cache renders it instantly on re-mount. Met, but weakest of the four: on its own it would never justify a library, only ride on the three strong triggers.

Three strong triggers, one weak. One screen hitting three at once is rare; most surfaces hit zero, which is why most stay Server Components and Server Actions. This thread is the unusual case the library was built for, and why this chapter chose it.

Now make the call yourself. The funnel below commits you to the answers in order, one question at a time, as you’d reason through a real screen. The value isn’t the verdict; it’s the sequence of cheaper defaults you rule out reaching it.

Does the comment thread earn TanStack Query?

The read/write seam: query for reads, Server Action for writes

Section titled “The read/write seam: query for reads, Server Action for writes”

You’ve proven the thread needs TanStack Query. Now the central call: the library owns the read, not the write, even though it ships a perfectly good useMutation.

The read is the thread itself: a useInfiniteQuery against a route handler, GET /api/invoices/[id]/comments?cursor=…, polled, capped, and SSR-prefetched. This is the live, cached surface the four triggers described.

The write is posting a comment, and it stays a Server Action, addCommentAction. Once the action resolves, the client calls queryClient.invalidateQueries({ queryKey: commentKeys.lists(invoiceId) }) to mark the thread stale so it refetches.

Why a Server Action when useMutation is right there? Because the action already owns a stack of things a route-handler useMutation would force you to rebuild by hand: the progressive-enhancement form contract , server-side Zod validation, the audit-log write (posting a comment is an auditable event, recorded inside the action where session and tenant context already live), and the canonical Result your forms already read.

So the two systems cooperate instead of competing for the mutation. The write seam mutates and triggers invalidation; the read seam owns the cache that gets invalidated. They meet at the invalidate call.

This is “trigger before tool” applied to the write: useMutation’s surface doesn’t beat the Server Action here, so the action stays. The two panels below are complementary halves of the seam, not options to choose between.

  • Hook: useInfiniteQuery
  • Endpoint: GET /api/invoices/[id]/comments?cursor=…
  • Owns: the live, scrollable, polled thread, the cached reads
  • Cache: the TanStack client cache, keyed by commentKeys.lists(invoiceId)
  • Refresh: refetchInterval: 10_000; maxPages: 10; SSR-prefetched first page
TanStack Query owns the read cache.

A quick check on whether the seam decision landed.

A teammate proposes: “Let’s drop the Server Action and just POST the comment from a useMutation, then invalidateQueries — one system, fewer moving parts.” What’s the strongest reason this is the worse design on this screen?

It throws away the form contract, the server-side validation, and the audit-log write that the action already owns — work you’d have to rebuild by hand to dodge an invalidateQueries call you’d make anyway.
useMutation can’t call invalidateQueries, so the thread would never refetch after the post.
A route-handler POST can’t reach the database, so the comment would never persist.
The optimistic page-zero write only works from inside a Server Action’s onMutate, so useMutation couldn’t show the comment instantly.

The read goes through GET /api/invoices/[id]/comments?cursor=…, wrapped in the organizations chapter’s authedRoute(role, schema, fn) helper for the auth and tenancy check, and returns a Zod-validated { comments, nextCursor }. That response schema lives in /lib, imported by both the handler (to validate what it sends) and the queryFn (to parse what it receives), so code enforces the contract rather than a convention two files agree to follow.

Why a route handler, and not a /lib function the client calls directly or a Server Action like the write side? Three reasons.

First, the client cannot import /lib directly. Those functions reach for Drizzle, which pulls your database driver and DATABASE_URL into the bundle, so importing one into a Client Component ships your connection string to the browser. The route handler is the network boundary that keeps the database server-side, and client reads must cross it.

Second, the route handler is also your public contract. Because the read is plain HTTP, a future mobile app, Zapier integration, or one-off script reaches the thread through the same surface the browser uses. TanStack Query layered a client cache on top of that API without replacing or forking it: the architecture stayed open.

Third, the dual fetcher from the wiring lesson. On the server, inside prefetchInfiniteQuery, calling fetch() to your own host is a wasteful loopback to read data the server can already touch directly. So the read function branches on typeof window: in the browser it fetches the handler; on the server it runs the Drizzle query (listInvoiceComments) in-process. Both branches parse the same schema, so the shape is identical, and the handler stays the seam for external callers.

Here is the handler’s signature and the shape it returns.

src/app/api/invoices/[id]/comments/route.ts
export const GET = authedRoute(
'member',
commentsQuerySchema,
async ({ params, query }, { orgId }) => {
const page = await listInvoiceComments(orgId, params.id, query.cursor);
return Response.json({ comments: page.rows, nextCursor: page.nextCursor });
},
);

Adding the optimistic comment to the first cache page

Section titled “Adding the optimistic comment to the first cache page”

This is the lesson’s one new mechanism. The primitives lesson named two optimistic shapes: via-variables, which renders the in-flight value inline with no cache write, and cache-update, which writes the value into the cache yourself. Here the new comment must appear at the top of the first page of a useInfiniteQuery, which lives in the cache at data.pages[0]. That is a structured cache write, not an inline render, so it’s the cache-update shape. The rule that picks it: the shape of what you’re updating dictates the technique. Inline render means via-variables; a cache write means cache-update.

The cache-update shape runs in a fixed order inside onMutate:

  1. Cancel in-flight queries with cancelQueries, so a mid-flight poll can’t land and clobber your optimistic row.

  2. Snapshot the current cache with getQueryData, so you have something to roll back to.

  3. Write the optimistic comment into page zero with setQueryData.

  4. Return the snapshot as context, so the error handler can reach it.

  5. Restore from that snapshot in onError if the post fails.

  6. Invalidate in onSettled so the cache reconciles with the server, win or lose.

The step that’s easy to skip is cancel first. This thread polls every ten seconds, so without cancelQueries an in-flight poll lands carrying the old server state, overwrites your cache, and your comment vanishes until the next refetch. A non-polling screen might survive without it; this one doesn’t.

A second hazard: a coworker’s comment could arrive via the poll while your action is still resolving, and you’d risk showing your own comment twice once its persisted row returns. The fix is the reconcile-by-key trick from the Server Actions chapter. The optimistic comment carries the same client-generated UUID you send to the action, so the persisted row replaces it by key instead of stacking a duplicate beside it.

Walk the skeleton below in order.

const addComment = useMutation({
mutationFn: (input: NewComment) => postComment(invoiceId, input),
onMutate: async (input) => {
await queryClient.cancelQueries({ queryKey: commentKeys.lists(invoiceId) });
const previous = queryClient.getQueryData(commentKeys.lists(invoiceId));
queryClient.setQueryData<InfiniteData<CommentPage>>(
commentKeys.lists(invoiceId),
(old) => prependToFirstPage(old, optimisticComment(input)),
);
return { previous };
},
onError: (_err, _input, context) => {
queryClient.setQueryData(commentKeys.lists(invoiceId), context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: commentKeys.lists(invoiceId) });
},
});

Cancel first. Without this line, an in-flight poll lands a beat later and overwrites your optimistic row. The step most easily missed.

const addComment = useMutation({
mutationFn: (input: NewComment) => postComment(invoiceId, input),
onMutate: async (input) => {
await queryClient.cancelQueries({ queryKey: commentKeys.lists(invoiceId) });
const previous = queryClient.getQueryData(commentKeys.lists(invoiceId));
queryClient.setQueryData<InfiniteData<CommentPage>>(
commentKeys.lists(invoiceId),
(old) => prependToFirstPage(old, optimisticComment(input)),
);
return { previous };
},
onError: (_err, _input, context) => {
queryClient.setQueryData(commentKeys.lists(invoiceId), context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: commentKeys.lists(invoiceId) });
},
});

Snapshot the cache and stash it. This previous value is the only thing you can roll back to if the post fails.

const addComment = useMutation({
mutationFn: (input: NewComment) => postComment(invoiceId, input),
onMutate: async (input) => {
await queryClient.cancelQueries({ queryKey: commentKeys.lists(invoiceId) });
const previous = queryClient.getQueryData(commentKeys.lists(invoiceId));
queryClient.setQueryData<InfiniteData<CommentPage>>(
commentKeys.lists(invoiceId),
(old) => prependToFirstPage(old, optimisticComment(input)),
);
return { previous };
},
onError: (_err, _input, context) => {
queryClient.setQueryData(commentKeys.lists(invoiceId), context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: commentKeys.lists(invoiceId) });
},
});

The page-zero write. Typed as InfiniteData<CommentPage>, the updater clones old.pages and prepends the optimistic comment to pages[0].comments, so it shows at the top of the thread instantly. The comment carries the same client UUID sent to the action, so it reconciles by key later.

const addComment = useMutation({
mutationFn: (input: NewComment) => postComment(invoiceId, input),
onMutate: async (input) => {
await queryClient.cancelQueries({ queryKey: commentKeys.lists(invoiceId) });
const previous = queryClient.getQueryData(commentKeys.lists(invoiceId));
queryClient.setQueryData<InfiniteData<CommentPage>>(
commentKeys.lists(invoiceId),
(old) => prependToFirstPage(old, optimisticComment(input)),
);
return { previous };
},
onError: (_err, _input, context) => {
queryClient.setQueryData(commentKeys.lists(invoiceId), context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: commentKeys.lists(invoiceId) });
},
});

On error, restore the snapshot from context. The optimistic row disappears and the user sees the error.

const addComment = useMutation({
mutationFn: (input: NewComment) => postComment(invoiceId, input),
onMutate: async (input) => {
await queryClient.cancelQueries({ queryKey: commentKeys.lists(invoiceId) });
const previous = queryClient.getQueryData(commentKeys.lists(invoiceId));
queryClient.setQueryData<InfiniteData<CommentPage>>(
commentKeys.lists(invoiceId),
(old) => prependToFirstPage(old, optimisticComment(input)),
);
return { previous };
},
onError: (_err, _input, context) => {
queryClient.setQueryData(commentKeys.lists(invoiceId), context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: commentKeys.lists(invoiceId) });
},
});

On settled, invalidate, win or lose, so the cache refetches. The persisted row replaces the optimistic one by UUID, so there’s no duplicate.

1 / 1

Paying for two caches: invalidation after a post

Section titled “Paying for two caches: invalidation after a post”

TanStack Query leaves this invoice page holding comment data in two caches. The Server Component cache holds the summary side, the layout’s cards and any server-rendered count; the TanStack client cache holds the live thread. One post changes data in both, so one post has to refresh both.

When addCommentAction succeeds, two invalidations fire against the two caches:

  • updateTag(invoiceTag(invoiceId)), inside the action, refreshes the Server Component layer with read-your-writes, so the poster sees their own write in the summary and the server-rendered count at once. (Reach for the revalidateTag(tag, 'max') sibling when nothing is waiting on the write, as in a webhook or background job.)
  • queryClient.invalidateQueries({ queryKey: commentKeys.lists(invoiceId) }), on the client once the action’s Result resolves, marks the TanStack thread stale and refetches it.

Forgetting one is the design’s most common bug: updateTag cannot reach a useQuery, and invalidateQueries cannot reach a Server Component. Fire only updateTag and the live thread goes stale; fire only invalidateQueries and the summary count does. That is the “list fresh, detail stale” bug, the cost of the second cache, paid on every post.

Step through one post below and watch each cache go stale as its own API fires.

Server Component cache untouched
Invoice summary 3 comments
updateTag(invoiceTag)
TanStack client cache untouched
data.pages[0]
You optimistic · unconfirmed
Dana 40m
invalidateQueries(commentKeys…)

Submit. The optimistic comment already sits in pages[0] from onMutate. Neither server cache is touched yet; the user just sees their row instantly.

Server Component cache stale · refetch pending
Invoice summary 3 comments
updateTag(invoiceTag)
TanStack client cache untouched
data.pages[0]
You optimistic · in flight
Dana 40m
invalidateQueries(commentKeys…)

Server. addCommentAction writes the row, writes the audit log, and calls updateTag(invoiceTag). The Server Component cache goes stale; the TanStack cache is untouched.

Server Component cache stale · refetch pending
Invoice summary 3 comments
updateTag(invoiceTag)
TanStack client cache stale · refetch pending
data.pages[0]
You optimistic · in flight
Dana 40m
invalidateQueries(commentKeys…)

Resolve. The action returns Result.ok; the client awaits it and fires invalidateQueries(commentKeys.lists(id)). Now the TanStack cache is stale too: a second API hitting a second cache.

Server Component cache fresh
Invoice summary 4 comments
updateTag(invoiceTag)
TanStack client cache fresh
data.pages[0]
You confirmed · same UUID
Dana 40m
invalidateQueries(commentKeys…)

Reconcile. Both layers refetch. The persisted row replaces the optimistic one by UUID, and the summary card’s count ticks up. Two caches, two APIs, one consistent screen.

Sort each refresh job into the cache that owns it, and watch for the trap.

Sort each refresh job into the cache that owns it — and the API that invalidates it. Drag each item into the bucket it belongs to, then press Check.

Server Component cache Invalidated with updateTag
TanStack client cache Invalidated with invalidateQueries
The invoice summary card’s comment count, rendered in the Server Component layout
A server-rendered “X comments” badge in the page header
The live thread the user is currently scrolled through
The optimistic row the poster just added
A comment count rendered inside the 'use client' thread component itself

Only the comment thread crosses the client boundary

Section titled “Only the comment thread crosses the client boundary”

Inventory the invoice page. The header, customer card, line-items table, and status-and-version banner are all Server Components with zero useQuery. Only the comment thread (comment-thread.tsx, marked 'use client') touches TanStack Query. The page itself (app/(app)/invoices/[id]/page.tsx) stays a Server Component: it prefetches the thread’s first page and wraps that one leaf in a <HydrationBoundary>.

A client-state library does not have to turn the whole page into a Client Component. Put 'use client' at the top and add useQuery for the comments, and the summary, line items, and banner all ship as client JavaScript and lose their server-fast first paint, all to feed one thread. Draw the boundary at the leaf instead: the static regions stay server-fast, and you pay TanStack Query’s cost only where it earns its place. Server Components own the first paint; TanStack owns the live cache.

page.tsx · Server Component + <HydrationBoundary> prefetchInfiniteQuery
Invoice header Server Component
Customer card Server Component
Line items Server Component
Status / version banner Server Component
Comment thread 'use client' · TanStack Query

Only the thread crosses the client boundary; every other region ships zero client JavaScript for its data.

Because the page prefetches with prefetchInfiniteQuery and hydrates, the thread’s first page paints server-rendered with no skeleton, identical to the static regions around it. Only the scroll-back and the polling hit the network afterward.

This lesson was the design review: you ran the funnel, drew the read/write seam, and counted the two-cache cost, but wrote no files. The next chapter implements this exact surface end to end:

  1. The seeded comments and the route handler behind GET /api/invoices/[id]/comments.

  2. The <Providers> shell and per-request getQueryClient() from the wiring lesson, and the page’s <HydrationBoundary> prefetching the first page.

  3. The useInfiniteQuery with ten-second polling and maxPages: 10.

  4. The addCommentAction plus useActionState form, and the full useMutation optimistic add with its cache update.

  5. The verify recipe: a comment arriving from another session, the optimistic comment showing instantly, and a forced 500 rolling it back.