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:
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:
┌─────────────────────────────────────────────────────────────┐│ 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.”
import { createRoot } from 'react-dom/client';import App from './App';
// This creates NEW DOM nodescreateRoot(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:
┌────────────────────────────────────────────────────────────────┐│ NORMAL CLIENT RENDER ││ ││ React Fiber Tree DOM Tree ││ ┌───────────┐ ┌───────────┐ ││ │ Fiber │ ──create──► │ <div> │ (new node) ││ │ stateNode │──────────────►│ │ ││ └───────────┘ └───────────┘ ││ │└────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────┐│ HYDRATION ││ ││ React Fiber Tree DOM Tree (already exists) ││ ┌───────────┐ ┌───────────┐ ││ │ Fiber │ ──reuse───► │ <div> │ (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:
- Server sends HTML with DOM structure
- Browser parses and displays it
hydrateRoot()is called- React walks the existing DOM tree
- For each DOM element, React creates a fiber node and sets
stateNodeto that existing element - 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
import { hydrateRoot } from 'react-dom/client';import App from './App';
// Hydrate reuses existing DOM, attaches event listenershydrateRoot(document.getElementById('root'), <App />);And on the server:
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:
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
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:
function UserProfile() { const [isMobile, setIsMobile] = useState(false);
useEffect(() => { setIsMobile(window.innerWidth < 768); }, []);
return <div>{isMobile ? 'Mobile' : 'Desktop'}</div>;}2. Date/Time Differences
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:
function TimeDisplay() { const [time, setTime] = useState(null);
useEffect(() => { setTime(new Date().toLocaleString()); }, []);
return <span>{time || 'Loading...'}</span>;}3. Auth State Mismatch
function Header() { const user = getCurrentUser(); // Different on server vs client! return <span>{user ? user.name : 'Guest'}</span>; // 💥 mismatch}Fix: Pass initial state from server:
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:
{ 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:
| Scenario | Use |
|---|---|
| Content-heavy site (blogs, docs) | SSR + Hydration |
| Interactive dashboard behind login | Client-side render |
| E-commerce (SEO + interactivity) | SSR + Hydration |
| Real-time app (chat, games) | Client-side render |
| Static marketing page | SSR only (no hydration needed!) |
Key Takeaways
- Hydration is DOM reuse: React attaches to existing DOM instead of recreating it
- stateNode is the bridge: Connects fiber tree to actual DOM elements
- Consistency is critical: Server and client must render identical content
- hydrateRoot, not createRoot: The right function for SSR apps
- 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:
- 👨💻 hydrateRoot - React Documentation
- 👨💻 renderToString - React Documentation
- 👨💻 React as a UI Runtime - Dan Abramov
- 👨💻 React 18 SSR Documentation
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments