open atlas
↑ Back to track
React, zero to senior RCT · 11 · 01

The RSC model: two environments, one tree

Server components run once per request — async, direct data access, zero bundle weight. Client components hydrate and live. The Flight payload is a serialized element tree with module-reference holes, not HTML, and it composes with SSR instead of replacing it.

RCT Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior
Already know this unit? Take a 1-minute quick check →

The docs platform was a classic SPA: every article page shipped the markdown parser, the syntax highlighter with its grammar files, the sanitizer, and a date library with locale data — 1.1 MB of minified JavaScript to display text that never changed after publish. The rewrite moved rendering into React Server Components: the same libraries now run on the server, the article route’s client bundle dropped to roughly 210 kB, and total blocking time on a mid-range phone fell from 2.1 s to under 400 ms. Two weeks in, a teammate added a Copy button inside the article component — onClick={copy} — and dev crashed with “Event handlers cannot be passed to Client Component props.” The PR thread filled with genuine confusion: it is a React component, why can it not have onClick? Because it is a server component: it ran once, on the server, while the response was being built. By the time a human can click anything, the function behind that onClick — its closure, its module, its process state — may no longer exist. Knowing which parts of your tree live where, and what is allowed to travel between them, is the entire RSC model.

By the end of this lesson you will know exactly why that onClick failed — and how to fix it without blowing up your bundle.

Two environments, one tree

A React tree in an RSC framework spans two environments. Server components — the default, no directive needed — execute on the server, once per request (or once per revalidation, if the framework caches). They can be async functions: await db.query(...), read the filesystem, call an internal service — data access with no API endpoint in between. Because they run to completion and are then gone, they cannot hold useState, schedule useEffect, attach event handlers, or touch window — there is no “later” for them in any browser. Client components — modules that start with the 'use client' directive — are bundled, downloaded, hydrated, and then live: state, effects, handlers, browser APIs. One tree freely interleaves both kinds: a server page renders a client RangePicker, which can itself receive server-rendered children (that interleaving is the next lesson).

One naming trap is worth killing early: “server component” does not mean “renders on the server” — SSR has been rendering client components on the server for a decade. It means only ever exists on the server. The component’s code is never part of any bundle, and it never re-executes in the browser.

What the server half of the tree buys you

Three concrete wins, in descending order of how often they justify the migration:

  • Zero bundle weight for server-only code. A syntax highlighter with its grammars and themes runs to hundreds of kB even minified; a markdown parser plus sanitizer adds tens more; a date/i18n stack with locale data the same again. In a server component those imports cost the client nothing — the docs platform in the Hook cut ~900 kB this way without deleting a feature. The user downloads the output of those libraries, never the libraries.
  • Direct data access. The query lives next to the component that needs it, runs at datacenter latency (sub-millisecond to single-digit ms to the database, versus 50–300 ms RTT from a browser), and never exposes an API surface you must version and secure separately.
  • Automatic code splitting. Every 'use client' boundary is a split point: client components arrive as separate chunks referenced from the payload, loaded when the tree actually mounts them. Nobody maintains React.lazy bookkeeping by hand.

Together, these three properties mean the server half of your tree pays for itself: heavy libraries never hit the client, data fetching moves to where it is cheapest, and bundle splitting becomes structural rather than a manual chore. Skip the server-component model entirely and you trade all three back for a simpler mental model — a real tradeoff, not a free lunch.

The Flight payload: a tree with holes, not HTML

When a request comes in, server components execute and dissolve into their output. What goes over the wire is the RSC payload (the format React calls Flight): a serialized element tree, not markup. Shape simplified:

0:["$","article",null,{"children":[
  ["$","h1",null,{"children":"Q3 traffic report"}],
  ["$","$L1",null,{"initialRange":"30d","points":[120,84,97]}]]}]
1:I["./RangePicker.js",["chunk-1f9a"],"RangePicker"]

Row 0 is the already-resolved markup that server components produced — plain elements, their work done. $L1 is a hole: a module reference where a client component mounts. Row 1 resolves it — which chunk to download, which export to use — and the props sitting in the hole (initialRange, points) were serialized server-side, which is why they must be serializable at all. Notice what is absent: the server components themselves, their data-fetching code, the markdown parser. They contributed output, not code. The payload streams, too — rows arrive incrementally, so a slow query deep in the tree does not block the rows above it.

Quiz

On a client-side navigation in an RSC framework, the response body is not HTML. What is it, and why does the client runtime need the I-rows (module references)?

RSC and SSR are different layers — and they compose

This is the distinction interviews keep probing because production teams keep blurring it. SSR takes client components and renders them to HTML once on the server, so the user sees pixels before the bundle loads; the code still ships, hydration still runs, and from then on the component lives in the browser. SSR is a first-paint optimization for client React. RSC is a persistent server layer: server components re-render on the server whenever the client navigates or refetches, and their code never ships at all.

They compose rather than compete. First load: server components run → Flight payload → the SSR pass renders the whole tree (client components included) to HTML → the browser paints immediately → hydration brings only the client components to life, using the inlined payload as the tree description. Navigation afterwards: no HTML at all — the client fetches a fresh Flight payload for the new route and React reconciles it into the live DOM, preserving the state of client components that stayed in place. HTML appears exactly once in this pipeline, and only as a first-paint artifact.

Failure mode: where did onClick go

The Copy-button incident generalizes. onClick={copy} on a button inside a server component means a function has to cross into the payload — and functions do not serialize (what would the browser receive — the source? its closure? its database handle?). Frameworks fail fast in dev: “Event handlers cannot be passed to Client Component props.” The fix is never to mark the whole page 'use client' — that drags every import on the page back into the bundle and undoes the migration. Extract the smallest interactive leaf (CopyButton.tsx, ten lines, 'use client' at the top) and keep the article itself on the server. The review smell to watch for: a 'use client' at the top of a 400-line page file, added to make one button work.

Quiz

A teammate says: our app already has SSR, so we already have what RSC offers — the components render on the server either way. What is wrong with that?

Recall before you leave
  1. 01
    Walk through what happens on first load and on a subsequent navigation in an RSC framework — name every artifact that crosses the network and what the browser does with it.
  2. 02
    Why exactly can a server component be an async function that awaits a database, yet cannot hold useState or an onClick — and what is the correct fix when one needs a button?
Recap

The RSC model splits one React tree across two environments with opposite lifecycles. Server components — the default — run once per request on the server: they may be async and await a database or filesystem directly, their imports cost the bundle nothing (which is where the headline wins live: highlighters, markdown pipelines, locale data — hundreds of kB that the user never downloads), and they cannot hold state, effects, or event handlers, because by the time a user exists they are gone. Client components, marked by the use client directive, are bundled, hydrated, and live in the browser with the full interactive toolkit. What crosses the wire is the Flight payload: a serialized element tree in which server components appear only as their resolved output, and client components appear as holes — module references binding a chunk and an export, plus serialized props. It is not HTML, and it streams. SSR is a separate, composable layer: on first load the payload feeds an SSR pass that renders the whole tree to HTML for first paint, after which hydration wakes only the client components; on every later navigation the client fetches Flight alone and React reconciles it into the live DOM, preserving client state in place. The canonical failure is putting interactivity in a server component — an onClick is a function that cannot serialize into the payload, and the dev error says exactly that. The fix is a minimal use client leaf, never a page-level directive that drags the whole import graph back into the bundle. Now when you see a use client at the top of a 400-line page file, you know exactly what it cost — and how to undo it.

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 6 done

Something unclear?

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

Apply this

Put this lesson to work on a real build.

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

Trademarks belong to their respective owners. Editorial reference only.