open atlas
↑ Back to track
React patterns, senior RXP · 10 · 02

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

RXP Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

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.

Goal

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.

1

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

2

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.

3

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.

4

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.

Worked example

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.

Check yourself
Quiz

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?

Recap

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

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.