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 the invoice page rendered entirely on the server: it reads the database, formats every row, and ships HTML with zero JavaScript. 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, a status filter at the top of the list, a date picker beside it. None of those can run on the server, because a button has to respond to a click in the browser, after the server is long done. The page needs the browser now, and the question is no longer whether to opt in but how much.

The reflex is to add 'use client' to the top of the page file. The button lights up, so the change looks done. It is also the most common mistake in an App Router codebase: that one directive drags the entire page into the browser bundle, including the list, every row, and the heavy date and currency formatting they import, just to power one button.

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

This lesson is about the opt-in: what crossing into the browser costs, how to tell when a piece of UI has earned it, and where to draw the line so you pay for interactivity on purpose rather than by accident.

Every component, Client ones included, runs on the server first. Behind that one sentence sits the full mechanism this section traces.

A Server Component is simple: its code stays on the server, runs once to produce output, and never reaches the browser. A Client Component differs in one way. Its code ships to the browser, so it runs in two places: once on the server, then again in the browser. That second run is what a Client Component costs.

Picture a single interactive leaf, a <MarkPaidButton />, inside an otherwise server-rendered page. Its life runs in three steps:

  1. Server render. The request comes in, the button runs on the server like everything else, and produces HTML: a real <button> with its label and styling. The user sees it the instant the page paints, before any JavaScript has loaded. No blank screen while the bundle downloads.
  2. Ship. The HTML paints. In parallel, the button’s JavaScript, its code plus React’s client runtime, downloads in the background. The button is now visible but dead: it looks right, but clicking does nothing, because no handler is attached yet.
  3. Hydrate. Once the JavaScript arrives, React runs the same component again in the browser. It walks the HTML already on the page, matches it 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 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 are the same button rendered twice, and both renders must produce the same thing. That is the contract hydration depends on: in step 3 React matches its fresh browser render against the HTML the server sent. If the two disagree, say one label on the server and another in the browser, React cannot reconcile them and you get a hydration error.

A button label can’t disagree, but anything that genuinely differs between the two environments will: a random number, the current time, a window read. Those mismatches, and how to fix them, come in a later lesson. For now, hold the shape: a Client Component renders twice, and the two renders must agree.

A Server Component pays none of this: 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 it one question: does this need to be alive in the browser? If it responds to the user, holds state that changes, or touches 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. The two lists below are that one question, itemized.

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

  • useState / useReducer: state lives 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 exists only once a page is loaded.
  • Context: both a createContext provider and the components that read it are client-bound. Last lesson said 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, effects, or window internally, it needs the client. A few older libraries ship no directive of their own and need a thin wrapper file: a 'use client' file that re-exports the library’s component, marking the boundary on its behalf.

And 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. 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. 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 heavy parsing libraries render once and ship zero bytes to the browser.
  • 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 params and searchParams on the page props from the file-system routing chapter.

The one item worth seeing in code is the Context provider, since “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 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, the “wrap, don’t import” move: a client provider can wrap server-rendered content without dragging it across the boundary.

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

A Client Component costs real bytes, and only a short list of features needs one. Together they give 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.

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?

Putting it at the top of the page does more damage than just shipping the list. A Client Component cannot be async, so the page loses the await in its body and you’re forced to drag data fetching back into a useEffect. You’ve shipped the whole page to the browser and undone last lesson’s server-rendering win, all to attach one click handler. The better answer keeps the page, list, and row as Server Components, leaving the await in place, and marks only the button as Client.

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 becomes a useState plus a useEffect, and the list, the rows, and every formatting dependency they import all ship to the browser to power one button per row.

This works because a Server Component can render a Client Component as a child. (For the inverse, a client shell wrapping server-rendered content, reuse the children move from last lesson.) What’s new is the direction: you push the directive down the tree toward the leaf instead of letting it sit at the top by default.

The 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 it has moved down to a single leaf, and almost nothing is. Less shading means less bundle.

'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.

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. You check that cost with @next/bundle-analyzer , which renders your client bundle as a treemap, sizing every file and dependency by the bytes it adds. 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 helps twice over. Keeping the client leaf small keeps the props you pass across the boundary small, and that matters two ways. 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. And every prop is also data sent over the wire, so narrow props are smaller payloads. What crosses that wire is a later lesson; for now: narrow on JavaScript, narrow on data.

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. Imported from a 'use client' file, directly or several hops away, it joins the client graph despite having no directive of its own.

That second case is where people get it wrong. '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 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, yet it ships in the client bundle: its only importer is a Client Component, and the directive on the button pulled the helper across the boundary.

That is the one fact about the directive you need today: 'use client' marks the boundary into the client subgraph and propagates to everything that subgraph imports. The next lesson covers the directive’s finer rules and its 'use server' sibling.

The two-step read also explains last lesson’s rule against importing a Server Component into a Client file: the directive drew a boundary, and that import would drag server code straight across it.

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.

You’ve used 'use client' as a boundary marker that flips a file and its imports into the client graph. The next lesson covers that directive in full: its placement rule, the silent-typo trap, its 'use server' sibling, and the server-only / client-only packages that turn a misplaced import into a build error instead of a production leak. Two threads stay open for later in this chapter: what is allowed to cross the wire when you pass props to a Client Component, and what happens when the two renders disagree.