React 109 ⚛️ Progressive and Selective Hydration
Hydration has historically been an all-or-nothing operation. The server sends HTML, the browser parses it, and React attaches event handlers to the entire tree before any part of the page becomes interactive. In a large application, the JavaScript bundle can be hundreds of kilobytes, and the main thread can be blocked for hundreds of milliseconds while React walks the tree. The page looks ready but is not interactive, and every user click during that window is either ignored or queued. React 18 introduced two related mechanisms that change this: streaming SSR, which sends HTML progressively as components resolve, and selective hydration, which allows React to hydrate parts of the tree independently and to prioritize the parts the user interacts with.
This chapter covers the streaming server API that produces the HTML, the <Suspense> boundary that defines the units of independent hydration, the interaction-based prioritization that determines which unit hydrates next, and the two-pass rendering pattern that defers browser-only content until after hydration.
Key point: Selective hydration is not a separate API. It is a behavior that emerges from wrapping components in <Suspense> and using hydrateRoot. The boundaries define the units of independent hydration, and React’s scheduler decides which unit to hydrate next based on what the user is trying to interact with.
Why hydration needs to be selective
The bundle-size problem. A page with a heavy chart component, a comment editor, and a product list may have a JavaScript bundle where the chart is 200 kB, the editor is 150 kB, and the list is 20 kB. Traditional hydration waits for all 370 kB to load before any of them becomes interactive. If the user wants to click a button in the product list, they wait for the chart and the editor even though they are not using them.
The blocking problem. Even after the bundle loads, React’s hydration walks the entire tree synchronously. A deeply nested tree or a component with expensive initialization can block the main thread for hundreds of milliseconds. During that window, the page is still non-interactive. Selective hydration breaks the walk into segments that can be interrupted and resumed.
The streaming problem. Traditional SSR requires the server to wait for all data before sending the first byte of HTML. If one component fetches from a slow API, the entire page’s first paint is delayed. Streaming SSR sends the shell immediately and streams the slow component’s HTML when its data resolves. Selective hydration is the client-side counterpart: it allows the already-streamed parts to become interactive before the slow parts have even arrived.
a. Streaming SSR with renderToPipeableStream
The server-side prerequisite for selective hydration is streaming. renderToPipeableStream (Node.js) and renderToReadableStream (web streams) send HTML as it becomes ready. The shell—everything outside <Suspense> boundaries—is sent immediately, and each suspended boundary streams its fallback first, then the real content when the data resolves.
// server.js
import { renderToPipeableStream } from 'react-dom/server'
import App from './App'
app.get('/', (req, res) => {
const { pipe } = renderToPipeableStream(<App />, {
onShellReady() {
res.setHeader('Content-Type', 'text/html')
pipe(res)
},
onError(err) {
console.error(err)
},
})
})
The onShellReady callback fires when the shell is ready to be sent. The pipe(res) call streams the HTML to the response. Suspended components are sent as inline <script> tags that swap the fallback for the real content when their data resolves.
The client-side counterpart is hydrateRoot, which replaces the older ReactDOM.hydrate. The hydrateRoot API is what enables selective hydration; the older API does not support it.
// client.js
import { hydrateRoot } from 'react-dom/client'
import App from './App'
hydrateRoot(document.getElementById('root'), <App />)
b. Selective Hydration with Suspense Boundaries
The <Suspense> boundary is the unit of independent hydration. Each boundary hydrates when its code and data are ready, without waiting for its siblings. A boundary that is not wrapped in <Suspense> hydrates as part of the parent boundary’s hydration.
function App() {
return (
<div>
{/* Critical, small: hydrates first */}
<Header />
<AddToCartButton productId={productId} />
{/* Medium priority: hydrates when its code loads */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={productId} />
</Suspense>
{/* Low priority: hydrates last */}
<Suspense fallback={<FooterSkeleton />}>
<Footer />
</Suspense>
</div>
)
}
The Reviews component is likely a large bundle and may fetch data. Wrapping it in <Suspense> tells React to send the ReviewsSkeleton immediately and to hydrate the Header and AddToCartButton without waiting for Reviews. When the user interacts with Reviews—clicking the reviews tab, for example—React detects the interaction on a non-hydrated component and prioritizes hydrating that component first.
This interaction-based prioritization is the feature that distinguishes selective hydration from simple code splitting. The boundaries define what can hydrate independently, and the user’s interaction determines what should hydrate next. React maintains a record of which components are waiting to hydrate, and when an event fires on one of them, it flushes that component’s hydration before continuing with the rest of the tree.
c. Two-Pass Rendering for Client-Only Content
Selective hydration does not solve the problem of content that genuinely cannot be rendered on the server. A component that reads window.matchMedia, localStorage, or a browser-only library will produce different output on the server and the client, which causes a hydration mismatch. The two-pass rendering pattern is the fix.
function ThemeToggle() {
const [theme, setTheme] = useState(null)
useEffect(() => {
setTheme(localStorage.getItem('theme') ?? 'light')
}, [])
// First render (server + client hydration): theme is null
// Server and client produce the same output
if (theme === null) {
return <div className="theme-placeholder" />
}
return <button onClick={() => setTheme('dark')}>{theme}</button>
}
The first render on the client produces the same output as the server render, so hydration succeeds. The useEffect fires after hydration, reads the browser-only value, and triggers a second render with the real content. The cost is a second render and a brief flash, but the hydration is correct.
The ClientOnly wrapper is the reusable form of this pattern. It renders its children only after the effect has fired, which means the children are never part of the server render or the hydration comparison.
function ClientOnly({ children, fallback = null }) {
const [isClient, setIsClient] = useState(false)
useEffect(() => {
setIsClient(true)
}, [])
return isClient ? children : fallback
}
Complete Example Session
// ============================================
// PART 1: SERVER — STREAMING SSR
// ============================================
import { renderToPipeableStream } from 'react-dom/server'
app.get('/', (req, res) => {
const { pipe } = renderToPipeableStream(<App />, {
onShellReady() {
res.setHeader('Content-Type', 'text/html')
pipe(res)
},
})
})
// ============================================
// PART 2: CLIENT — HYDRATE ROOT
// ============================================
import { hydrateRoot } from 'react-dom/client'
hydrateRoot(document.getElementById('root'), <App />)
// ============================================
// PART 3: APP — SUSPENSE BOUNDARIES
// ============================================
function App() {
return (
<div>
<Header />
<AddToCartButton />
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews />
</Suspense>
<Suspense fallback={<FooterSkeleton />}>
<Footer />
</Suspense>
</div>
)
}
// ============================================
// PART 4: INTERACTION PRIORITIZATION
// ============================================
// User clicks on Reviews before it has hydrated
// React detects the interaction on the non-hydrated component
// React prioritizes hydrating Reviews first
// The user's click is processed as soon as hydration completes
// ============================================
// PART 5: TWO-PASS FOR CLIENT-ONLY
// ============================================
function ClientOnly({ children, fallback = null }) {
const [isClient, setIsClient] = useState(false)
useEffect(() => {
setIsClient(true)
}, [])
return isClient ? children : fallback
}
// Usage
<ClientOnly fallback={<ChartSkeleton />}>
<InteractiveChart />
</ClientOnly>
The five parts trace the server stream, the client hydration, the boundary structure, the interaction prioritization, and the client-only fallback.
Quick Reference
APIs
| API | Purpose |
|---|---|
renderToPipeableStream | Node.js streaming SSR |
renderToReadableStream | Web streams streaming SSR |
hydrateRoot | Client hydration with selective support |
<Suspense fallback={...}> | Independent hydration boundary |
lazy(() => import(...)) | Code-split component |
Hydration Priority
| Component Position | Hydration Order |
|---|---|
Outside <Suspense> | With the parent shell |
Inside <Suspense> | When its code/data is ready |
| User interacts with non-hydrated | Prioritized immediately |
| Below the fold | Last |
Two-Pass Pattern
| Render | isClient | Output |
|---|---|---|
| Server | false | Fallback |
| Client hydration | false | Fallback (matches server) |
| After effect | true | Real content |
Best Practices
✅ Do This:
// Wrap independent sections in Suspense
<Suspense fallback={<Skeleton />}><HeavyComponent /></Suspense> // ✅
// Use hydrateRoot, not ReactDOM.hydrate
hydrateRoot(root, <App />) // ✅
// Use two-pass rendering for browser-only content
const [isClient, setIsClient] = useState(false) // ✅
❌ Don’t Do This:
// Wrap the entire app in one Suspense boundary
<Suspense fallback={<Spinner />}><App /></Suspense> // no granularity // ❌
// Use ReactDOM.hydrate (React 17 API)
ReactDOM.hydrate(<App />, root) // no selective hydration // ❌
// Read browser-only APIs during render
const theme = localStorage.getItem('theme') // hydration mismatch // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| No selective hydration | ReactDOM.hydrate used | hydrateRoot |
| Entire app hydrates as one unit | One Suspense boundary at root | Multiple boundaries |
| Interaction not prioritized | Component not inside Suspense | Wrap in Suspense |
| Hydration mismatch from browser API | Read during render | Two-pass rendering |
| Slow data fetch blocks hydration | Component in a boundary that waits on data | Keep critical components outside slow boundaries |
Real-World Examples
1. Streaming Server
renderToPipeableStream(<App />, { onShellReady() { pipe(res) } })
2. Client Hydration
hydrateRoot(document.getElementById('root'), <App />)
3. Suspense Boundary
<Suspense fallback={<Skeleton />}><HeavyComponent /></Suspense>
4. Multiple Boundaries
<Suspense fallback={<A />}><CompA /></Suspense>
<Suspense fallback={<B />}><CompB /></Suspense>
5. ClientOnly Wrapper
<ClientOnly fallback={<ChartSkeleton />}><InteractiveChart /></ClientOnly>
6. Two-Pass Theme
const [theme, setTheme] = useState(null)
useEffect(() => setTheme(localStorage.getItem('theme')), [])
7. Lazy Component
const Chart = lazy(() => import('./Chart'))
8. Interaction Priority
User clicks non-hydrated button → React hydrates that component first
9. Boundary Granularity
One boundary per independent section → maximum benefit
10. Critical Component Outside Boundary
<AddToCartButton /> {/* outside Suspense — hydrates with shell */}
Visual
The Hydration Timeline
┌──────────────────────────────────────────────────────────────┐
│ TRADITIONAL HYDRATION │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ Load entire bundle → hydrate entire tree → interactive ││
│ └─────────────────────────────────────────────────────────┘│
│ │
│ SELECTIVE HYDRATION │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ Load shell → hydrate shell → Header interactive ││
│ │ Load Reviews → hydrate Reviews → Reviews interactive ││
│ │ Load Footer → hydrate Footer → Footer interactive ││
│ │ ││
│ │ User clicks Reviews before its hydration ││
│ │ → React prioritizes Reviews ││
│ └─────────────────────────────────────────────────────────┘│
│ │
│ The page becomes interactive in pieces. │
│ │
└──────────────────────────────────────────────────────────────┘
Boundary Granularity
┌──────────────────────────────────────────────────────────────┐
│ ❌ ONE BOUNDARY ✅ MULTIPLE BOUNDARIES │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ <Suspense> │ │ <Header /> │ │
│ │ <Header /> │ │ <AddToCart /> │ │
│ │ <Reviews /> │ │ <Suspense> │ │
│ │ <Footer /> │ │ <Reviews /> │ │
│ │ </Suspense> │ │ </Suspense> │ │
│ └─────────────────┘ │ <Suspense> │ │
│ │ <Footer /> │ │
│ All hydrate together. │ </Suspense> │ │
│ └─────────────────┘ │
│ Each hydrates when ready. │
│ │
└──────────────────────────────────────────────────────────────┘
Two-Pass Rendering
┌──────────────────────────────────────────────────────────────┐
│ SERVER RENDER │
│ isClient = false → fallback rendered │
│ │ │
│ ▼ │
│ CLIENT HYDRATION │
│ isClient = false → fallback rendered (matches server) │
│ │ │
│ ▼ │
│ useEffect fires │
│ │ │
│ ▼ │
│ setIsClient(true) │
│ │ │
│ ▼ │
│ SECOND RENDER │
│ isClient = true → real content rendered │
│ │
│ No mismatch. The first render is the contract. │
│ │
└──────────────────────────────────────────────────────────────┘
Interaction Prioritization
┌──────────────────────────────────────────────────────────────┐
│ PENDING HYDRATION QUEUE │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ Reviews (not hydrated yet) ││
│ │ Footer (not hydrated yet) ││
│ └─────────────────────────────────────────────────────────┘│
│ │
│ User clicks on Reviews │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ React moves Reviews to the front of the queue ││
│ │ Hydrates Reviews first ││
│ │ Processes the click ││
│ └─────────────────────────────────────────────────────────┘│
│ │
│ The user's interaction determines what hydrates next. │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Streaming SSR | renderToPipeableStream / renderToReadableStream |
| Selective hydration API | hydrateRoot |
| Boundary unit | <Suspense> |
| Interaction priority | React hydrates the interacted component first |
| Client-only content | Two-pass rendering with useEffect |
| Code splitting | lazy() inside <Suspense> |
| Shell | Content outside Suspense boundaries |
| Critical components | Keep outside slow boundaries |
Key takeaways:
- Selective hydration requires streaming SSR and Suspense boundaries. The server streams the shell immediately and sends suspended content as it resolves. On the client,
hydrateRoothydrates each Suspense boundary independently as its code and data become available. - User interactions prioritize hydration. When a user clicks on a component that has not yet hydrated, React detects the event and hydrates that component before continuing with the rest of the tree. This is the feature that makes the page feel responsive even when the full hydration has not completed.
- The boundary granularity determines the benefit. A single Suspense boundary at the root provides no selective hydration; it hydrates as one unit. Multiple boundaries around independent sections allow React to prioritize the parts that matter. The critical interactive components should not be inside a boundary that depends on a slow data fetch.
- Two-pass rendering is the fix for browser-only content. The component renders a fallback on the server and during the first client render, then updates to the real content in an effect. This avoids the hydration mismatch without
suppressHydrationWarning. hydrateRootis required. The olderReactDOM.hydrateAPI does not support selective hydration. The migration tohydrateRootis the prerequisite for the React 18 behavior.
Remember: Selective hydration is not a single feature that you enable with a flag. It is a consequence of three things: streaming SSR, Suspense boundaries, and hydrateRoot. The server streams the HTML in pieces, the boundaries define the pieces that can hydrate independently, and hydrateRoot hydrates them in the order that React’s scheduler determines, with user interactions taking priority. The pattern is to place boundaries around independent sections, keep the critical interactive components outside slow boundaries, and use two-pass rendering for content that cannot be rendered on the server. When these three pieces are in place, the page becomes interactive in pieces rather than all at once, and the pieces the user cares about become interactive first.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!