Skip to content
Chapter 30Lesson 2

Client Components and pushing the boundary down

How the App Router's 'use client' directive opts UI into the browser, and why you push that boundary down to the smallest interactive leaf.

In the last lesson you saw the invoice page render entirely on the server. It reads the database, formats every row, and ships the result as HTML. The browser downloads zero JavaScript for it. That is the App Router’s default, and for a page that only displays data it is exactly what you want.

Then the product grows. Someone wants a “Mark as paid” button on each invoice. Someone wants a status filter at the top of the list, and a date picker next to it. None of those can run on the server, because a button has to respond to a click that happens in the browser, long after the server is done. So the page needs the browser now, and the question is no longer whether to opt in but how much.

On day one almost everyone adds 'use client' to the top of the page file and moves on. The button lights up, so the change looks done. It is also the most common mistake in an App Router codebase, because that one directive drags the entire page into the browser bundle: the list, every row, and the heavy date and currency formatting code they all import, just to power one button.

src/app/invoices/page.tsx (the tempting move)
'use client';
export default function InvoicesPage() {

The previous lesson gave you the default and the list of things a Server Component can’t do. This lesson is about the opt-in: what crossing into the browser actually costs, how to decide when a piece of UI has earned it, and where to draw the line so you pay for interactivity in bytes you chose rather than bytes you spent by accident. By the end you’ll be able to explain what happens when a Client Component loads, look at a feature and decide on sight whether it needs the browser, and push the boundary down to the smallest leaf that does.

What “interactive” costs: the two-render model

Section titled “What “interactive” costs: the two-render model”

This section zooms into one fact from the last lesson: every component, Client ones included, runs on the server first. A full mechanism sits behind that one sentence, and it’s worth seeing in detail.

A Server Component is straightforward: its code stays on the server, runs once to produce output, and never reaches the browser. A Client Component differs in one specific way. Its code ships to the browser, so it runs in two places: once on the server, and again in the browser. That second run is the source of everything a Client Component costs, so the next few steps trace it in order.

Picture a single interactive leaf, a <MarkPaidButton />, somewhere inside an otherwise server-rendered page. Here is its life:

  1. Server render. When the request comes in, the button runs on the server like everything else and produces HTML: a real <button> element with its label and styling. This goes into the response. The user sees the button the instant the page paints, before a single line of JavaScript has loaded. That is the point of the server render: no blank screen while the bundle downloads.
  2. Ship. The HTML arrives and paints. In parallel, the button’s JavaScript, meaning its code plus React’s client runtime, downloads in the background. At this moment the button is visible but dead: it looks right, but clicking it does nothing, because no click handler is attached yet.
  3. Hydrate. Once the JavaScript arrives, React runs the same button component again, this time in the browser. It walks the HTML that’s already on the page, matches it up node by node, and attaches the event listeners and state that make it interactive. Now the click works.

That third step has a name. Hydration is the browser-side second render that brings static server HTML to life.

Step 1 / 4 Server render
Server <MarkPaidButton /> <button>Mark as paid</button>

runs once, emits HTML

Browser waiting for HTML…
On the server, the button renders to plain HTML — no JavaScript involved yet.
Step 2 / 4 Ship & paint
Server done — HTML sent
Browser not interactive yet
JS downloading…
The HTML paints immediately. The user sees the button before its JavaScript arrives — but clicking does nothing.
Step 3 / 4 Hydrate
Server done — HTML sent
Browser same component, 2nd render interactive · onClick attached
React re-runs the same component in the browser, matches it to the HTML, and attaches the click handler. This is hydration.
Step 4 / 4 Click works
Server done — HTML sent
Browser ✓ marked paid

one component, rendered twice

Now interactivity is live. The same component rendered twice — once for instant HTML, once for behaviour.

Steps 1 and 3 share something important: the button renders twice, and both times it must produce the same thing. That is the contract hydration depends on. React’s job in step 3 is to match its fresh browser render against the HTML the server already sent. If the two disagree, say the server rendered one label and the browser renders another, React can’t reconcile them and you get a hydration error.

You won’t hit that here, because a button label is the same on a server in a data center as it is in a browser in someone’s kitchen. But anything that genuinely differs between the two environments breaks the match: a random number, the current time, a window read. Those mismatches, and how to fix them, are the subject of a later lesson in this chapter. For now you only need the shape: a Client Component renders twice, and the two renders must agree.

That second render is the cost. A Server Component has none of it: no second render, no shipped code, no client runtime. A Client Component buys interactivity by sending its own code plus React’s machinery to every visitor’s browser. That is what 'use client' spends, and the rest of this lesson is about spending it carefully.

What earns 'use client', and what doesn’t

Section titled “What earns 'use client', and what doesn’t”

Before you write 'use client' on anything, ask one question of it: does this need to be alive in the browser? If it has to respond to the user, hold state that changes, or touch something only the browser has, the answer is yes and it becomes a Client Component. If it just turns data into markup, the answer is no and it stays a Server Component, the default you get for free. The two lists below are that single question, itemized.

A component earns 'use client' when it reaches for any of these:

  • useState / useReducer: state has to live in a mounted component instance, and instances only exist in the browser.
  • useEffect / useLayoutEffect: there is no “after the browser paints” without a browser to paint.
  • a ref to a DOM node: you need the real element, and real elements only exist client-side.
  • event handlers (onClick, onChange, onSubmit): attaching a listener is browser JavaScript.
  • browser APIs: window, localStorage, navigator, IntersectionObserver, and the rest of the platform that only exists once a page is loaded.
  • Context: both a createContext provider and the components that read it are client-bound. The last lesson told you a Server Component can’t consume Context; the other half is that the provider file itself needs 'use client'. This is the item people forget.
  • interactive third-party libraries: a carousel, a charting library, an animation library. Even when your own usage looks like plain declarative JSX, if the library reaches for state or effects or window internally, it needs the client. A few older libraries don’t ship their own directive and need a thin wrapper file to mark the boundary; the mechanics of that wrapper are the next lesson’s.

And here is the list that corrects the day-one instinct, the things that look like they need the browser but do not earn 'use client':

  • Fetching data. This is the most common false trigger: “I need data, so I need the client.” You don’t. You await it directly in a Server Component body, exactly as you saw last lesson. Reaching for the client here is how people end up rebuilding server fetching with useEffect for no reason.
  • Rendering markdown, code blocks, or large static content. That belongs on the server, where the heavy parsing libraries render once and ship zero bytes to the browser, the whole win from last lesson.
  • Reading environment variables or secrets. Keep these on the server, where they belong and never travel to the client.
  • Reading the URL on a server-rendered page. Use the server, via params and searchParams on the page props you met in the previous chapter on file-system routing.

The one item worth seeing in code is the Context provider, because “the provider must be a Client Component” is the non-obvious rule on the earns-it list.

src/app/theme-provider.tsx
'use client';
const ThemeContext = createContext<Theme>('light');
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
return <ThemeContext value={theme}>{children}</ThemeContext>;
}

