Skip to content

What is React Hydration and Why Does It Exist?

I was setting up server-side rendering (SSR) for a React app when I hit a confusing error:

Browser Console
Warning: Expected server HTML to contain a matching <div> in <div>.

What? The HTML looked fine. The component was simple. Why was React complaining?

Turns out, I didn’t understand what hydration actually does. Let me walk you through what I learned.

The Problem: Static HTML Can’t Click

I thought SSR was simple: server renders HTML, browser shows it, job done.

But then I realized my buttons weren’t working. My onClick handlers? Dead. My form validation? Nothing. The page looked right, but it was just a static skeleton.

Here’s what was happening:

SSR Flow (Simplified)
┌─────────────────────────────────────────────────────────────┐
│ SERVER │
│ ┌─────────────────┐ │
│ │ renderToString()│ ──► <div>Hello World</div> │
│ └─────────────────┘ (just text, no behavior) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ BROWSER │
│ ┌─────────────────┐ │
│ │ Display HTML │ ──► User sees content │
│ └─────────────────┘ But clicking does nothing │
└─────────────────────────────────────────────────────────────┘

The server rendered HTML, but React wasn’t “attached” to it yet. The JavaScript bundle needed to load and take over.

My First Attempt: Just Render Again

I thought, “Fine, I’ll just have React render the page again on the client.”

client.js (WRONG APPROACH)
import { createRoot } from 'react-dom/client';
import App from './App';
// This creates NEW DOM nodes
createRoot(document.getElementById('root')).render(<App />);

This worked… kind of. But I noticed a flash. The content briefly disappeared, then reappeared. Layout jumped around. Users saw a glitchy experience.

What happened? React was recreating all the DOM nodes that already existed from the server. Total waste of work.

The Realization: Why Can’t We Reuse DOM Nodes?

This is where things got interesting. I asked myself:

Why throw away perfectly good DOM nodes and rebuild them?

The server already built the DOM. The browser already parsed it. Why not just… use what’s already there?

That’s exactly what hydration does.

How Hydration Actually Works

Let me show you what happens under the hood:

Hydration vs Normal Render
┌────────────────────────────────────────────────────────────────┐
│ NORMAL CLIENT RENDER │
│ │
│ React Fiber Tree DOM Tree │
│ ┌───────────┐ ┌───────────┐ │
│ │ Fiber │ ──create──► │ &lt;div&gt; │ (new node) │
│ │ stateNode │──────────────►│ │ │
│ └───────────┘ └───────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────┐
│ HYDRATION │
│ │
│ React Fiber Tree DOM Tree (already exists) │
│ ┌───────────┐ ┌───────────┐ │
│ │ Fiber │ ──reuse───► │ &lt;div&gt; │ (existing node) │
│ │ stateNode │──────────────►│ │ │
│ └───────────┘ └───────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘

The key is a property called stateNode. Every fiber node (React’s internal representation of a component) has a stateNode that points to the actual DOM element.

During hydration:

  1. Server sends HTML with DOM structure
  2. Browser parses and displays it
  3. hydrateRoot() is called
  4. React walks the existing DOM tree
  5. For each DOM element, React creates a fiber node and sets stateNode to that existing element
  6. React attaches event listeners to those existing elements

No DOM recreation. No layout thrashing. Just “waking up” the existing HTML.

The Code That Fixed Everything

client.js (CORRECT APPROACH)
import { hydrateRoot } from 'react-dom/client';
import App from './App';
// Hydrate reuses existing DOM, attaches event listeners
hydrateRoot(document.getElementById('root'), <App />);

And on the server:

server.js
import { renderToString } from 'react-dom/server';
import App from './App';
app.get('/', (req, res) => {
const html = renderToString(<App />);
res.send(`
<!DOCTYPE html>
<html>
<body>
<div id="root">${html}</div>
<script src="/client.js"></script>
</body>
</html>
`);
});

The key difference: hydrateRoot instead of createRoot. One function tells React to reuse existing DOM instead of creating new ones.

Why I Got That Hydration Error

Back to my original error:

Browser Console
Warning: Expected server HTML to contain a matching <div> in <div>.

This happens when server and client render different content. React walks the DOM expecting certain elements, but finds something else.

Common causes I ran into:

1. Browser-Only Code During SSR

Problematic Code
function UserProfile() {
// Server doesn't have window.innerWidth!
const isMobile = window.innerWidth < 768; // 💥 crashes on server
return <div>{isMobile ? 'Mobile' : 'Desktop'}</div>;
}

Fix: Use useEffect for browser-only code:

Fixed Code
function UserProfile() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
setIsMobile(window.innerWidth < 768);
}, []);
return <div>{isMobile ? 'Mobile' : 'Desktop'}</div>;
}

2. Date/Time Differences

Problematic Code
function TimeDisplay() {
// Server and client run at different times!
return <span>{new Date().toLocaleString()}</span>; // 💥 mismatch
}

Fix: Either render consistent timestamp or skip during SSR:

Fixed Code
function TimeDisplay() {
const [time, setTime] = useState(null);
useEffect(() => {
setTime(new Date().toLocaleString());
}, []);
return <span>{time || 'Loading...'}</span>;
}

3. Auth State Mismatch

Problematic Code
function Header() {
const user = getCurrentUser(); // Different on server vs client!
return <span>{user ? user.name : 'Guest'}</span>; // 💥 mismatch
}

Fix: Pass initial state from server:

Fixed Code
function Header({ initialUser }) {
const [user] = useState(initialUser); // Consistent initial state
return <span>{user ? user.name : 'Guest'}</span>;
}

The Fiber Tree: Why This Architecture Matters

I didn’t truly understand hydration until I learned about React’s fiber architecture.

Every React component corresponds to a “fiber” node. A simplified view:

Fiber Node Structure (Simplified)
{
type: 'div', // What element is this?
props: { ... }, // Props passed to element
stateNode: null, // 🔑 The DOM element (this is the key!)
child: Fiber, // First child
sibling: Fiber, // Next sibling
return: Fiber, // Parent
}

The stateNode property is the bridge. During normal rendering, React creates a DOM element and assigns it to stateNode. During hydration, React finds an existing DOM element and assigns it to stateNode.

Same result. Very different path to get there.

When to Use Hydration vs Client-Side Render

I learned to pick the right tool:

ScenarioUse
Content-heavy site (blogs, docs)SSR + Hydration
Interactive dashboard behind loginClient-side render
E-commerce (SEO + interactivity)SSR + Hydration
Real-time app (chat, games)Client-side render
Static marketing pageSSR only (no hydration needed!)

Key Takeaways

  1. Hydration is DOM reuse: React attaches to existing DOM instead of recreating it
  2. stateNode is the bridge: Connects fiber tree to actual DOM elements
  3. Consistency is critical: Server and client must render identical content
  4. hydrateRoot, not createRoot: The right function for SSR apps
  5. Know when to skip it: Not every app needs SSR + hydration

React hydration exists because throwing away perfectly good DOM is wasteful. By reusing what the server built, we get the best of both worlds: fast initial load from SSR, and rich interactivity from client-side React.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments