Skip to content

Why React Needs Hydration But EJS/Express SSR Doesn't

I was debugging a hydration error in my React app when a thought hit me: back when I used EJS with Express, I never had “hydration” problems. The server rendered HTML, the client attached event handlers, and everything just worked. Why does React make this so complicated?

Then a React core team member (rickhanlonii) dropped this insight in a Reddit thread:

“Adding the event handler on the client was hydration!”

Wait - EJS has hydration too? This sent me down a rabbit hole to understand what’s really happening.

The Confusion

I used to write EJS templates like this:

server.js
// Express with EJS
app.get('/', (req, res) => {
res.render('index', { items: ['Apple', 'Banana'] });
});
index.ejs
<ul id="list">
<% items.forEach(item => { %>
<li><%= item %></li>
<% }); %>
</ul>
<button id="btn">Add</button>
client.js
document.getElementById('btn').addEventListener('click', () => {
// Add new item
});

Server sends HTML, browser renders it, JavaScript attaches events. Done. No hydration warnings, no mismatched nodes, no complexity.

Then I switched to React SSR and suddenly I’m dealing with this:

app.jsx
function App() {
const [items, setItems] = useState(['Apple', 'Banana']);
return (
<ul>
{items.map(item => <li key={item}>{item}</li>)}
<button onClick={() => setItems([...items, 'Orange'])}>Add</button>
</ul>
);
}

Why does React need a whole hydration step while EJS just worked?

The Real Answer: Architecture, Not Complexity

The key insight is that both approaches DO hydrate - they just do it differently.

EJS “Hydration” (Imperative)

ejs-hydration-flow.txt
Server Client
------ ------
Template + Data --> HTML --> Browser renders HTML
|
v
JS manually attaches events
(querySelector + addEventListener)

The server outputs an HTML string. The browser displays it immediately. Then your JavaScript code manually finds elements and attaches behavior.

ejs-hydration.js
// This IS hydration - making static HTML interactive
const btn = document.getElementById('btn');
btn.addEventListener('click', handleClick);

This is imperative hydration: you tell the browser exactly how to make the HTML interactive.

React Hydration (Declarative)

react-hydration-flow.txt
Server Client
------ ------
Component + Data --> HTML --> Browser renders HTML
| |
v v
Fiber Tree (conceptual) Hydration: Build fiber tree,
match to DOM, attach events

React’s hydration does the same thing - but it builds an entire internal structure first.

Why React Needs the Fiber Tree

The real question is: what does React get from all this complexity?

React’s declarative model requires storing things that EJS never needs:

fiber-structure.js
// Simplified Fiber Node - what React builds during hydration
{
tag: FunctionComponent, // Type of component
type: App, // The component function
stateNode: domNode, // Actual DOM element
// The tree structure
return: parentFiber, // Parent
child: firstChildFiber, // First child
sibling: nextFiber, // Next sibling
// Where the magic happens
memoizedState: {
memoizedState: ['Apple', 'Banana'], // useState values!
queue: { dispatch: setItems }, // The setter function
next: null
},
memoizedProps: {}, // Previous props
pendingProps: {}, // New props
}

Without this fiber tree, where would useState store its values? Where would useEffect track its cleanup functions? Where would useRef keep its references?

What Happens Without Hydration?

If React just re-rendered without hydrating:

  1. State Loss - All useState values reset to initial values
  2. Effect Re-runs - Every useEffect cleanup and setup fires again
  3. Focus Loss - Input focus resets to nothing
  4. Content Flash - Users see the page re-render

Side-by-Side Comparison

Let me show you the same functionality both ways:

EJS/Express Version

server-ejs.js
const express = require('express');
const app = express();
app.set('view engine', 'ejs');
app.get('/', (req, res) => {
res.render('todo-list', {
title: 'My Todos',
items: ['Buy milk', 'Walk dog', 'Code React']
});
});
todo-list.ejs
<!DOCTYPE html>
<html>
<head><title><%= title %></title></head>
<body>
<h1><%= title %></h1>
<ul id="todo-list">
<% items.forEach(item => { %>
<li class="todo-item"><%= item %></li>
<% }); %>
</ul>
<input id="new-todo" type="text" placeholder="Add todo">
<button id="add-btn">Add</button>
<script src="/client.js"></script>
</body>
</html>
client-ejs.js
// Manual "hydration" - attach events to existing DOM
document.addEventListener('DOMContentLoaded', () => {
const todos = []; // State managed manually
document.getElementById('add-btn').addEventListener('click', () => {
const input = document.getElementById('new-todo');
const list = document.getElementById('todo-list');
const li = document.createElement('li');
li.className = 'todo-item';
li.textContent = input.value;
list.appendChild(li);
todos.push(input.value); // Manual state tracking
input.value = '';
});
});

React SSR Version

server-react.jsx
import { renderToString } from 'react-dom/server';
import App from './App';
app.get('/', (req, res) => {
const initialItems = ['Buy milk', 'Walk dog', 'Code React'];
const html = renderToString(<App initialItems={initialItems} />);
res.send(`
<!DOCTYPE html>
<html>
<head><title>My Todos</title></head>
<body>
<div id="root">${html}</div>
<script src="/client.js"></script>
</body>
</html>
`);
});
App.jsx
function App({ initialItems }) {
const [todos, setTodos] = useState(initialItems);
const [input, setInput] = useState('');
const addTodo = () => {
setTodos([...todos, input]);
setInput('');
};
return (
<div>
<h1>My Todos</h1>
<ul>
{todos.map((todo, i) => (
<li key={i} className="todo-item">{todo}</li>
))}
</ul>
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Add todo"
/>
<button onClick={addTodo}>Add</button>
</div>
);
}
client-react.js
import { hydrateRoot } from 'react-dom/client';
import App from './App';
// Hydration: build fiber tree, match to DOM, attach events
hydrateRoot(document.getElementById('root'), <App initialItems={['Buy milk', 'Walk dog', 'Code React']} />);

The React version is more code, but notice:

  • State is managed automatically in the fiber tree
  • Event handlers are attached declaratively
  • The component can run on both server AND client

What Actually Gets Created

During hydration, React builds this structure in memory:

fiber-after-hydration.js
// What React creates for the App component
{
type: App,
memoizedState: {
// useState for todos
memoizedState: ['Buy milk', 'Walk dog', 'Code React'],
queue: { dispatch: setTodos },
next: {
// useState for input
memoizedState: '',
queue: { dispatch: setInput },
next: null
}
},
child: {
// h1 element fiber
type: 'h1',
memoizedProps: { children: 'My Todos' },
sibling: {
// ul element fiber
type: 'ul',
child: {
// li fiber for first todo
type: 'li',
memoizedProps: { className: 'todo-item', children: 'Buy milk' },
sibling: { /* next li */ }
}
}
}
}

EJS has nothing equivalent. It just outputs HTML strings.

The Trade-off

comparison-table.txt
| Feature | EJS/Express SSR | React SSR |
|----------------------|---------------------|---------------------|
| Output | HTML string | HTML string |
| Internal Model | None | Fiber tree |
| State Management | Manual (variables) | Built-in (useState) |
| Event Binding | Imperative | Declarative |
| Hydration | Manual attachment | Automatic building |
| Client Bundle | Smaller | Larger (React) |
| Mental Model | Template + JS | Component tree |

When EJS Wins

  • Simple pages with minimal interactivity
  • SEO-critical content sites
  • Smaller bundle size requirements
  • Teams familiar with traditional MVC

When React SSR Wins

  • Complex interactive applications
  • Shared server/client components
  • Rich state management needs
  • Teams preferring declarative programming

The Key Insight

The React core team member was right. EJS DOES hydrate - it’s just manual:

manual-hydration.js
// EJS "hydration" - making static HTML interactive
const btn = document.getElementById('btn');
btn.addEventListener('click', handleClick);
// This is hydration!
// You're taking static HTML and making it interactive.

React’s hydration is more complex because React’s architecture demands more. The fiber tree enables:

  • Automatic state management
  • Efficient updates (virtual DOM diffing)
  • Concurrent features
  • Error boundaries
  • Suspense

EJS gives you a simpler mental model at the cost of manual state and event management.

Conclusion

React needs hydration because React maintains an internal fiber tree. Without it, useState, useEffect, and other hooks have nowhere to store their values. EJS “hydrates” too - it just does it imperatively through manual event attachment.

The question isn’t “why does React need hydration?” It’s “what do you gain from React’s complexity?” If you need declarative components and automatic state management, the hydration cost is worth it. If you just need to render some HTML with a few event handlers, EJS might be the simpler choice.

I now understand that hydration isn’t a React invention - it’s what we’ve always done when making server-rendered HTML interactive. React just formalized it and made it automatic.

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