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:
// Express with EJSapp.get('/', (req, res) => { res.render('index', { items: ['Apple', 'Banana'] });});<ul id="list"> <% items.forEach(item => { %> <li><%= item %></li> <% }); %></ul><button id="btn">Add</button>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:
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)
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.
// This IS hydration - making static HTML interactiveconst 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)
Server Client------ ------Component + Data --> HTML --> Browser renders HTML | | v v Fiber Tree (conceptual) Hydration: Build fiber tree, match to DOM, attach eventsReact’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:
// 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:
- State Loss - All
useStatevalues reset to initial values - Effect Re-runs - Every
useEffectcleanup and setup fires again - Focus Loss - Input focus resets to nothing
- Content Flash - Users see the page re-render
Side-by-Side Comparison
Let me show you the same functionality both ways:
EJS/Express Version
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'] });});<!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>// Manual "hydration" - attach events to existing DOMdocument.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
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> `);});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> );}import { hydrateRoot } from 'react-dom/client';import App from './App';
// Hydration: build fiber tree, match to DOM, attach eventshydrateRoot(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:
// 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
| 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:
// EJS "hydration" - making static HTML interactiveconst 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:
- 👨💻 React Fiber Architecture
- 👨💻 React Hydration Documentation
- 👨💻 EJS Documentation
- 👨💻 Reddit Discussion
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments