Part 6 · 4 chapters · ~35 min

Server Components And The Compiler

A Server Component runs where the data is and sends what it returned, not what it is; a Client Component is the leaf that needs a browser; the compiler writes the memoisation you would have. This part is the division and the directive, the Flight payload row by row and how it travels, server actions as endpoints, what the compiler analyses and emits and refuses, and the two component kinds costed side by side.

30

What a Server Component is

the question

"Server Components: is that SSR? Is it a different React? What runs where?"

Not SSR, though it composes with it. A Server Component is a component that runs only on the server (per request, or at build time), can be async and read data directly, and whose output is sent to the client as a serialised element tree rather than as HTML or as code. The client never has the component's code; it has what the component returned. Client Components (modules marked "use client") are the leaves that need a browser: state, effects, event handlers. Together they make one tree, split by where each part runs.

code
// the server / client boundary, in code
// app/posts/[id]/page.js  (server component by default; no directive)
import { db } from '@/lib/db'                  // server-only: never bundled for the client
import { LikeButton } from './LikeButton'      // a client component: imported by reference
export default async function Page({ params }) {
  const post = await db.posts.find(params.id)   // async component; data access inline; no useEffect, no loading state here
  return <article><h1>{post.title}</h1><Markdown source={post.body} /><LikeButton postId={post.id} initialCount={post.likes} /></article>
}

// LikeButton.js
'use client'                                    // this module and everything it imports is client code
import { useState } from 'react'
export function LikeButton({ postId, initialCount }) { const [n, setN] = useState(initialCount); return <button onClick={() => { setN(n + 1); like(postId) }}>{n}</button> }

// the rules that follow from the serialisation boundary:
//   server → client props: serialisable only (JSON-ish + Date, Map, Set, typed arrays, promises, server action references, and React elements)
//   a client component cannot import a server component (there is no server at runtime on the client) but can receive one as children/props
//   server components cannot use hooks, event handlers, browser APIs; they can be async and can await anything server-side
//   "use server" marks server actions: functions callable from the client (RPC); the reference crosses the boundary, the body stays on the server
//   secrets and server-only modules: import 'server-only' makes an accidental client import a build error
the division
  1. Server Components: no hooks except use() for context and promises in some setups, no state, no effects, no event handlers, no browser APIs. Can be async; can import server-only modules (database clients, file system, secrets, heavy libraries); their dependencies are not in the client bundle.
  2. Client Components: everything React has always been: hooks, state, effects, handlers, refs. Marked by "use client" at the top of the module; the boundary propagates to everything that module imports.
  3. Composition: a server component can render client components and pass them serialisable props, including other server components' output as children. A client component cannot import a server component, but can render whatever it was given. The tree alternates freely; the constraint is the direction of imports.
  4. Shared components: a module with no directive can be used by both; it is server code when imported from the server and client code when imported from a client module (it gets bundled for the client in that case).
