Skip to content
Chapter 77Lesson 1

Project overview

You are going to bolt a comment thread onto the invoice detail page you built back in the invoices project. Not a static list — a live one. The seeded thread paints the instant the page loads, with no spinner and no loading flash. A comment you post appears at the top of the thread the moment you hit submit, before the server has even confirmed it; if the server then rejects it, the row vanishes and an error banner tells you why. And a comment a coworker posts from another session shows up in your open thread on its own, within ten seconds, with no refresh. That is the whole feature, and it is exactly the kind of surface that pulls a team toward a client cache.

The decision this project exists to install is a restraint one. TanStack Query is a power tool, and the way inexperienced teams get burned is by reaching for it page-wide the moment they adopt it — turning a perfectly good Server Component page into a client-rendered one to get a cache they only needed in 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, untouched. The cost of bringing the tool in is that you now own two caches and two read paths, and a clean architecture keeps them from leaking into each other. That ownership is what this project teaches.

The data layer here 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) and the inspector documents the equivalent Postgres index in prose, so you focus this chapter entirely on the client-cache architecture rather than on standing up infrastructure you have already practiced.

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

This project is where the four wiring decisions from the previous chapter stop being demos and become one running feature. By the end you will have practiced:

  • Wiring TanStack Query into an App Router surface the way an experienced engineer does: a <QueryClientProvider> with production defaults, a per-request QueryClient on the server, and 'use client' pushed down to the leaf rather than smeared across the page.
  • Bridging the server and client caches: prefetching on a Server Component, dehydrating, and hydrating the client cache 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 cache-update optimistic shape — snapshot, optimistic write, rollback on failure — and reconciling the two caches that now hold the same data.

Five facts about the shape carry the whole project. None of them are built this lesson; this is the map you will keep returning to.

  • The page stays a Server Component; the thread is one client leaf. The invoice detail page renders its header and cards as Server Components, exactly as the invoices project left them. At the bottom sits a single <CommentThread /> marked 'use client'. 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 — that is the HTTP contract the thread polls and scroll-fetches against, the same seam a future mobile or third-party client could hit. The server’s first-paint prefetch reads the store directly, in-process, through a server-only listCommentsPage. Same key, same response shape, but two distinct functions on purpose: the client fetcher must never import server-only code, or next build fails (and in a real app it would drag the database driver 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 it is wrapped in React’s cache() so each request gets its own instance — a shared one would leak one tenant’s prefetched comments into the next tenant’s render. On the client it is a single long-lived instance, which is the entire point of a cache. And commentKeys is the only place query-key arrays are allowed to exist.
  • 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. You will lean on it heavily.

That is the shape. How the optimistic update snapshots and rolls back, and how polling pauses, are decisions the build lessons own — don’t worry about the mechanics yet.

The starter shares one file tree with the finished solution — no files are added or removed across the whole project. Your work is edits inside the stubbed files. The tree below highlights those: the files carrying a TODO are the build, and everything else is provided whole — the entire 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

The grouping is deliberate. src/lib/comments/ is a feature-shaped directory: the schema, keys, fetcher, queries, action, and force-failure flag for the thread all sit together, so the read seam and the write seam are neighbours rather than scattered across lib/. getQueryClient lives one level up at src/lib/query-client.ts because the factory is shared infrastructure — it has nothing to do with comments specifically and the next client-cached feature would reuse it as-is. And commentKeys lives beside the comment feature because query-key arrays are that feature’s query-system identifiers, the same way the cache-tag builders lived beside their feature in tags.ts back in the cache-invalidation project.

Three implementation lessons turn this map into the working feature, each closing 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-plus-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 everything the comment thread needs in the store: an invoiceComments table paged by a (createdAt, id) keyset cursor (the inspector documents the equivalent Postgres composite index), and a deterministic seed of 240 comments on each org’s focal invoice, alternating between the org’s two seeded users — the second identity is the one the inspector’s “Insert coworker comment” control attributes rows to. cacheComponents: true stays on from the invoices project; the thread is a fresh dynamic-with-client-cache leaf that does not disturb the cached invoice-header subtree above it.

  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 starter’s package.json.

    Terminal window
    pnpm install
  3. Start the dev server.

    Terminal window
    pnpm dev

Open an invoice detail page — the root redirects to /invoices, so click through to any invoice, or jump straight to a focal one at /invoices/inv-0001. The invoice detail page renders end to end: the invoice number, the customer and total cards, the lifecycle and edit paths all work, just as they did in the invoices project. Below the cards, the comment thread shows its placeholder — Thread not wired yet. — because the hooks are not implemented yet. Visit /inspector and the identity switcher and comment controls all load, but posting and polling do nothing, because the provider, hooks, and seams are unwritten. That is the correct starting state. The next lesson lands the first piece: the seeded thread painting instantly on first load.