| |

React 109 ⚛️ Progressive and Selective Hydration

⏱️ Estimated read: 11 min | |

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

APIPurpose
renderToPipeableStreamNode.js streaming SSR
renderToReadableStreamWeb streams streaming SSR
hydrateRootClient hydration with selective support
<Suspense fallback={...}>Independent hydration boundary
lazy(() => import(...))Code-split component

Hydration Priority

Component PositionHydration Order
Outside <Suspense>With the parent shell
Inside <Suspense>When its code/data is ready
User interacts with non-hydratedPrioritized immediately
Below the foldLast

Two-Pass Pattern

RenderisClientOutput
ServerfalseFallback
Client hydrationfalseFallback (matches server)
After effecttrueReal 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

PitfallWhy It HappensFix
No selective hydrationReactDOM.hydrate usedhydrateRoot
Entire app hydrates as one unitOne Suspense boundary at rootMultiple boundaries
Interaction not prioritizedComponent not inside SuspenseWrap in Suspense
Hydration mismatch from browser APIRead during renderTwo-pass rendering
Slow data fetch blocks hydrationComponent in a boundary that waits on dataKeep 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

ItemValue
Streaming SSRrenderToPipeableStream / renderToReadableStream
Selective hydration APIhydrateRoot
Boundary unit<Suspense>
Interaction priorityReact hydrates the interacted component first
Client-only contentTwo-pass rendering with useEffect
Code splittinglazy() inside <Suspense>
ShellContent outside Suspense boundaries
Critical componentsKeep 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, hydrateRoot hydrates 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.
  • hydrateRoot is required. The older ReactDOM.hydrate API does not support selective hydration. The migration to hydrateRoot is 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.

Was this helpful?


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!