what it buys and what it costs
  1. Buys: data access without a client round trip or a loading state (the component awaits it); zero client bundle for server-only code and its dependencies; the ability to keep secrets and heavy libraries off the client; streaming of the tree as data resolves (part 5's transport).
  2. Costs: a framework or custom bundler integration (the "use client" boundary, the module map, the payload transport); a mental model with two environments; serialisation limits on props; no state in the server tree (re-rendering a server component means a new request for its payload); debugging across the boundary.
  3. Not a replacement for client state: interactive UI is still client components; RSC changes where the data-fetching and static-content parts of the tree run, not where the interactive parts do.
A SERVER COMPONENT REQUEST
from the route to the RSC payload to the client tree
swipe the figure sideways, or tap expand for full screen
1/6
request
GET /posts/42. The router resolves to a Server Component tree: Page > Layout > Post. The server renders Page: it awaits db.posts.find(42) directly in the component (Server Components can be async). No bundle for Page is ever sent to the client.
31

The payload, the transport, and actions

code
// server actions: the client calls a server function by reference
// actions.js
'use server'
export async function like(postId) { await db.likes.insert({ postId, userId: await currentUser() }); revalidatePath(`/posts/${postId}`) }
// LikeButton.js ('use client'): import { like } from './actions'   → the client gets a reference { id: 'actions.js#like' }; calling it POSTs
//   the arguments (serialised) to the framework's action endpoint; the server runs the body; the response can include a fresh RSC payload
//   so the UI updates (revalidatePath re-renders the route's server components and streams the new tree)
// in a form: <form action={like}> works without JavaScript (progressive enhancement: a normal POST), and with it via fetch + useActionState (part 9)
// what to remember: an action is an endpoint. validate inputs, check auth, rate limit, exactly as you would a route handler.
// the convenience is the wiring; the security surface is the same
the RSC payload (React Flight)
  1. Rows: one per line, id:type…. Element rows (["$", type, key, props]) are the serialised output of server components, with children nested. Module rows (I[...]) describe a client component's chunk and export. Reference tokens inside element rows ("$L1" for a lazy module ref, "$@2" for a promise, "$S" for Suspense, "$F" for a server action reference) point at other rows.
  2. Streaming: rows arrive in resolution order; a Suspense boundary's content is a later row that fills a placeholder; the client renders what it has and suspends on what it does not. The format is designed to be parsed incrementally, which is why it is line-oriented rather than one JSON document.
  3. Deduplication: repeated objects and module refs are referenced by id, not repeated; a large shared object crosses once.
  4. On initial load: the framework renders the payload to HTML (SSR of the reconstructed tree) and also inlines the payload so hydration can rebuild the same tree without re-requesting it. On client navigation: only the payload is fetched (text/x-component), and the router merges the new subtree into the existing client tree, preserving client component state that was not part of the change.
server actions
  1. A function marked "use server" (per function or per module) can be imported by client components; the client receives a reference, not the body. Calling it serialises the arguments and POSTs to the framework's action endpoint; the server runs it; the response can carry a new RSC payload so the affected route re-renders.
  2. Forms: <form action={fn}> submits via the action; without JavaScript it is a normal POST (progressive enhancement); with it, React intercepts, calls the action, and useActionState / useFormStatus expose the pending and result state (part 9).
  3. Security: an action is an HTTP endpoint with a generated route. Validate every argument, authenticate, authorise, rate-limit, exactly as for a REST handler. The ergonomic wrapper does not change the threat model; it hides the endpoint, which is a reason to be more careful, not less.
  4. Revalidation and caching: frameworks cache rendered server components (per route, per fetch) and expose invalidation (revalidatePath, revalidateTag) so an action can mark what to re-render. This is the server-state model from part 7 moved to the server.
32

The React Compiler

The compiler automates the memoisation discipline of part 2: it analyses each component for which values depend on which reactive inputs, and emits code that caches every derived value and element in a per-fiber slot, recomputing only when the inputs change by reference. The output is equivalent to careful manual useMemo, useCallback and React.memo, generated from the code rather than from a human's guess at the dependencies.

how it works
  1. Lower the component to a high-level IR in SSA form (every value defined once; the algorithms course part 8), with control flow explicit.
  2. Analyse reactivity: props, state, context and values derived from them are reactive; imports and constants are not. Track mutations: a value mutated after creation cannot be cached across renders unless the mutation is also within the cached scope. Track escapes and aliasing. Validate the rules of hooks and of render purity as far as static analysis allows.
  3. Partition the body into reactive scopes: maximal regions that produce one or more values from a set of inputs with no observable mutation leaking out.
  4. Emit: const $ = _c(n) (useMemoCache: a hook returning a per-fiber array of n slots); each scope becomes if (inputs changed) { compute; store } else { load }. JSX elements are values too, so element identity is stable when their inputs are.
  5. Skip components where the analysis finds a rule violation it cannot reason around; report it through the lint plugin; leave the component uncompiled.
what it changes in practice
  1. Manual memoisation becomes mostly unnecessary in compiled code. Existing useMemo/useCallback are preserved (the compiler respects them) but new code need not add them.
  2. Parent re-renders stop at unchanged children because the element references the parent passes are cached: the plain props-identity bailout (part 2) passes.
  3. The rules of React are enforced at build time. A component that mutates props, reads refs in render, or calls hooks conditionally is flagged and skipped. Codebases that followed the rules compile wholesale; those that did not get a list.
  4. It does not: reduce the cost of a render whose inputs changed; memoise across components (each has its own cache); handle external mutable state (uSES still needed); fix effect dependency arrays (a separate lint); or change behaviour of correct code (compiled and uncompiled must be semantically identical, which is the correctness bar it holds itself to).
  5. Costs: a cache array per compiled fiber (tens of slots, a few hundred bytes); the compares per render; build integration (Babel plugin, or built into frameworks); opt-out via "use no memo" for a component that must not be memoised (rare).
the sizes
In the first large deployments (Instagram, Quest Store), the compiler cut interaction render times measurably without code changes, with the gains concentrated in large trees whose parents re-render often. The Profiler's "why did this render" is the before-and-after instrument; the "Memo ✨" badge marks compiled components.
WHAT THE COMPILER DOES TO A COMPONENT
from source to memoised output, by dependency analysis
swipe the figure sideways, or tap expand for full screen
1/6
the source
Input: function List({ items, filter }) { const visible = items.filter(i => i.name.includes(filter)); const onSelect = id => select(id); return
    {visible.map(i => )}
}. Every render: a new visible array, a new onSelect function, new Row elements: no bailout anywhere below.
33

Server and client, costed

ConcernServer ComponentClient Component (SSR + hydration)Client-only (no SSR)
Where it runsServer (per request or build)Server for HTML, then clientClient only
Data accessDirect (await in render)Fetched client-side (or passed as props from a server parent)Fetched client-side
Client bundleNone for the component or its depsThe component and its depsThe component and its deps
State, effects, handlersNoneAllAll
UpdatesA new payload request (navigation, revalidation, action)Local re-renderLocal re-render
First paintIn the shell or streamed; no client JS needed to showHTML in the shell; interactive after hydrationAfter the bundle loads and renders
Hydration costNone (plain elements; no component to run)Runs the component to attach handlersNot applicable (a full render)
Secrets and heavy librariesSafe; never shippedShipped; must not include secretsShipped
Cost per requestServer CPU and data access per request (or cached)Server CPU for HTMLNone on the server
Best forContent, layouts, data-heavy views, anything static per requestInteractive widgets that should appear immediatelyHighly interactive, personalised, or offline-capable parts
placing the boundary
  1. Push client boundaries down: a page that is mostly content with a few interactive islands should be a server tree with small client leaves (the like button, the comment form), not a client page that happens to fetch on the server.
  2. Pass server output as children into client wrappers (a client <Tabs> whose panels are server-rendered content) so interactivity wraps static content without pulling it into the client bundle.
  3. Keep secrets and data clients server-side with server-only imports as a build-time guard.
  4. Expect the payload on navigation: client-side navigation in an RSC app is a request for the route's payload; the latency is that request. Prefetch on hover or in view, as frameworks do by default for links.
  5. Caching is the server-state problem again (part 7): what is cached per route, per user, per fetch, and what invalidates it. The framework's cache semantics are the thing to read first.
the pointer
Part 7 is state at scale: local, lifted, context, store, URL, and server state; which library when; and when none. RSC moved one category (server state for content) to the server; the rest of the categories are the next part.