hydration and composition·published December 2024
React Server Components are hard to grok and building a mental model of how they work has proven to be difficult to many of us. But, despite the general discourse, they are not confusing for the sake of being confusing, and they are not a conspiracy to sell server cycles. Beneath the abstraction is fundamentally strong engineering that has unlocked both UX and DX gains that were previously inaccessible. I think it's worthwhile to learn their design and what problems they truly solve.
The advantages of client-side rendering and even the advantages of traditional server-side rendered apps (like PHP or Ruby) have been well discussed and both approaches have merit. I'd like to walk you through an approach to RSCs from a slightly different angle–one that looks at them through the lens of hydration and composition. To me, it's under this spotlight that RSCs shine.
In this post, we'll explore:
- Why hydration is so costly and over-utilized
- Why server components exist
- How through composition, they are unlike anything that we've seen before
- Why RSCs are so confusing, and how we can simplify the mental model
This won't cover everything, but hopefully it will clear up some of the aspects that are tough to wrap your head around. And once they do begin to click, I think you'll find the rest of it slowly falls in place.
What is Hydration?
Hydration is taking some html and enriching it with client-side javascript. It doesn't matter if you are writing a simple php or rails app and sprinkling in some javascript by hand, or an isomorphic SSR app that handles it for you. Everyone hydrates at some point. And we're all desiccated, because hydration is costly.
Hand-hydrating a PHP or rails app works until you need to re-use elements, or you need to share state across components, or you need cross-view client side state. There are of course ways to solve these problems, but it's really, really hard to do so scalably. As your website grows, it only becomes more and more tangled. You'll notice that most of the people who espouse the benefits of this pattern often exclaim the lack of build-steps and compilation as its true benefit, not its technological superiority.
1<div>
2 A Reddit Post
3 <div>
4 <button class="preview">Show</button>
5 <img src="https://placecats.com/neo/300/200" style="display:none;" />
6 </div>
7</div>
8
9<script>
10 // this is hydration
11 document.querySelector('.preview').addEventListener('click', (e) => {
12 const $img = e.target.parentElement.querySelector('img');
13 // toggle visibility
14 $img.style.display = $img.style.display === 'none' ? 'block' : 'none';
15 });
16</script>
Though this is a rather naive example, we can see how this is fickle - our javascript is not contained nor componentized – it's globalized and not composable. It works fine in isolation, but it will never actually exist in isolation.
Isomorphic Rendering
The javascript world solved this hand-hydration spaghetti problem by introducing isomorphic rendering. This allowed people to write apps entirely in javascript that render on both the server and the client. It was a natural evolution. As with many software solutions, this was wonderful in theory, but less so in practice. Instead of chonky client-only JS bundles downloading, then parsing, then executing, then rendering before anything was visible, we could render the site on the server first. But with that came a fatal flaw: we hydrated everything. The entire website, all of it.
Contrary to general consensus, the cost of hydrating everything is not only slower download speeds for those javascript bundles, but the time it takes to reach interactivity. Downloading, parsing, and executing all of that javascript is incredibly slow and blocks the main thread. Websites would freeze for a few seconds before you could enter text into an input, click a button, or even scroll the page.
React's hydration is even more powerful than just attaching event handlers. It takes over your components' rendering on the client so it can re-render updates for future cycles. This is incredibly useful, but again, costly and unnecessary for every single div.
This process of rendering your components and attaching event handlers is known as “hydration”. It’s like watering the “dry” HTML with the “water” of interactivity and event handlers. (Or at least, that’s how I explain this term to myself.)
-Dan Abramov
Writing an entire website in one composable paradigm improved our code quality – and the user (or Googlebot) saw something immediately. But all of your rendering code had to be pure on both the server and the client, leading to frustration and hamstringing the server.
1export function Timestamp() {
2 return new Date().toLocaleString();
3}
Error: Hydration failed because the initial UI does not match what was rendered on the server
With React SSR, code runs in both environments
This turned the trusted server into what is effectively a dumbed down client. We lost all of the access control and environmental benefits of the server itself. And, when writing something like PHP, you never had to consider what timestamp was going to render, because it was only being rendered in one place.
Reinventing PHP
Dismissals of RSCs often come with a lackadaisical insult to say the React developers have just reinvented PHP. But that's a cheap knock.
The reason those PHP apps sprinkled with JS kinda worked is the same reason React Server Components exist – it turns out we don't need to hydrate most of our application. Most of our components are not interactive and they don't need client-side updates frequently. They are merely simple divs and text and depend on data that doesn't change often (like a Reddit post title). All we need most of the time is a little interactivity sprinkled throughout. The dissidents were right.
Let's take our Reddit example and show how we might render it with PHP.
1<?php
2$stmt = $pdo->prepare("SELECT title, image FROM posts WHERE id = ?");
3$stmt->execute([$id]);
4$post = $stmt->fetchAll(PDO::FETCH_ASSOC);
5?>
6<div>
7 <?= $post['title'] ?>
8 <div>
9 <button class="preview">Show</button>
10 <img src="<?= $post['image'] ?>" style="display:none;" />
11 </div>
12</div>
13
14<script>
15 document.querySelector('.preview').addEventListener('click', (e) => {
16 const $img = e.target.parentElement.querySelector('img');
17 // toggle visibility
18 $img.style.display = $img.style.display === 'none' ? 'block' : 'none';
19 });
20</script>
When we don't need to automatically hydrate all of our html, we can take full advantage of the server. In a PHP route you can query a database, or write an API call to a third party without exposing it to the client, or just render some complex html. It's fast, easy to write, and intuitive. It's one of those things frontend engineers forgot they missed, and once you start playing with it, the client-side complexity around data fetching just melts away.
This is not "reinventing PHP", this is understanding that PHP's advantages weren't previously accessible to our modern frontend stack due to universal hydration. Be critical of where we were before, not where we are now!
This gives power back to the server, instead of having to render our UI on both the server and the client simultaneously, we can render just on the server and take with it the wonderful advantages the server gives us.
What is a Server Component?
A server component is a React component that renders ONLY on the server, meaning it doesn't hydrate and it doesn't get sent to the client bundle. Again, this is often most of the UI on our websites, and shedding all of that hydration leads to significantly smaller bundle sizes and much faster time-to-interactivity (TTI).
React 18 emphasized the commitment to reducing TTI by introducing selective hydration, which prioritizes interactive hydration as early as possible - pushing time to interactivity towards its lower limit without you having to do anything.
Since our code has the ability to run only on the server, the server now becomes a first-class citizen (instead of the dumb client it was relegated to previously with isomorphic rendering).
Let's look at the PHP example as a server component. And yes, the SQL parameter is properly sanitized.
1export default async function Page({ params }) {
2 const post = await sql`SELECT title, image FROM posts WHERE id = ${params.id}`;
3
4 if (!post) return notFound(); // 404
5
6 return (
7 <div>
8 {post.title}
9 <div>
10 <button className="preview">Show</button>
11 <img src={post.image} style={{ display: 'none' }} />
12 </div>
13 </div>
14 );
15}
Nothing here is hydrating yet, we're just rendering out html like we were with PHP. But the big differentiator is: we can compose in hydration with little effort.
Let's take the interactive part of our UI and write it as a client component.
1'use client';
2
3import { useState } from 'react';
4
5export function Preview({ image }) {
6 const [isOpen, setIsOpen] = useState(false);
7
8 const toggleIsOpen = () => setIsOpen(!isOpen);
9
10 return (
11 <div>
12 <button onClick={toggleIsOpen}>{isOpen ? 'Hide' : 'Show'}</button>
13 {isOpen && <img src={image} />}
14 </div>
15 );
16}
And let's return to page.js and compose that into our server component:
1export default async function Page({ params }) {
2 const post = await sql`SELECT title, image FROM posts WHERE id = ${params.id}`;
3
4 if (!post) return notFound(); // 404
5
6 return (
7 <div>
8 {post.title}
9+ <Preview image={post.image} />
10 </div>
11 );
12}
That's it – that's all you have to do to "sprinkle" in interactivity. We're not managing spaghetti, we're composing an architecture that reaches across the hydration aisle, and we still were able to take full advantage of the server.
When this mental model clicks, when you start to mold it for yourself, you'll be shocked at how natural it feels. You can render incredibly complex components, svgs, charts, and other things on the server, and just compose in the small interactions when you need them. It's a more mature, more flexible programming model than anything we've had prior.
That all being said, it is hard to build that mental model. You're right to find it confusing. I think there are ways to simplify the mental model and over time, I expect these things will come to feel more natural, or other frameworks will adopt these patterns and make them more accessible.
Where Is My Code Running?
A friend phoned me for some help with RSCs and he pointedly asked "where the fuck is this running?" - where is this code executing? It's a frustrating and unsettling experience, it's not something any of us are used to. Running code in two environments is inherently complicated, and abstracting it away blurs that clarity in the pursuit of composition. It takes a second to understand why it's designed this way, but it does make sense.
There are two directives in next.js for RSCs: 'use client' and 'use server'. Let's start with the first one – you might guess that components marked with 'use client' are rendered on the client. But, you'd only be half correct: they are actually rendered on both the server and the client.
These are named "Client Components" because they are included in the client bundle, not because of where they are executed. Their initial state is rendered on the server and future updates are managed on the client.
With this simple table, we can visualize where our code is being executed:
Server Components | ✅ | 🚫 |
Client Components | ✅ | ✅ |
I think a better way to think about client components is to consider them "Hydrated Components" – or the almost coarsely German-sounding "Hydratable Components". Because their initial state renders on the server, and then they hydrate. If they didn't render on the server initially, the "Show" button would magically appear well after the user loaded the page.
execution environments
Some of these "Client Components" may not be hydrated in the traditional sense (if they are hidden behind state, like our <img> tag in the Reddit example), but most of them will be and more importantly, they can be. Personally, I find it to be an easier mental framing because it more accurately explains in which environments your components could execute.
This brings us to the 'use server' directive, which is reserved for server actions. This is another network boundary pattern that is rather lovely, but has less to do with rendering components. It is an abstraction for communicating back to the server from the client (like an API request), using the server function as a reference to a POST request. It's not explicitly necessary to use in order to take advantage of RSCs, and is only additive. A better name, in my opinion, would be to give actions the directive of 'use action' or even 'use server action' to avoid the confusion with server components.
Server Components have no directive because they are the default. This is intentional because the html tree starts rendering from the top down, from the server. Hydration is sprinkled in further down the tree.
The confusion around these names can be no further verified than by looking at React's own docs where they readily share that this is a common misconception. If your docs must point out such a profound misunderstanding, perhaps it would be best to improve the naming of these patterns.
screenshot of the react docs
The reasoning behind these names is you have to begin your mental model with this idea: that everything starts in the server, the shell of your application begins at the server. From there, when you need to, the 'use client' directive moves you across the network boundary into the client bundle. And from the client, the 'use server' directive moves you back across the network boundary to the server to POST actions. Once this clicks, it becomes very natural and the abstraction is remarkably smooth, but it's clearly difficult for people to understand.
crossing the network boundary
Naming these concepts is incredibly hard - we are abstracting away the network in the pursuit of composition, it's not simple stuff. While the mental model is difficult to build, and the concepts can be tough to grasp, the engineering behind it is in fact solid and empowering. It is worth taking the time to play with these ideas.
Wrapping It Up
Despite the broad confusion, RSCs give us newfound optionality as to how we architect websites. No longer do we have to pick between composition and hydration – we have access to the best of both worlds. If you take the time to learn them, I think you'll find the experience rather delightful, and unlike anything that has ever existed before. But there's plenty of room to make them more accessible, and to soften the cognitive load.
There are other solutions out there to the hydration problem, like Qwik's resumability paradigm or Astro's (and others) Islands Architecture. It's worth keeping an eye on these to see if we can solve this hydration composition problem in simpler ways.
If you are happy with your PHP app or your vanilla react, if you truly have no need for RSCs, please keep using the technology that serves you. RSCs are not a one-size-fits-all solution, rather they are a great balance that scales remarkably well to many needs. I submit that most "Top 1,000+" public-facing websites would net-benefit from the technological advantages that RSCs provide, and even many that aren't public-facing. Please don't confuse this with me suggesting you drop everything and rewrite your applications, but for those of us who have experienced the hydration and composition problems, RSCs are a natural conclusion.
For future posts, I'd like to write more about how RSCs are a return to web standards, and how they encourage us to move client side state to the URL thereby giving our UIs more object permanence. I'd also like to explain how RSCs allow us to drop page-level loading states, an entire class of UI that is largely unnecessary, over-complex, and generally hinders user experience. And I'd like to show you how server components ultimately will lead to better UX – which is really what matters most. If you are interested hearing more about these ideas, please send me a note below.