Skip to content
Chapter 77Lesson 1

Project: a TanStack Query comment thread

You are going to add a live comment thread to the invoice detail page from the invoices project. The seeded thread paints the instant the page loads, with no spinner. A comment you post appears at the top of the thread before the server confirms it; if the server rejects it, the row vanishes and an error banner explains why. A comment a coworker posts from another session arrives in your open thread within ten seconds, with no refresh. This is the kind of surface that pulls a team toward a client cache.

The decision is one of restraint. Teams often reach for TanStack Query page-wide, turning a working Server Component page into a client-rendered one to get a cache they needed in only one corner. You will do the opposite: TanStack Query stays scoped to the thread leaf, a single <CommentThread /> at the bottom of the page. Everything above it — the invoice header, the customer card, the total card — stays a Server Component. The cost is that you now own two caches and two read paths, and a clean architecture keeps them from leaking into each other.

The data layer is a deterministic in-memory store: no Postgres, no Docker, no .env. It mirrors the SQL shapes the real thing would use (a keyset cursor, org-scoped reads), so this chapter stays on the client-cache architecture rather than infrastructure you have already practiced.

The finished invoice detail page: header and cards on top, the comment thread below with a freshly posted optimistic row at its top.

This project combines the four wiring decisions from the previous chapter into one feature. By the end you will have practiced:

  • Wiring TanStack Query into an App Router surface: a <QueryClientProvider> with production defaults, a per-request QueryClient on the server, and 'use client' pushed down to the leaf, not the whole page.
  • Bridging the server and client caches: prefetching on a Server Component, dehydrating, and hydrating so the first paint already carries data with no loading state.
  • Reading a paginated, polling list with useInfiniteQuery: cursor paging, a bounded count of retained pages, and a poll cadence that pauses while the tab is hidden.
  • Writing through a Server Action with the optimistic snapshot, write, and rollback-on-failure shape, reconciling the two caches that now hold the same data.

Five facts about the project’s shape carry the whole build; this is the map you will keep returning to.

  • The page stays a Server Component; the thread is one client leaf. The header and cards render as Server Components, as the invoices project left them. At the bottom sits a single <CommentThread /> marked 'use client', and nothing else on the page touches TanStack Query.
  • Two read paths, one wire shape, two functions. The client reads through a public route handler, GET /api/invoices/[id]/comments: the HTTP contract the thread polls and scroll-fetches against, and the seam a future mobile or third-party client could hit. The server’s first-paint prefetch reads the store directly through a server-only listCommentsPage. Same key, same response shape, but two functions on purpose: the client fetcher must never import server-only code, or next build fails and the database driver gets pulled into the browser bundle.
  • One write seam: a Server Action. addCommentAction owns the input parse, the comment insert, the audit-log write, and the cache-tag invalidation. The client posts through it with useMutation and invalidates its own cache once the action resolves.
  • The QueryClient is per-request on the server, a singleton on the client. On the server, React’s cache() gives each request its own instance; a shared one would leak one tenant’s prefetched comments into the next tenant’s render. On the client, a single long-lived instance keeps the cache across renders. And commentKeys is the only place query-key arrays are allowed to live.
  • An /inspector page is your verification surface. It carries an identity switcher plus the controls the build lessons drive against: “Force 500 on next POST”, “Insert coworker comment”, “Clear client cache”, “Open thread with polling OFF”, and a comment audit tail.

The starter and the finished solution share one file tree; no files are added or removed across the project. Your work is the edits inside the stubbed files, marked TODO below. Everything else is provided whole: the invoices surface, the in-memory store with its seeded comments, the shared Zod schemas, the server-only listCommentsPage, the force-failure flag, and the inspector.

  • Directorysrc/
    • Directorylib/
      • query-client.ts TODO — makeQueryClient() + getQueryClient() (typeof window branch + cache())
      • Directorycomments/
        • schema.ts provided — shared Zod request/response schemas
        • keys.ts TODO — commentKeys.all / lists(invoiceId) / detail(id)
        • queries.ts provided — server-only listCommentsPage (in-process store read)
        • fetcher.ts TODO — fetchCommentsPage, client-only HTTP fetcher
        • actions.ts TODO — addCommentAction (the write seam)
        • force-failure.ts provided — per-user one-shot force-500 flag
      • Directoryinvoices/ provided — the full invoices surface
    • Directoryapp/
      • layout.tsx provided — <Providers> already wraps children (doc-only TODO)
      • Directory_components/
        • providers.tsx TODO — add QueryClientProvider + gated devtools + ClearCacheOnFlag
      • Directoryapi/invoices/[id]/comments/
        • route.ts TODO — GET handler, the client read seam
      • Directory(app)/invoices/[id]/
        • page.tsx TODO — add prefetch + dehydrate + <HydrationBoundary>
        • comment-thread.tsx TODO — 'use client'; useInfiniteQuery + useMutation
        • comment-form.tsx TODO — controlled form driven by CommentThread props
      • Directoryinspector/
        • page.tsx provided — the verification surface

src/lib/comments/ is a feature-shaped directory: schema, keys, fetcher, queries, action, and force-failure flag sit together, so the read seam and the write seam are neighbours instead of scattered across lib/. getQueryClient lives one level up at src/lib/query-client.ts because the factory is shared infrastructure: it knows nothing about comments, and the next client-cached feature reuses it as-is.

Three implementation lessons turn this map into the working feature, each ending on a runnable state.

Lesson 2 — Provider, per-request factory, and the SSR-hydrated first page

Wires the provider, keys, per-request factory, and the prefetch-and-hydration bridge, so the seeded thread paints with no client loading state.

Lesson 3 — Infinite scroll, polling, and the route handler

Adds the public read seam and the leaf’s useInfiniteQuery, so “Load older” pages in and a coworker’s comment arrives within the poll window.

Lesson 4 — Optimistic add and rollback with useMutation

Adds the Server Action write seam and the optimistic post: instant row, rollback on failure, two-system invalidation, then verifies the full flow.

The starter is the invoices codebase plus the comment store the thread needs: an in-memory invoiceComments store paged by a (createdAt, id) keyset cursor, seeded with 240 comments on each org’s focal invoice that alternate between the org’s two users. cacheComponents: true stays on from the invoices project.

  1. Get the starter codebase from the project repository, under Chapter 077/start/.

  2. Install dependencies. @tanstack/react-query and @tanstack/react-query-devtools already ship in the package.json.

    Terminal window
    pnpm install
  3. Start the dev server.

    Terminal window
    pnpm dev

Confirm the starting state. The root redirects to /invoices, so open the focal invoice at /invoices/inv-0001. The detail page renders end to end, exactly as in the invoices project. Below the cards, the comment thread shows its Thread not wired yet. placeholder, because its hooks are not implemented. At /inspector, the controls all load, but posting and polling do nothing, because the provider, hooks, and seams are unwritten.