Skip to content

React Advanced

v19.2

A React 19 tree can span two runtimes. Server Components render once, away from the browser, and send their output rather than their code; Client Components ship as JavaScript, are rendered to HTML on the server for the first paint, then hydrate. Suspense marks the places where output may arrive late, which is what makes streaming possible. Everything here is about that split — mutations and client-side transitions belong to whichever framework wires it up.

Server ComponentClient Component
Renders once, ahead of the browserRuns and re-renders in the browser
Can await data in the bodyHas state, effects, event handlers
Ships no JavaScriptShips its bundle and hydrates
No state, effects, or DOM APIsNo direct database or filesystem

A Server Component “renders ahead of time, before bundling, in an environment separate from your client app or SSR server” — at build time or per request — and its result, not its source, reaches the browser. Because they “do not re-render or hydrate”, they have no state, no effects and no browser APIs, and in exchange they may be async and await a query directly in the body. They need no directive: in a Server Component tree, server is the default.

declare const db: {
byUser(id: string): Promise<{ id: string }[]>;
};
async function Notes({ id }: { id: string }) {
const notes = await db.byUser(id);
return (
<ul>
{notes.map(n => <li key={n.id}>{n.id}</li>)}
</ul>
);
}

Gotcha: Server rendering and Server Components are different things. A Client Component is still rendered to HTML on the server for the first paint — then it hydrates and ships its JavaScript. Only a Server Component sends nothing to the browser at all.

Crosses the boundaryDoes not
Primitives, plain objects, DateClass instances
Arrays, Map, SetOrdinary functions
JSX elements and PromisesUnregistered symbols
Server FunctionsNull-prototype objects

'use client' at the very top of a file “marks the boundary between server and client code in the module dependency tree”: that module and everything it imports become client code, so the directive is declared once at the seam, never in every file. Props crossing the seam are serialized, which is the whole constraint — and since JSX elements and Promises both serialize, a Server Component can render its own output into a Client Component through children and keep it on the server.

"use client";
import { useState, type ReactNode } from "react";
type Props = { children: ReactNode };
export function Expander({ children }: Props) {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(!open)}>
More
</button>
{open && children}
</div>
);
}

Gotcha: An inline callback prop cannot cross the boundary — ordinary functions do not serialize, so onSelect={() => …} from a Server Component throws. Move the handler into the client module, or pass a Server Function instead.

StageWhat the browser gets
Server renderHTML: a fast, non-interactive paint
RSC payloadServer output plus client placeholders
HydrationHandlers attached to the existing DOM
MismatchReact re-renders on the client instead

hydrateRoot “attaches React to existing HTML that was already rendered by React on the server” — it adopts the DOM rather than building it, which is what makes the first paint cheap. The contract is exact: “the React tree you pass to hydrateRoot needs to produce the same output as it did on the server.” Differ, and you pay twice — once for HTML the client throws away, once to render it again.

import { hydrateRoot } from "react-dom/client";
declare const App: () => null;
const el = document.getElementById("root")!;
hydrateRoot(el, <App />);

Gotcha: The usual culprits are all “correct” code: Date.now(), Math.random(), typeof window !== "undefined" branches, and locale-dependent formatting — the server and the browser simply disagree. For one genuinely dynamic node, suppressHydrationWarning is the escape hatch; it works one level deep and patches nothing.

SuspendsDoes not suspend
use() on a pending promisefetch inside an Effect
lazy() componentsData set in an event handler
Data streamed from the serverAnything awaited outside render

<Suspense> shows its fallback until all code and data its children need have loaded. Children under one boundary are revealed together, so nesting boundaries is how a page is staged rather than gated: a coarse boundary around the shell, finer ones around the slow parts. During a transition React will not replace content that is already on screen with a fallback, which is the difference between a navigation and a flash of skeletons.

import { Suspense, type ReactNode } from "react";
declare function Profile(): ReactNode;
declare function Posts(): ReactNode;
export function Page() {
return (
<Suspense fallback={<p>Loading page…</p>}>
<Profile />
<Suspense fallback={<p>Loading posts…</p>}>
<Posts />
</Suspense>
</Suspense>
);
}

Gotcha: Suspense only reacts to reads that happen during render. A fetch in useEffect or an event handler never triggers a fallback no matter how many boundaries wrap it — that data needs its own loading state, or a move to use().

use()Hooks
Legal inside if and loopsTop level only
Reads a Promise or a ContextContext only, via useContext
Suspends until the value is readyNever suspend

use reads a promise or a context, and is the one React API exempt from the rules of hooks: it may be called conditionally. Reading a pending promise suspends the component, so it wants a Suspense boundary above it and an error boundary for rejection — the docs are explicit that try/catch around use does not work. The intended flow is a Server Component starting the request and passing the promise down, so the server begins work the client finishes awaiting.

"use client";
import { use } from "react";
export function Message(
{ promise }: { promise: Promise<string> },
) {
return <p>{use(promise)}</p>;
}

Gotcha: use(fetch(url)) in a Client Component creates a new promise on every render, so the component suspends forever and re-fetches endlessly. Client-side promises must come from a cache keyed by input, or from a Server Component’s props.

PieceRole
The shellEverything outside every boundary
A boundaryStreams its HTML once its data resolves
Inline scriptSwaps each fallback for real content
allReadyWait for the whole page, for crawlers

Streaming “allows the user to start seeing the content even before all the data has loaded on the server”: the shell is flushed first, then each boundary’s HTML arrives out of order as its data resolves, carrying a script that replaces the fallback. The reveal “does not need to wait for React itself to load in the browser”, and selective hydration then makes the arrived parts interactive without waiting for the rest. React 19.2 batches those reveals briefly so content lands in groups rather than one row at a time.

import { renderToReadableStream }
from "react-dom/server";
declare const App: () => null;
const stream = await renderToReadableStream(
<App />,
{ bootstrapScripts: ["/main.js"] },
);
// Crawlers need the finished document:
// if (isCrawler) await stream.allReady;

Tip: The shell is everything outside every boundary, so it is also everything the user waits for. Push slow data deeper — one boundary near the leaf that needs it — and the page paints sooner without changing a single fetch.