Skip to content
React rc advanced 4 min read

Hydration

When you render React on the server, the browser receives ready-to-paint HTML — but that markup is inert. Hydration is the process where React, running on the client, walks the existing DOM, reconstructs the component tree in memory, and wires up event listeners and state so the page becomes interactive. Done well, hydration gives you fast first paint plus a fully reactive app; done carelessly, it produces hydration mismatches that corrupt the UI. This page explains how hydration works and how to keep it deterministic.

How hydration works

A server-rendered app uses renderToString (or renderToPipeableStream) to produce HTML. On the client you call hydrateRoot instead of createRoot. The crucial difference: createRoot throws away whatever is in the container and renders fresh, while hydrateRoot adopts the existing DOM nodes and only attaches behavior to them.

// entry-client.jsx
import { hydrateRoot } from 'react-dom/client';
import App from './App.jsx';

hydrateRoot(document.getElementById('root'), <App />);
// entry-server.jsx
import { renderToString } from 'react-dom/server';
import App from './App.jsx';

export function render() {
  return renderToString(<App />);
}

During hydration React renders your components, but instead of creating new DOM it matches each element against the server-produced node in the same position. If the trees line up, React simply binds the onClick, onChange, and other handlers and the page is live. No new markup is generated, which is what makes hydration much cheaper than a full client render.

Hydration mismatches

A mismatch happens when the HTML React would generate on the client differs from the HTML the server sent. React expects the two passes to be identical, so any divergence forces it to discard the server markup for that subtree and re-render on the client — and it logs a warning.

The most common causes are non-deterministic values:

// BAD — server and client compute different values
function Greeting() {
  return (
    <div>
      <p>Random key: {Math.random()}</p>
      <p>Rendered at: {new Date().toLocaleTimeString()}</p>
    </div>
  );
}

Output:

Warning: Text content did not match. Server: "Random key: 0.4821"
Client: "Random key: 0.9037"
CauseWhy it breaksFix
Math.random() / crypto.randomUUID()Different value per renderCompute once with useId or a stable seed
Date.now() / new Date()Server and client clocks differRender after mount, or pass a fixed timestamp
toLocaleString / time zonesServer locale != browser localeFormat client-side in useEffect
window / localStorage readsUndefined on the serverGate behind useEffect
Invalid HTML nestingBrowser repairs DOM before hydrationFix the markup (no <div> inside <p>)

The standard pattern for genuinely client-only values is to render a neutral placeholder during SSR and the real value after mount, so both passes agree initially:

import { useState, useEffect } from 'react';

function Clock() {
  const [time, setTime] = useState(null);

  useEffect(() => {
    setTime(new Date().toLocaleTimeString());
  }, []);

  // Server and first client render both produce the placeholder
  return <p>{time ?? 'Loading time…'}</p>;
}

For stable unique IDs that must match across server and client, use the useId hook rather than a random generator:

import { useId } from 'react';

function Field({ label }) {
  const id = useId(); // deterministic, identical on both sides
  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} />
    </>
  );
}

Suppressing intentional mismatches

Sometimes a difference is unavoidable and harmless — a timestamp that you want to differ. For a single element you can set suppressHydrationWarning to silence the warning for that node’s text or attributes. It applies only one level deep and should be used sparingly.

function LastBuilt({ isoDate }) {
  return (
    <time suppressHydrationWarning dateTime={isoDate}>
      {new Date(isoDate).toLocaleString()}
    </time>
  );
}

Warning: suppressHydrationWarning does not make hydration correct — it just hides the message. React still uses the server text for the initial paint and updates it on the next render. Never use it to paper over structural mismatches like mismatched element types.

Selective and streaming hydration

React 18 introduced streaming SSR with renderToPipeableStream plus <Suspense>, which unlocks two behaviors that older renderToString could not offer:

  • Streaming HTML — the server flushes markup as it becomes ready instead of waiting for the entire page. Content inside a <Suspense> boundary is sent later as it resolves.
  • Selective hydration — React hydrates Suspense boundaries independently and out of order. It prioritizes boundaries the user interacts with, so a click on a not-yet-hydrated region jumps that region to the front of the queue.
import { Suspense } from 'react';

function Page() {
  return (
    <main>
      <Header />
      <Suspense fallback={<Spinner />}>
        {/* Streams in and hydrates on its own schedule */}
        <Comments />
      </Suspense>
    </main>
  );
}

Because each boundary hydrates separately, a slow or heavy component no longer blocks interactivity for the rest of the page. This is also the foundation that React Server Components build on.

Best practices

  • Use hydrateRoot for SSR/SSG output and createRoot only for purely client-rendered apps — mixing them up causes full re-renders.
  • Keep the first render deterministic: no Math.random, Date, locale formatting, or window access during initial render.
  • Reach for useId for stable IDs and defer client-only values to useEffect.
  • Use suppressHydrationWarning only for known, single-node text differences — never for structural ones.
  • Wrap independent, slow regions in <Suspense> to enable streaming and selective hydration.
  • Validate your HTML nesting; browser auto-correction is a frequent hidden source of mismatches.
Last updated June 14, 2026
Was this helpful?