Hydration Basics: Understanding Client-Side Interactivity with React - Part 2
A deeper dive into the hydration process in React, exploring how it works under the hood and best practices for optimizing performance.
Part 2: Intermediate – How Hydration Works Under the Hood
Now that we know what hydration is and why we use it, let’s peek under the hood. React’s hydration process basically runs through a reconciliation step on mount. In a nutshell, here’s what happens:
The server sent HTML for your entire app. The browser has that in its DOM.
Your client JS calls ReactDOM.hydrate (or hydrateRoot in React 18).
React starts reconciling. It walks through the server-generated DOM and the virtual DOM tree of your app in parallel.
For each component, React checks if the existing DOM nodes match what that component would render. If they match, React keeps the nodes and just attaches event listeners. If they don’t match, React will warn and do a normal render for that part.
Once reconciliation is done, the app is “hydrated”: event handlers are attached, useEffect hooks run (except those that should skip the first mount), and the app behaves normally.
It’s like React is looking at the “dry” HTML and adding the “water” of interactivity. During hydration, React tries hard to reuse the existing DOM as much as possible. This is more efficient than throwing it all away. However, hydration is a bit slower than just rendering from scratch, because React must do the diff check against the existing DOM.
Let’s look at an example of a hydration flow. Say we have a React app with a counter:
// App.jsx
import React, { useState } from 'react';
function App() {
const [count, setCount] = useState(0);
return (
<div>
<h1>Count: {count}</h1>
<button onClick={() => setCount(count + 1)}>Add One</button>
</div>
);
}
export default App;
On the server, we render this to HTML:
import ReactDOMServer from 'react-dom/server';
const appHtml = ReactDOMServer.renderToString(<App />);
// Say appHtml is "<div><h1>Count: 0</h1><button>Add One</button></div>"
The browser receives that HTML
<div>
<h1>Count: 0</h1>
<button>Add One</button>
</div>
inside the #root div. The user sees “Count: 0” and the button, but it’s not clickable yet. Then, the client script runs and does:
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.hydrate(<App />, document.getElementById('root'));
Now React reconciles:
It sees Count: 0 in the DOM and that <h1> with text Count: 0 is exactly what <App> would produce (with count = 0). Good, keep it.
It sees <button>Add One</button> in the DOM and knows <App> expects a button with text “Add One”. That matches too.
React attaches the onClick handler to the button. Now clicking “Add One” will update the state and re-render (client-side).
If something didn’t match (say the server had <p> but the component wanted a <div>), React would warn and re-render that part.
Here’s a little diagram to illustrate the flow:
In code, a common setup for SSR and hydration might look like this:
// server.js (Node/Express)
import express from 'express';
import ReactDOMServer from 'react-dom/server';
import App from './App';
const app = express();
app.get('/', (req, res) => {
const html = ReactDOMServer.renderToString(<App />);
res.send(\`
<!DOCTYPE html>
<html><body>
<div id="root">\${html}</div>
<script src="/bundle.js"></script>
</body></html>
\`);
});
app.listen(3000);
// client.js (Browser entry)
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.hydrate(<App />, document.getElementById('root'));
In React 18+, you’d use:
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(document.getElementById('root'), <App />);
React 18’s hydrateRoot sets up a concurrent root, but the principle is the same: reuse server HTML, then hook it up to React.
A few notes about events during hydration:
Event listeners (like onClick) are not active until after hydration. That means clicking a button before React hydrates won’t do anything.
React tries to avoid re-rendering the existing DOM. This is why hydration is usually fast for matching content.
However, hydration itself has overhead (diffing the tree). Sometimes it can even be slightly slower on very large trees compared to a fresh render, but the user experience benefit is usually worth it.