The directive on line 1 is what makes this a Client Component, and the file can’t drop it: the useState it holds, and the Context it supplies, only work in the browser. Notice that the server-rendered children still flow straight through it. That’s the “wrap, don’t import” move from last lesson: a client provider can wrap server-rendered content without dragging it across the boundary.

Carry one idea out of this section: most of a page does not earn the client. The earns-it list is short and specific, while the does-not list covers a surprising amount of what a real page actually does. When you’re unsure, the answer is Server.

The exercise below puts that to work. Sort each feature into the side it belongs on. The trick is that several of the “I clearly need the browser” items belong squarely in the Server column.

Decide which of these force a component into the browser, and which can stay on the server. Drag each item into the bucket it belongs to, then press Check.

Earns 'use client' Must run in the browser
Stays a Server Component The default; ships no JS
useState for an open/closed toggle
An onClick handler
A Context provider
A third-party date picker
A useEffect that subscribes to a keyboard shortcut
Reading localStorage
await db.query(...) for the invoice list
Rendering a 50-paragraph markdown document
Reading process.env.STRIPE_SECRET_KEY
Reading searchParams to filter the list

You now know two things: a Client Component costs real bytes, and only a short list of features actually need one. Put them together and you get the rule experienced engineers apply on every page: default to Server, and opt into Client at the smallest leaf that needs it. Everything above that leaf stays on the server and ships nothing.

The clearest way to see this is the refactor itself. Take the invoice list: a page that loads invoices and renders a row for each, every row carrying a “Mark as paid” button. Only the button is interactive. So where does 'use client' go?

The tempting answer puts it at the top of the page, and it does more damage than just shipping the list. A Client Component cannot be async, and that is a hard rule, not a style preference. So the moment the page becomes a Client Component, it loses the await in its body, and you’re forced to drag data fetching back into a useEffect. You’ve now shipped the entire page to the browser and undone the server-rendering win from last lesson, all to attach one click handler. The better answer keeps the page, the list, and the row as Server Components, leaving the await right where it was, and marks only the button as Client. The two versions sit side by side below.

src/app/invoices/page.tsx
'use client';
export default function InvoicesPage() {
const [invoices, setInvoices] = useState<Invoice[]>([]);
useEffect(() => {
listInvoices().then(setInvoices);
}, []);
return (
<ul>
{invoices.map((invoice) => (
<InvoiceRow key={invoice.id} invoice={invoice} />
))}
</ul>
);
}

The whole page is now a Client Component. It can’t be async, so the clean server fetch had to become a useState plus a useEffect. The list, the rows, and every formatting dependency they import all ship to the browser, just to power one button per row.

Keeping the page on the server while a client button sits inside it works because of the composition you already have: a Server Component can render a Client Component as a child. (When you need the inverse, a client shell wrapping server-rendered content, that’s the children move from last lesson, reused here.) What’s new is not the mechanic but the direction of effort: you actively push the directive down the tree, toward the leaf, instead of letting it sit at the top by default.

