The Fragile Art of Hydration: When React Renders Two Truths
Writing about my journey to become a React Developer
In the early days of the web, a page was just that, a page. A fixed document sent from the server to your browser. You asked, it delivered. It was simple, reliable, and lifeless.
Then came JavaScript, and with it, the dream of interactivity. Web apps began to render themselves. First with jQuery, then Backbone, then Angular, and finally React. But this came at a cost: blank screens before bootstraps, loading indicators before layouts.
Fast initial loads and rich interactivity were once competing goals. Server-rendered pages loaded quickly, but lacked the dynamic feel users expected.
Hydration emerged as the solution, allowing React to recognize the server-rendered HTML and quietly activate it with interactivity on the client side.
What Is Hydration, Really?
Hydration is how React connects two parts of your app: the server that sends complete HTML, and the browser that makes it interactive. It doesn’t rebuild everything from scratch, it takes over what’s already there. The HTML is already in the page; React just adds behavior like event handling and state, so the app starts working like a full React app.
In simple terms, hydration is when React wakes up the HTML sent from the server and adds interactivity without re-rendering it. This is done with a function called hydrateRoot() (added in React 18), which connects React to the pre-rendered content, but only if the HTML matches exactly.
If the server and client outputs don’t match, React won’t try to fix it; it will rerender the whole thing. This isn’t just about performance, it’s about consistency. Hydration is React’s way of saying both sides must agree on what the user sees, or the story will fall apart.
From hydrate() to hydrateRoot()
Before React 18, hydration was done using a simple function called hydrate(). It connected your React app to the HTML already on the page, without much extra logic. A typical setup looked like this:
import { hydrate } from ‘react-dom’;
hydrate(<App />, document.getElementById(‘root’));
But hydrate() had limits. It was made for older versions of React that rendered everything at once. It didn’t understand newer ideas like streaming HTML, Suspense, or loading only parts of the page when needed. As React grew to support more flexible, interruptible rendering, hydrate() started holding things back.
React 18 introduced hydrateRoot() to fix this. It’s built on React’s newer rendering engine, and it supports modern features like streaming, Suspense, and partial hydration. It’s not just a newer version, it’s a smarter, more flexible way to handle hydration in today’s React apps. Because of this, hydrate() is now considered deprecated and has been quietly replaced by hydrateRoot().
HydrateRoot() and the Pursuit of Consistency
In React 18, hydration got a big upgrade. The old hydrate() function was replaced by a newer one called hydrateRoot(). Here’s how it looks now:
import { hydrateRoot } from ‘react-dom/client’;
hydrateRoot(document.getElementById(‘root’), <App />);
But this change isn’t just about new code; it reflects a deeper shift in how React works. As React moved into a world of server components, streaming HTML, and concurrent rendering, it needed a better way to handle hydration. hydrateRoot() was made for that. It doesn’t try to do everything at once. It can hydrate slowly, as needed. It understands Suspense and knows how to update only parts of the page. It’s also better at handling mismatches between server and client.
Most importantly, hydrateRoot() fits with how modern React thinks. It sees the HTML from the server as a starting point, not the final answer. Its job is to help the browser continue the story that began on the server, smoothly, without guesswork.
Why Hydration Fails: A Break in Trust
Some people think hydration in React just means adding event listeners to HTML. But it’s more than that, it’s about picking up the app exactly where the server left off. React expects the HTML already on the page to match what it would have rendered on the client. Hydration is built on a quiet trust between server and client.
But sometimes, that trust breaks. Maybe Date.now() has changed since the page was rendered. Maybe the server sent bad HTML, or a user’s locale formats content differently. Even a browser extension can quietly inject something unexpected. When any of these happen, React sees a mismatch and throws an error:
“Hydration failed because the server-rendered HTML didn’t match the client.”
To React, this isn’t just a small glitch; it means the story can’t continue. The current DOM is thrown out, and React starts over from scratch.
Every hydration failure begins with how the browser builds the page. It follows a strict process: parsing HTML into a DOM tree, applying CSS, running scripts, computing layout, converting layout to pixels (rasterizing), and finally painting it on screen. React assumes this process was consistent across server and client.
But if you use unpredictable code like Math.random(), Date.now(), or conditional logic like typeof window !== ‘undefined’, the same code can lead to different outputs. That’s when React’s illusion of consistency breaks, and hydration fails.
Philosophical Consistency
Hydration isn’t just about speed, it’s about keeping the story consistent. React assumes your app tells one clear story, whether it’s shown by the server or the client. What users see when the page first loads isn’t just a temporary view , it’s meant to smoothly become the real, interactive app. That first version of the DOM isn’t a rough draft; it’s the final version your app meant to show.
When that smooth handoff fails, when what React expects doesn’t match what it sees, it’s more than just a bug. It breaks the trust between the server and the client. It shatters the idea that there’s one true version of your UI. That’s why hydration errors can feel so serious, they don’t just break the page, they break the story your app was trying to tell.
How to Hydrate Responsibly
To keep hydration working smoothly, stick to a few important rules. Use useEffect for things that should only run in the browser, don’t use window, localStorage, or Date.now() during render, because they can give different results each time. If a component can’t run on the server, turn off server-side rendering for it.
In Next.js, you can do this using dynamic(() => import(‘./ClientOnly’), { ssr: false }).
Be careful with hydration warnings. Only use suppressHydrationWarning if you really understand what it does; don’t hide warnings just to make them go away. Also, make sure your HTML is valid. Mistakes like putting a <div> inside a <p> won’t show errors at render time, but they can quietly break hydration.
Two Realities, One Reconciliation
Hydration isn’t a shortcut; it’s a quiet way of syncing the server’s static content with the browser’s live version. When hydration fails, it’s not just a bug; it’s a warning. It means your app told one version of the story on the server, and a different one on the client, and React, sticking to its rules, refused to pretend they were the same.
That’s the deeper meaning of hydration: the browser doesn’t just show your code, it works with it. So write carefully. Keep things in sync. Let the client continue the story the server started, don’t restart it. When you hydrate, feed the same roots, don’t plant a new tree.