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.
What a Server Component is
"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.
// 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- Server Components: no hooks except
use()for context and promises in some setups, no state, no effects, no event handlers, no browser APIs. Can beasync; can import server-only modules (database clients, file system, secrets, heavy libraries); their dependencies are not in the client bundle. - 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. - 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. - 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).
- 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).
- 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.
- 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.
The payload, the transport, and actions
// 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- 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. - 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.
- Deduplication: repeated objects and module refs are referenced by id, not repeated; a large shared object crosses once.
- 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.
- 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. - Forms:
<form action={fn}>submits via the action; without JavaScript it is a normal POST (progressive enhancement); with it, React intercepts, calls the action, anduseActionState/useFormStatusexpose the pending and result state (part 9). - 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.
- 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.
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.
- Lower the component to a high-level IR in SSA form (every value defined once; the algorithms course part 8), with control flow explicit.
- 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.
- 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.
- Emit:
const $ = _c(n)(useMemoCache: a hook returning a per-fiber array of n slots); each scope becomesif (inputs changed) { compute; store } else { load }. JSX elements are values too, so element identity is stable when their inputs are. - Skip components where the analysis finds a rule violation it cannot reason around; report it through the lint plugin; leave the component uncompiled.
- Manual memoisation becomes mostly unnecessary in compiled code. Existing
useMemo/useCallbackare preserved (the compiler respects them) but new code need not add them. - Parent re-renders stop at unchanged children because the element references the parent passes are cached: the plain props-identity bailout (part 2) passes.
- 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.
- 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).
- 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).
- {visible.map(i =>
Server and client, costed
| Concern | Server Component | Client Component (SSR + hydration) | Client-only (no SSR) |
|---|---|---|---|
| Where it runs | Server (per request or build) | Server for HTML, then client | Client only |
| Data access | Direct (await in render) | Fetched client-side (or passed as props from a server parent) | Fetched client-side |
| Client bundle | None for the component or its deps | The component and its deps | The component and its deps |
| State, effects, handlers | None | All | All |
| Updates | A new payload request (navigation, revalidation, action) | Local re-render | Local re-render |
| First paint | In the shell or streamed; no client JS needed to show | HTML in the shell; interactive after hydration | After the bundle loads and renders |
| Hydration cost | None (plain elements; no component to run) | Runs the component to attach handlers | Not applicable (a full render) |
| Secrets and heavy libraries | Safe; never shipped | Shipped; must not include secrets | Shipped |
| Cost per request | Server CPU and data access per request (or cached) | Server CPU for HTML | None on the server |
| Best for | Content, layouts, data-heavy views, anything static per request | Interactive widgets that should appear immediately | Highly interactive, personalised, or offline-capable parts |
- 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.
- 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. - Keep secrets and data clients server-side with
server-onlyimports as a build-time guard. - 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.
- 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.