'use client' and serializable props
'use client' marks a module and its whole import subtree as client; Server-to-Client props must be serializable, so data crosses downward as serializable props while server content is composed into client shells via children — not by importing across.
The first thing everyone does wrong with React Server Components is reach for 'use client' like a sprinkle — drop it on the one file that needs useState, ship it, move on. Then a Cannot pass a function from a Server to a Client Component error shows up, or your “small client island” quietly pulls a third of your bundle to the browser, and the directive stops feeling like a one-liner.
'use client' is not a per-component switch. It’s a boundary marker on a module, and it has two consequences most people learn the hard way: everything that module imports also becomes client code, and everything that crosses it as a prop has to be serializable. Get those two rules and the whole Server/Client model stops being mysterious.
After this lesson you can state exactly what 'use client' marks (a module and its entire import subtree, not one component); list what can and cannot cross the Server→Client prop boundary (serializable data yes; functions, class instances, and behavior-bearing objects no); explain why composing a Server Component into a Client Component through children works while importing one across the boundary does not; and recognize the two canonical failure modes — a non-serializable prop and an illegal cross-boundary import — on sight.
'use client' marks the module — and everything it imports — as the client boundary, not a single component. The directive sits at the top of a file. From that file down through its import graph, code runs (and ships) on the client. A Server Component that renders a 'use client' component is the boundary’s parent; the directive’s job is to tell the bundler “from here on, this subtree is client”. So the unit you’re marking is never “this one component” — it’s “this module and everything reachable from it”.
// likes-button.tsx
'use client';
import { useState } from 'react';
import { formatCount } from './format'; // <- this module is now CLIENT too
export function LikesButton({ initial }: { initial: number }) {
const [n, setN] = useState(initial);
return <button onClick={() => setN(n + 1)}>{formatCount(n)} likes</button>;
}formatCount had no directive of its own, but importing it from a client module pulls it across. The practical senior consequence: a 'use client' file at the top of a deep import tree drags that whole tree into the client bundle. Put the directive on the leaf that actually needs interactivity, not on a layout that imports half your app.
Props passed from a Server Component to a Client Component must be serializable. The boundary is a real wire: the server renders, and the props for each client island are serialized into the payload the browser hydrates from. Serializable means the things RSC can encode — primitives, plain objects and arrays of serializable values, Date, Map, Set, server-action references, JSX (ReactNode), and Promises of serializable values. Not serializable: arbitrary functions, class instances, and any object you’re passing for its behavior rather than its data.
// page.tsx (Server Component — no directive)
import { LikesButton } from './likes-button';
export default async function Page() {
const post = await db.post.find(); // class instance? -> map to plain data
return (
<LikesButton
initial={post.likes} // number — fine
// onLiked={() => track('like')} // function — CANNOT cross; throws
/>
);
}Why “no functions”? A function is opaque behavior — the server can’t ship the closure to the browser and have it mean the same thing. The blessed exception is a server action ('use server'), which is passed as a reference the client can invoke, not as a live closure. So callbacks cross the boundary as server actions, not as plain functions.
You compose Server content into a Client Component through children (or any ReactNode slot) — you do not import a Server Component into a 'use client' file. This is the move that confuses people. A Client Component can’t import a Server Component (the import graph rule from Step 1 would turn the Server Component into client code, losing its server-only powers like direct DB access). But it can render server-rendered content that’s handed to it as a prop. The Server Component stays on the server; its already-rendered output is passed down as children and the client shell just slots it in.
// client shell — owns interactivity (open/close), knows nothing about its content
'use client';
import { useState } from 'react';
export function Collapsible({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<section>
<button onClick={() => setOpen(o => !o)}>{open ? 'Hide' : 'Show'}</button>
{open && children}
</section>
);
}// server page — composes a SERVER component into the client shell as children
import { Collapsible } from './collapsible';
import { ServerReport } from './server-report'; // a Server Component (DB access etc.)
export default function Page() {
return (
<Collapsible>
<ServerReport /> {/* rendered on the server; passed in as children */}
</Collapsible>
);
}Collapsible never imports ServerReport. The parent (a Server Component) imports both and wires them together. That’s the “compose, don’t import across” rule: server content flows into client components as children, never across via an import.
The two failure modes, named so you spot them on sight. Failure A: passing a non-serializable prop across the boundary — a function, a class instance, a live object meant for behavior. You’ll see Functions are not valid as a child of Client Components or Only plain objects ... can be passed. The fix is to pass data (serialize the instance to a plain object) and pass callbacks as server actions. Failure B: importing a Server Component into a 'use client' module — by the Step 1 rule that re-tags the Server Component as client, so its server-only code (DB clients, secrets, fs) either breaks the build or, worse, leaks toward the browser. The fix is to invert: don’t import it; receive it as children from a server parent.
// ❌ Failure B: a Server Component imported into a client file
'use client';
import { ServerReport } from './server-report'; // now client; DB access breaks
export function Panel() { return <div><ServerReport /></div>; }
// ✅ Fix: take it as children, let a server parent supply it
'use client';
export function Panel({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}When not to push a boundary at all: if a subtree has no interactivity, leave it as Server Components — no directive, no serialization tax, no client bytes. 'use client' is a cost (serialization + bundle), so place it at the smallest leaf that genuinely needs browser-only APIs or state.
Refactor a “client modal that needs server data” from broken to correct. A product page wants a modal (client: open/close state) showing a server-fetched spec sheet (server: DB access). The instinct is to import the data-fetching component into the modal — which is exactly Failure B.
Before — the Client Component imports a Server Component and tries to pass a callback across:
// spec-modal.tsx
'use client';
import { useState } from 'react';
import { SpecSheet } from './spec-sheet'; // ❌ Server Component imported -> becomes client
export function SpecModal({ onOpen }: { onOpen: () => void }) { // ❌ function prop crosses
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => { setOpen(true); onOpen(); }}>Specs</button>
{open && <div className="modal"><SpecSheet /></div>} {/* SpecSheet now ships its DB code to the client */}
</>
);
}Two boundary violations: SpecSheet (server-only DB access) is imported into a client module, and onOpen is a live function passed from the server parent. After — invert both. The client shell takes server content as children, and the callback becomes a server action:
// spec-modal.tsx
'use client';
import { useState } from 'react';
export function SpecModal({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Specs</button>
{open && <div className="modal">{children}</div>}
</>
);
}// page.tsx (Server Component)
import { SpecModal } from './spec-modal';
import { SpecSheet } from './spec-sheet'; // stays on the server
import { logSpecsOpened } from './actions'; // 'use server' action
export default async function Page({ id }: { id: string }) {
return (
<SpecModal>
<SpecSheet productId={id} /> {/* server-rendered, passed as children */}
</SpecModal>
);
}SpecModal knows nothing about specs or the database — it owns only open/close. SpecSheet keeps its server powers because it’s never imported across the boundary; the server parent renders it and hands the output down as children. If you needed the open event tracked, logSpecsOpened crosses as a server action, not a plain function. The serializable data (productId, a string) is the only thing that travels down as a prop.
▸Why this works
Why does composing via children dodge the import rule? Because by the time children reaches the Client Component, the Server Component has already rendered on the server — what the client receives is its serialized output (a ReactNode), not the component’s source module. The client shell holds a hole and React drops server-rendered content into it during reconciliation. The Server Component’s code never enters the client’s import graph, so its server-only dependencies (DB clients, secrets) never get tagged for the browser. “Pass server output down, don’t pull server source across” is the entire trick.
▸Common mistake
The sneakiest version of the non-serializable-prop bug is a class instance that looks like data. You fetch a row through an ORM and pass the model object straight to a Client Component; it works in dev because the values are there, then breaks because the instance carries methods/prototype that can’t serialize — or it silently strips them and your client code calls a now-missing method. Pass a plain object ({ id, name, price }), not the live model. Same trap with a Date you’ve wrapped in a class, or a config object whose whole point is its methods. If you’re passing it for behavior, it doesn’t belong across the boundary — pass data and put the behavior on the client side or behind a server action.
A Client Component (a modal shell with open/close state) needs to display a section rendered by a Server Component that reads from the database. What's the correct way to combine them?
'use client' is a module boundary marker, not a per-component switch: it tags the file and its entire import subtree as client, so a directive placed too high drags a whole tree into the browser bundle — put it on the smallest interactive leaf. Across that boundary, data flows downward as serializable props — primitives, plain objects/arrays, Date, Map/Set, JSX, server-action references — while functions, class instances, and behavior-bearing objects are rejected; callbacks cross as server actions, not live closures. And you never import a Server Component into a 'use client' file (that would re-tag it as client and break its server-only powers); instead you compose server content into client shells through children, where a server parent renders the Server Component and passes its output down as a slot. The two failure modes — a non-serializable prop, and an illegal cross-boundary import — both resolve the same way: pass data down, and pass server content in as children. Mark the leaf, serialize the data, compose the rest.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.