The following diagram makes that movement spatial. The same component tree is drawn twice, and the shaded region is the JavaScript that ships to the browser. On the left the boundary sits at the root and the whole tree is shaded. On the right the boundary has moved down to a single leaf, and almost nothing is shaded. Less shading means less bundle, and that is the whole argument, shown as area.

'use client' at the page
InvoicesPage
InvoiceList
InvoiceRow × many
MarkPaidButton
ships whole tree + formatting deps
'use client' at the leaf
InvoicesPage
InvoiceList
InvoiceRow × many
MarkPaidButton
ships one button
The boundary at the page (left) ships the whole tree to the browser; pushed to the leaf (right), almost nothing ships. The shaded area is the client bundle.

This is not an abstract tidiness argument; the cost is something you can measure. Every 'use client' boundary adds its file’s code, its transitive dependencies, and React’s client runtime to the bundle the user downloads. The way you check that cost is with @next/bundle-analyzer , which renders your client bundle as a treemap, sizing every file and dependency by how many bytes it adds. You’ll learn to read that treemap in a later chapter on performance. For now the point is narrower: the boundary decision is measurable, so the habit is to check the bundle impact before merging anything that adds a Client Component. Skip the check and you can find out months later that a date picker dragged a 200 KB locale table into every page.

A narrow boundary brings one more benefit. Keeping the client leaf small keeps it cheap in bytes of code, but it also keeps the props you pass across the boundary small, and that matters for two further reasons. Anything you hand to a Client Component is visible to the browser, so a narrow boundary is part of how you avoid leaking a secret in props (the caution from last lesson). And every prop is also data sent over the wire, so narrow props are smaller payloads. The full story of what crosses that wire is a later lesson in this chapter; for now carry the short version: narrow on JavaScript, narrow on data. (The framework even ships a taint API that can flag a server-only value so passing it to a Client Component throws, a backstop for the secrets leak that is covered with security later.)

Here’s a habit that pays off the moment you open an unfamiliar codebase: you can tell whether any file is Server or Client before reading a line of its body. It’s a two-step read.

  1. Does the file start with 'use client'? If yes, it’s a Client Component, and so is everything it imports.
  2. If there’s no directive, how is the file reached? A file with no directive is a Server Component only if nothing above it in the import chain is a Client Component. If it’s imported from a 'use client' file, directly or several hops away, it gets pulled into the client graph even though it has no directive of its own.

That second point is the one that catches people, so look at it closely. 'use client' doesn’t flip one component; it marks an entry point into the client graph, and everything downstream of that entry travels into the browser with it. A small formatting helper with no directive can still be client code, purely because of where it’s imported from.

src/app/invoices/_components/mark-paid-button.tsx
'use client';
import { formatMoney } from './format-money';
src/app/invoices/_components/format-money.ts
export function formatMoney(cents: number) {
return `$${(cents / 100).toFixed(2)}`;
}

format-money.ts has no directive of its own, yet it’s part of the client bundle, because the only file importing it is a Client Component. The directive on the button pulled the helper across the boundary with it.

To put it plainly: 'use client' marks the boundary into the client subgraph, and it propagates to everything that subgraph imports. That is the one fact about the directive you need today. The rest belongs to the next lesson: that it must be the first non-comment line, that a typo in it fails silently, that repeating it deeper in the tree does nothing, its 'use server' sibling, and the server-only / client-only packages that turn a leaked import into a build error. This lesson stops at “it marks the boundary and flows downward.”

This reading habit also explains a rule from last lesson without re-deriving it: importing a Server Component into a Client file is forbidden because the directive just drew a boundary, and that import would drag server code straight across it. The rule isn’t arbitrary; it’s what the two-step read predicts.

Try the read on a few files. For each description, pick the side it lands on.

For each file, decide which side of the boundary it lands on. Pick the right option from each dropdown, then press Check.

format-money.ts has no directive and is imported only by page.tsx, so it’s a Component. Move that same helper’s only import into a 'use client' component and, with not a character of its own changed, it becomes code. And any file whose first line is 'use client' is — directive or import chain, either route in is enough.

Three ideas to carry forward:

  • The two renders. A Client Component runs on the server to produce instant HTML, then runs again in the browser to hydrate into something interactive, and both renders must produce the same output.
  • The rubric. State, effects, refs, event handlers, browser APIs, Context, and interactive third-party libraries earn 'use client'. Data fetching, secrets, markdown, and reading the URL on a server-rendered page do not.
  • The habit. Default to Server, push the boundary down to the smallest interactive leaf, and check the bundle cost before you merge.

You’ve been using 'use client' as a boundary marker, knowing only that it flips a file and its imports into the client graph. The next lesson is that directive in full: its exact placement rule, the silent-typo trap, its 'use server' sibling, the principle of preferring explicit markers over framework magic, and the server-only / client-only packages that turn a misplaced import into a build error instead of a production leak.

Two threads this lesson opened on purpose stay open for later in this chapter: exactly what is allowed to cross the wire when you pass props to a Client Component (the serialization rules behind “props must be serializable”), and what happens when the two renders don’t agree (hydration’s failure modes and their fixes).