
When Your React App Renders Twice and No One Knows Why: Understanding Hydration
Ever built a React app that seems to flicker on load, or worse, throws hydration errors that make no sense? You're not alone. I've been down the React hydration rabbit hole, and it's a critical concept for anyone building performant and stable server-rendered React applications.
by Sunil Band
The Silent Killer: When Your App Renders Twice
You've probably seen it. That subtle flicker, a momentary layout shift, or even an inexplicable error message about Expected server HTML to contain a matching <tag>. You build a server-rendered React app, everything looks fine in development, but in production, things get weird. The culprit? Often, it's a misunderstanding of hydration.
For a long time, server-side rendering (SSR) in React felt like magic. You write your components, call renderToString or renderToStaticMarkup, and boom, HTML on the server. The browser gets a fast initial paint, and then React 'takes over'. But this 'taking over' isn't a simple swap; it's a delicate process called hydration, where React attempts to attach event listeners and component state to the existing server-generated HTML.
The core problem arises when the server-rendered HTML and the client-rendered virtual DOM don't match. React expects a perfect alignment. If it finds a discrepancy – a missing attribute, an extra node, or different text content – it can't just fix it. It has to re-render the entire subtree from the point of mismatch, sometimes even the entire app. This leads to the dreaded double render, performance penalties, and those cryptic hydration errors.
The Hydration Process: What's Actually Happening?
Let's break down what React does during hydration. When your browser receives the initial HTML from the server, it's just static content. The user sees something immediately, which is great for perceived performance and SEO. Simultaneously, your client-side JavaScript bundle loads and executes.
Once the client-side React code runs, it doesn't re-render everything from scratch. Instead, it tries to "hydrate" the existing HTML. This involves two main steps: reconciling the DOM tree and attaching event handlers. React traverses the server-generated DOM, building its internal virtual DOM representation, and comparing it with what it would have rendered if it were rendering for the first time on the client.
If the two trees match, React simply attaches event listeners and recovers any state. If they don't match, React throws a hydration error and, depending on the severity and React version, might discard the server-rendered HTML and re-render everything from the client, leading to a visible flash or a broken interaction. This is why a mismatch is so costly.
import React from 'react';
import ReactDOM from 'react-dom/client';
function App() {
// Imagine this value might be different on server vs. client
// e.g., based on browser-specific APIs or client-side only data.
const isClient = typeof window !== 'undefined';
const greeting = isClient ? 'Hello from client!' : 'Hello from server!';
return (
<div>
<h1>{greeting}</h1>
{/* This element will cause a hydration warning if isClient differs */}
{isClient && <p>This paragraph only renders on the client.</p>}
<button onClick={() => alert('Clicked!')}>Click Me</button>
</div>
);
}
// On the server, you'd typically use renderToString or renderToPipeableStream
// const html = ReactDOMServer.renderToString(<App />);
// On the client, we use hydrateRoot to attach React to the server-generated HTML.
// It expects the HTML structure to match what renderToString produced.
if (typeof document !== 'undefined') {
const rootElement = document.getElementById('root');
if (rootElement) {
// hydrateRoot is the entry point for hydrating a server-rendered app.
// It expects a pre-rendered DOM element to attach to.
ReactDOM.hydrateRoot(
rootElement,
<React.StrictMode>
<App />
</React.StrictMode>
);
}
}
// What causes mismatches?
// 1. Client-side only code rendering elements (like the 'isClient && <p>' example).
// 2. Different data fetched on server vs. client (e.g., race conditions, dynamic content).
// 3. Time-sensitive values (timestamps) rendered without careful handling.
// 4. Using APIs like localStorage or window in render that aren't available on server.
// 5. Incorrect HTML structure (e.g., missing <tbody> in a table, invalid nesting).In the example above, if isClient evaluates differently on the server (which it will, it's false on the server and true on the client), then the <p> tag will be missing in the server HTML but present in the client's virtual DOM. React will yell at you, and likely re-render the div containing the p tag.
Common Pitfalls and How to Avoid Them
Understanding common causes of hydration mismatches is key to preventing them. Here are the big ones I've run into:
1. Client-Only Code in Render
This is perhaps the most frequent offender. If you have components or parts of your JSX that rely on browser-specific APIs (like window, document, localStorage) or client-side specific state during the initial render, you're asking for trouble. The server has no window object, so any code path that renders different output based on its presence will diverge.
Solution: Guard client-only rendering with a useEffect hook or a flag that's only set after hydration. Use a pattern like this:
import React, { useState, useEffect } from 'react';
function ClientOnlyContent() {
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
}, []);
if (!hasMounted) {
// Render a placeholder or nothing on the server and during initial client render
return null;
}
// This content will only render after hydration on the client
return <p>This content is client-side only! Width: {window.innerWidth}px</p>;
}
// Usage:
// function App() {
// return (
// <div>
// <h1>My App</h1>
// <ClientOnlyContent />
// </div>
// );
// }This hasMounted pattern ensures that ClientOnlyContent only renders its actual content after the initial client-side mount, avoiding any mismatch with the server-rendered HTML.
2. Time-Sensitive Data and Random IDs
Rendering new Date() directly in your component can lead to mismatches if the server and client render happen at slightly different milliseconds (which they almost always will). Similarly, generating random IDs (e.g., for id attributes or key props) without a stable seed or a useEffect can cause divergence.
Solution: For dates, render a static placeholder on the server or format the date on the client after hydration. For IDs, use a stable ID generation library like useId (React 18+) or ensure your random IDs are generated consistently, perhaps by passing a seed from the server if absolutely necessary.
import React, { useState, useEffect, useId } from 'react';
function MyComponent() {
const [currentTime, setCurrentTime] = useState('');
const componentId = useId(); // React 18+ for stable, unique IDs
useEffect(() => {
// Only update time on the client after hydration
setCurrentTime(new Date().toLocaleString());
const interval = setInterval(() => {
setCurrentTime(new Date().toLocaleString());
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<div id={`component-${componentId}`}>
<p>Current time: {currentTime || 'Loading...'}</p>
{/* If currentTime was rendered directly, it would mismatch */}
</div>
);
}3. Inconsistent Data Fetching
If your server fetches data that's different from what the client fetches (e.g., due to different environment variables, caching strategies, or race conditions), the resulting HTML will naturally diverge. This is a subtle one because your component logic might be identical, but the inputs change.
Solution: Ensure a consistent data fetching strategy. For frameworks like Next.js, this is often handled automatically with getServerSideProps or Server Components. For custom SSR setups, pass the initial data from the server to the client (e.g., via a script tag) and reuse it for the initial client render, effectively 'priming' the client-side store.
// Server-side (e.g., within a Next.js getServerSideProps or similar)
// function getServerSideProps() {
// const initialData = fetchSomeData();
// return { props: { initialData } };
// }
// Client-side
function DataDisplay({ initialData }) {
const [data, setData] = useState(initialData);
useEffect(() => {
// If you need to re-fetch or update, do it here *after* hydration
// and ensure the initial state is from server.
// const clientFetchedData = fetchSomeData();
// setData(clientFetchedData);
}, []);
return <p>Data: {data.value}</p>;
}The Trade-offs of Hydration
Hydration isn't without its costs. While it provides a great initial user experience, the process itself can be a performance bottleneck. The client has to download the JavaScript bundle, parse it, execute it, and then traverse the DOM to attach event handlers. For very large or complex applications, this 'hydration tax' can delay interactivity significantly, even if the content is visible.
This is precisely why we're seeing patterns like Progressive Hydration and Partial Hydration emerge, where only parts of the page are made interactive, or components are hydrated in a prioritized manner. React Server Components also aim to reduce this tax by pushing more rendering logic to the server, resulting in less JavaScript for the client to download and hydrate.
It's a balance: fast initial render vs. fast interactivity. Understanding hydration helps you make informed decisions about when SSR is truly beneficial and when other techniques like Static Site Generation (SSG) (where there's no client-side JS to hydrate, just static HTML) might be more appropriate.
Wrapping up
React hydration is a fundamental concept that's often overlooked until problems arise. It's not just about getting HTML to the browser quickly; it's about ensuring a seamless transition from static content to an interactive application. Mismatches lead to performance penalties and a poor user experience, but they are almost always preventable with careful component design.
Your concrete next step: Audit your existing server-rendered React applications (Next.js, Remix, etc.) for hydration warnings. Open your browser's developer console in production mode and look for messages like Warning: Prop 'className' did not match. or Warning: Text content did not match.. Once you find one, trace it back to the component and apply the hasMounted pattern or ensure data consistency. You'll be surprised how many subtle bugs and performance hiccups you can iron out by just addressing these warnings.

When Your Typescript Needs a Real Workout: Diving into Type Challenges
I've seen countless teams struggle with TypeScript, not because they don't understand the basics, but because they haven't truly pushed its type system to its limits. This isn't just about avoiding `any`; it's about leveraging the compiler to enforce complex invariants at compile time. That's where

When Your Database Needs to Think: Adding AI Search with pgvector
Semantic search using embeddings is a game-changer, but integrating it often feels like a separate service problem. What if your existing Postgres database could handle it natively?

When Your Data Visualization Needs to Be More Than Just a Static Chart: Diving into Plotly.js
Tired of static charts that only tell half the story? Plotly.js offers a powerful way to bring your data to life with rich interactivity, letting users explore and understand complex datasets directly in their browser. It's not just about pretty graphs; it's about making data explorable.


















