The per-invoice comment thread clears the bar
A worked design review that judges one real invoicing screen against the threshold for reaching for TanStack Query, then draws the seam between its cached reads and the Server Action that owns the writes.
You now have the reflex: the threshold that decides when to reach for the library, the four primitives you write at the call site, and the App Router wiring that keeps the cache from leaking across requests. All of that has been abstract so far, a library waiting for a job. This lesson gives it one. It takes a real screen in the invoicing app, the per-invoice comment thread, and runs everything you’ve learned against it, the way an experienced engineer would in a design review before a single file gets written.
This is the chapter’s payoff and its handoff. Earlier you learned that TanStack Query is conditional: it earns its place only when the platform defaults stop pulling their weight. This lesson is that condition actually being met, on one concrete surface, and 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 an invoice detail page in the app, at /invoices/[id].
The summary is on the left: who it’s billed to, the line items, the total, the status.
On the right runs a comment thread, the kind of thing coworkers use to hash out a dispute, leave an internal note, or paste in what a customer said on the phone.
This is the SaaS-standard “activity on a record” pattern. You’ve seen its cousins everywhere: the comment stream under a support ticket, the timeline on a CRM deal, the conversation on a pull request. Once you can build it here, you can build it on any record in any product.
Before we make a single decision, get the picture in your head. The diagram below sketches the screen so every choice in this lesson has something concrete to attach to.
Customer disputes the design-review line — says it was quoted at half this.
Pulled the original SOW. The quote was for one round; this invoice covers two.
Got it. I’ll reply and attach the SOW so they can see the second round.
Sent. Marking this resolved once they confirm.
A user does exactly three things on this thread, and each one turns into a decision later, so hold onto them: they read the latest comments (someone else may have just posted), they scroll back through the history, and they post a new comment of their own.
The rest of the lesson moves in three steps. First we prove this screen clears the bar. Then we draw the seam between what TanStack Query owns and what stays a Server Action. Then we count the cost of that seam honestly.
Running the four-trigger funnel on this screen
Section titled “Running the four-trigger funnel on this screen”Here’s the trap an inexperienced engineer falls into: they’ve just learned a powerful library, the screen looks “live and interactive,” so they reach for it on instinct.
The experienced move is the opposite.
You don’t reach for the library because a screen feels like it needs one; you run the funnel.
A trigger only counts if you can name the cheaper default it beats.
A vague “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.
Recall the four triggers you learned at the start of this chapter, one clause each: polling (the client must learn about server changes on a tight cadence, with no user action), optimistic mutations that land in a cached query, infinite scroll that needs to reuse already-loaded pages, and cross-view caching of the same data across mounts. Hold each one against the thread, and for each, name the default it beats.
Polling. A coworker posts from their own session, and the user sitting on this page expects the comment to show up without hitting refresh.
The right cadence is roughly every ten seconds while the tab is focused, well below “the user clicked something.”
The cheapest default that could cover this is a setInterval calling router.refresh(), but that re-renders the entire route segment every tick: the summary, the line items, the banner, all of it, just to maybe surface one new comment.
That is too heavy and too jarring. Met.
Optimistic into a cached query. When the user posts, the comment should appear instantly, and a 500 from the server should roll it back with an error.
useOptimistic from the Server Actions chapter could handle that part alone.
But the optimistic comment has to land at the top of the first page of the cached infinite query, the same cache the thread reads from and the poll refreshes, not just a throwaway local list.
useOptimistic gives you single-list inline rollback. It has no idea the infinite-query 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 read an old exchange, then scrolls forward again to the bottom.
The Server-Component cursor pagination you built in the list view chapter produces the right URLs, but it refetches every page on each navigation: scroll up, refetch; scroll back down, refetch.
useInfiniteQuery with maxPages keeps the loaded pages sitting in the client cache for the whole session, so scrolling back is instant. Met.
Cross-view caching. The same thread might get peeked at from a “recent activity” sidebar that mounts and unmounts as the user moves around the app, and a populated client cache renders it instantly on re-mount. Met, but this is the weakest of the four. On its own, cross-view caching would never justify pulling in a whole library. It’s a nice-to-have riding on the back of the three strong triggers, exactly as the threshold lesson warned.
So the tally is three strong triggers plus one weak one. The point worth sitting with is that one screen hitting three triggers at once is rare. Most surfaces in a SaaS hit zero, which is why most surfaces stay Server Components and Server Actions. This thread is the unusual case the library was actually built for, which is exactly why this chapter chose it as the worked example. When you see a screen this loaded, you’ve found a genuine home for TanStack Query.
Now perform the decision yourself. You’ve read the answers; the funnel below makes you commit to them in order, one question at a time, the way you’d reason through a real screen. The value isn’t the verdict at the end. It’s the sequence of cheaper defaults you rule out on the way there.
If it isn’t server state, no query cache applies.
Re-check the premise: useState, nuqs, or useActionState is the owner here.
An occasional, user-triggered refresh doesn’t justify a second cache.
Let the Server Component own the read and call router.refresh() when the user asks for fresh data.
A single-list optimistic add with rollback is React’s job, not the library’s.
Reach for useOptimistic and keep the screen on platform defaults.
If scroll-back can refetch, the URL-driven server pagination you already have is enough. No client cache earns its weight yet.
Three strong triggers (polling, optimistic-into-cache, and infinite scroll with reuse), and every cheaper default fell through on the way here. This is exactly the surface the library was built for.
Drawing the seam: what gets a query, what stays a Server Action
Section titled “Drawing the seam: what gets a query, what stays a Server Action”You’ve proven the thread needs TanStack Query.
The next decision is the most important architectural call in this lesson: the library owns the read but not the write, even though TanStack ships a perfectly good useMutation.
Here is the split, stated plainly.
The read side is the thread itself.
A useInfiniteQuery reads from a route handler, GET /api/invoices/[id]/comments?cursor=…, with refetchInterval: 10_000 for the poll, maxPages: 10 to cap memory, and an SSR-prefetched first page so there’s no skeleton on load.
This is the live, cached, polled surface, exactly the workload the four triggers described.
The write side is posting a comment.
That stays a Server Action, addCommentAction, driven by useActionState on the form: the same five-seam shape (parse, authorize, mutate, revalidate, return) you’ve been writing since the Server Actions chapter.
After the action resolves, the client calls queryClient.invalidateQueries({ queryKey: commentKeys.lists(invoiceId) }) to mark the thread stale so it refetches.
So why does the write stay a Server Action when the library has useMutation right there?
Because the Server Action already owns a stack of things that a useMutation against a route handler would force you to rebuild by hand.
It owns the progressive-enhancement form contract , so the form submits even before JavaScript loads.
It owns the server-side Zod validation on the way in.
It owns the audit-log write: posting a comment is an auditable event in this app, and the audit row is written inside the action where the session and tenant context already live.
And it owns the canonical Result your forms already know how to read.
So the two systems are not competitors fighting over the mutation. They cooperate at exactly one place. The write seam mutates and triggers invalidation, and the read seam owns the cache that gets invalidated. One rule keeps this straight: reads go through TanStack Query, mutations go through the Server Action, and the two meet at the invalidate call.
Now answer the obvious objection, because you should be asking it: why not just use useMutation for the post too, and skip the Server Action entirely?
To do that you’d POST to a route handler, re-implement the form contract by hand, lose progressive enhancement, and wire the audit-log write yourself, all of it to avoid a single invalidateQueries call you’re going to make anyway.
That trade is backwards.
This is the “trigger before tool” discipline you applied to the read side, now applied to the write side: useMutation’s surface doesn’t beat the Server Action here, so the action stays.
The two panels below hold the two halves of the seam side by side. Read them as complementary subsystems, not as two options where you pick one.
- 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
- Mechanism: a Server Action,
addCommentAction, on a native<form>viauseActionState - Owns: the form contract, Zod validation, the audit-log write, the
Result - After it resolves: the client fires
queryClient.invalidateQueries({ queryKey: commentKeys.lists(invoiceId) })
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?
invalidateQueries call you’d make anyway.useMutation can’t call invalidateQueries, so the thread would never refetch after the post.POST can’t reach the database, so the comment would never persist.onMutate, so useMutation couldn’t show the comment instantly.Result. You’d reimplement all of it just to avoid a single invalidateQueries call — an upside-down trade. The other options are simply false: useMutation invalidates fine, route handlers reach the database, and the page-zero onMutate write lives in the client mutation, not the action. The senior split holds: reads through TanStack, the mutation through the Server Action, and the two meet at the invalidate seam.The read seam: one route handler, two callers
Section titled “The read seam: one route handler, two callers”The read goes through GET /api/invoices/[id]/comments?cursor=…, wrapped in the authedRoute(role, schema, fn) helper from the organizations chapter for the auth and tenancy check, returning a Zod-validated { comments, nextCursor } shape.
That response schema lives in /lib and is imported by both the handler (to validate what it sends) and the queryFn (to parse what it receives), so the contract is structural rather than a convention two files politely agree to follow.
But step back: why a route handler at all?
Why not have the client call a /lib function directly, or post through a Server Action like the write side does?
There are three reasons, each one an experienced-engineer call.
The first is a hard boundary: the client cannot import /lib directly.
Those functions reach for Drizzle, and Drizzle drags your database driver, along with your DATABASE_URL, into the bundle.
Import a data-layer function into a Client Component and you ship your database connection string to the browser.
The route handler is the network boundary that keeps the database server-side, and there’s no way around it for client-initiated reads.
The second is that the route handler is also your public contract. Because the read is plain HTTP, a future mobile app, a Zapier integration, or a one-off script reaches the thread through the exact same surface the browser uses. Pulling TanStack Query in added a client cache on top of that API; it didn’t replace it, fork it, or sacrifice it. That’s the win to notice: the architecture stayed open. You bought a cache without closing the door on every other client.
The third connects back to the dual fetcher you saw in the wiring lesson.
On the server, inside prefetchInfiniteQuery, calling fetch() to your own host would be a wasteful loopback: an HTTP round-trip 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 and skips the network entirely.
Both branches parse the same schema, so the data shape is identical either way.
The route handler is still the seam for external callers; the in-app server prefetch just takes the shortcut.
Here’s the handler’s signature and the shape it returns. This is enough to ground “this is the seam,” not a full walkthrough (that’s the next chapter’s job).
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 }); },);The optimistic add: writing into page zero of the cache
Section titled “The optimistic add: writing into page zero of the cache”This is the one genuinely new piece of how in the lesson. Everything else has been recall pointed at a screen; here you learn the specific mechanism this thread needs, because the primitives lesson named it only in the abstract.
Start with why it’s this shape and not the simpler one.
When you toured the primitives, you saw two ways to do an optimistic update: the via-variables shape (render the in-flight value inline, with no cache write) and the cache-update shape (write the optimistic value into the cache yourself).
On this screen, the new comment has to appear at the top of the first page of a useInfiniteQuery, and that page lives inside the cache, in data.pages[0].
Showing it there isn’t an inline render; it’s a structured write into the cache.
So this is the cache-update shape, and the rule that picks it is worth keeping: the shape of what you’re updating dictates the optimistic technique. An inline render calls for via-variables; a cache write calls for cache-update.
The cache-update shape runs in a fixed order inside onMutate. You’ve seen the order before; here it’s concrete:
-
Cancel in-flight queries with
cancelQueries, so a poll that’s mid-flight can’t land and clobber your optimistic row. -
Snapshot the current cache with
getQueryData, so you have something to roll back to. -
Write the optimistic comment into page zero with
setQueryData. -
Return the snapshot as context, so the error handler can reach it.
-
Restore from that snapshot in
onErrorif the post fails. -
Invalidate in
onSettledso the cache reconciles with the server, win or lose.
The non-obvious step, the one an inexperienced engineer skips and then can’t explain the flicker, is cancel first.
This thread polls every ten seconds.
If you write the optimistic comment while a background poll is already in flight, that poll’s response arrives a moment later carrying the old server state, overwrites your cache, and your optimistic comment vanishes until the next refetch puts it back.
cancelQueries aborts that in-flight poll so it can’t win the race.
On a non-polling screen you might get away without it; on this screen it’s essential.
There’s a second hazard worth naming. Between the moment you add the optimistic row and the moment the action resolves, a coworker’s comment could arrive via the poll, and now you risk showing the same comment twice once your own persisted row comes back. 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 when the persisted row arrives via the invalidation refetch, it replaces the optimistic one by key instead of stacking a duplicate beside it.
The skeleton below is the cache-update mutation, stepped so you can focus on one beat at a time. Walk it in order; the page-zero write is the part to slow down on.
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. This thread polls every ten seconds. Without this line, an in-flight poll lands a beat later and overwrites your optimistic row. It’s the step most easily missed, hence the orange.
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 current 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 the new row 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 and reconciles with the server. The persisted row replaces the optimistic one by UUID, so there’s no duplicate.
If you want to watch this whole sequence built live, the walkthrough below implements it on a comments list end to end.
Paying for two caches: invalidation after a post
Section titled “Paying for two caches: invalidation after a post”Now the honest part: the cost of the seam. The moment you bring TanStack Query in, this invoice page holds comment-derived data in two caches at once. The Server Component cache holds the summary side: the layout’s cards and any server-rendered comment count. The TanStack client cache holds the live thread. A single post touches data in both, so a single post has to refresh both, and that duty is the price tag for the four triggers you cashed in earlier.
When addCommentAction succeeds, two invalidations fire, from two different places, against two different caches:
updateTag(invoiceTag(invoiceId)), inside the action, refreshes the Server Component layer with read-your-writes: the parent layout’s summary and any server-rendered count.updateTagis the read-your-writes choice here because the person who just posted must see their own write reflected immediately. (Its siblingrevalidateTag(tag, 'max')is the eventual-consistency variant you’d reach for from a webhook or a background job, where the writer isn’t sitting there waiting.)queryClient.invalidateQueries({ queryKey: commentKeys.lists(invoiceId) }), on the client once the action’sResultresolves, marks the TanStack thread stale and refetches it.
Here’s the part to remember, because forgetting it is the single most common bug in this whole design: updateTag cannot reach a useQuery, and invalidateQueries cannot reach a Server Component.
They are two separate caches with two separate invalidation APIs that know nothing about each other.
Fire only the updateTag and the live thread goes stale; fire only the invalidateQueries and the summary card’s count goes stale.
That’s the canonical “list fresh, detail stale” bug, and it isn’t a flaw in the design.
It’s the cost of the second cache, paid on purpose, every time you post.
The sequence below scrubs through a single post so you can watch both caches light up at the moments they actually fire. Move through it one step at a time, and notice that the two systems act at different instants through different APIs yet land on one consistent screen.
updateTag(invoiceTag) data.pages[0] invalidateQueries(commentKeys…) Submit. The optimistic comment is already sitting in pages[0] from onMutate. Neither server cache has been touched yet; the user just sees their row instantly.
updateTag(invoiceTag) data.pages[0] invalidateQueries(commentKeys…) Server. addCommentAction runs: it writes the row, writes the audit log, and calls updateTag(invoiceTag). The Server Component cache is marked stale; the TanStack cache is untouched.
updateTag(invoiceTag) data.pages[0] invalidateQueries(commentKeys…) Resolve. The action returns Result.ok; the client awaits it and fires invalidateQueries(commentKeys.lists(id)). Now the TanStack cache is marked stale too: a second API hitting a second cache.
updateTag(invoiceTag) data.pages[0] 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.
To make sure the mapping is solid, 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.
'use client' thread component itselfThe boundary map: one leaf is client, the rest stays server
Section titled “The boundary map: one leaf is client, the rest stays server”We’ve proven the case, drawn the seam, and paid the bill. The last move ties the whole chapter together: scope the library to the one leaf that needs it, and leave everything else alone.
Inventory the invoice page.
The header, the customer card, the line-items table, the status and version banner: every one of those is a Server Component with zero useQuery.
The comment thread (comment-thread.tsx, marked 'use client') is the only part of this screen that touches TanStack Query.
The page itself (app/(app)/invoices/[id]/page.tsx) stays a Server Component: it prefetches the thread’s first page and renders a <HydrationBoundary> around that one leaf.
This is where the chapter’s whole thesis lands: bringing in a client-state library does not mean turning the whole page into a Client Component.
An inexperienced engineer adds useQuery for the comments and puts 'use client' at the top of the page “to make it work,” and now the summary, the line items, and the banner all ship as client JavaScript and lose their server-fast first paint, all to feed one thread.
You draw the boundary at the leaf instead.
The static, server-fast parts stay static and server-fast, and you pay TanStack Query’s cost only on the region that earned it.
It’s the same division the wiring lesson set up: Server Components own the first paint, TanStack owns the live cache.
The map below shows it at a glance: one box crosses the client boundary, the rest don’t.
page.tsx · Server Component + <HydrationBoundary> prefetchInfiniteQuery 'use client' · TanStack Query
Only the thread crosses the client boundary; every other region ships zero client JavaScript for its data.
And because the page prefetches via 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.
You got the live cache exactly where you needed it and a fast first paint everywhere, including the leaf.
What the next chapter builds from here
Section titled “What the next chapter builds from here”This lesson was the design review. You ran the funnel, drew the read/write seam, learned the page-zero optimistic write, and counted the two-cache cost: every decision and every shape the screen needs. What you did not do is write the files. That’s deliberate. Nothing here is left broken; it’s simply not the build.
The next chapter is the implementation. It assembles this exact surface end to end:
-
The seeded comments and the route handler behind
GET /api/invoices/[id]/comments. -
The
<Providers>shell and per-requestgetQueryClient()from the wiring lesson, and the<HydrationBoundary>on the page that prefetches the first page. -
The
useInfiniteQuerywith ten-second polling andmaxPages: 10. -
The
addCommentActionplususeActionStateform, and the fulluseMutationcache-update optimistic add. -
The verify recipe: a comment arriving from another session, the optimistic comment showing instantly, and a forced
500rolling it back.
The references below are the canonical sources for that build: the optimistic-update pattern, the App Router prefetching shape, and the concurrency edge cases behind the “cancel first” rule. Skim them before the next chapter; you’ll recognize every moving part.
External resources
Section titled “External resources”The cache-update vs via-variables patterns and the cancel-snapshot-rollback sequence.
Server-side prefetch + dehydrate + HydrationBoundary for the App Router — the SSR-hydrated initial-data shape.
TkDodo, a TanStack maintainer, on the in-flight race behind this lesson's load-bearing cancelQueries call.