React Server Components
I was trying to make sense of why new React code shows up with use client
and use server
annotations everywhere. Apparently, there’s this new construct called Server components
now.
Why is it cool
-
React doesn’t need to construct the full DOM for the app in the browser. If you use an RSC-compatible framework, it can pre-compute parts of the component tree on the server. This saves both bandwidth and computation time on the client.
-
It is a unified mental model for thinking about webpages - whether they are static sites like a blog, or a full fledged app. I dunno, once I got it, it just feels like a really neat abstraction.
Why is it annoying
Most educational material (sans Dan Abramov, obviously) on React server components has been poor, sorry. They always seem to center around with what is allowed, what are the edge cases, which part of the framework they work with.
It doesn’t feel that tricky once you see what React is trying to get at. And yet.
-
A lot of React/JS libraries were designed to work in the browser, relying on browser apis and affordances. When you try to run them on the server, they break. Server side rendering (SSR) has always existed but not all libraries are isomorphic, or want to be.
-
When the React team says RSC is the future, it can be antagonizing; adding more work for both library developers and users.
-
What might seem like arbitrary rules - no hooks in server components, client components can’t render server components but can receive them as children, and others.
How to think about it
There are a couple maxims I think, that help make sense of the RSC model.
- Your webpage is rendered as a tree of react components.
- React components aren’t just markup, they are full fledged javascript functions.
- You can’t know the output of a function until you execute it.
The RSC model is about trying to render as much of the DOM tree as possible on the server; starting out from the top level component and until it comes across a client component. At that point, the framework sends the incompletely rendered tree to the browser, and the code (and props) for the client component. The React bundle in the browser executes that code to inject the client component, and finish the tree.
Some implications.
-
Client components can’t do things like, say talk using your backend connection to the database. Database connections can’t be serialized and shipped across the nework, and the browsers typically don’t support those connections either (nevermind databases with a web API).
-
Server components can’t have state hooks -
useState
,useContext
, and the likes. A server component gets executed when the server endpoint is called, its output serialized and sent to the browser. These endpoints are stateless and there is no persistentcomponent
across requests. -
Server components can refer to client components in their body. If you use an RSC framework, it recognizes the server-client boundary, inserts a skeleton for the client component, and sends the incomplete tree to the browser. React in the browser can figure out how to fill in the client component.
-
A client component can’t refer to a server component in its body. Because client components are executed by React in the browser, which can’t run the server component functions. A server component might need affordances not available in the browser.
-
However, a client component can receive server components as children props. Note this is not really an edge case - if the client component is receiving some props, they were computed outside it. RSC is indeed about computing as much as you can on the server.
This is unlike the client component function referring to a server component inside it. The RSC framework doesn’t know the contents of the client component function, because it can’t run it on the server. And hence, it can’t precompute the server components in this case.
Example snippets
Wes Bos has a good diagram showing how composition works in the server component paradigm. Following are some quick notes about why those work, or don’t.
1. Server Components
export default function MyServerComponent() {
// Server logic: can read from DB, filesystem, etc.
const data = "You have 15 files rendered with node v20.8.0 from Server.tsx!";
return (
<div>
<p>{data}</p>
</div>
);
}
This shows a simple server component output: it fetches or calculates data on the server and sends back rendered results. It works because the server environment can access things like the filesystem or database. React takes the markup generated by this component and ships it to the client.
2. Client Components
"use client";
import { useState } from "react";
export default function MyClientComponent() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
{count} Clicks
</button>
);
}
Here, a client component displays interactive content (like a button). It’s rendered in the client environment because it needs browser APIs or state. You can track clicks or user input, something you can’t do in a purely server component.
3. Client Components Inside Server Components
"use server";
import MyClientComponent from "./MyClientComponent";
export default function ServerWrapper() {
return (
<div>
<p>Server logic here</p>
<MyClientComponent />
</div>
);
}
The server can nest a client component. The tree starts on the server, which renders what it can. Wherever interactivity is needed, it hands off to the client component. That’s valid because the server already prepared the layout or data, and the client piece just needs to plug in.
4. You Can’t Put a Server Inside a Client
"use client";
// This won't work as intended because you can't import/run server code here.
import MyServerComponent from "./MyServerComponent";
export default function ClientWrapper() {
return (
<div>
<p>Trying to render a server component here</p>
<MyServerComponent />
</div>
);
}
A client component can’t directly import or run a server component, as the browser doesn’t have access to server-only logic (e.g., reading from disk or using server credentials). Attempting this would break the tree because the client can’t execute that server function.
NOTE: The framework can’t possibly know that ClientWrapper
is trying to render MyServerComponent
until the ClientWrapper
function is already run. Maybe a compiler pass could do it someday. But that’s the reason for this rule.
5. Passing a Server Component Through Props
Server “Container” Component
"use server";
import MyServerComponent from "./MyServerComponent";
import ClientShell from "./ClientShell";
export default function Container() {
return (
<ClientShell
serverBlock={<MyServerComponent />}
/>
);
}
Client Shell Component
"use client";
export default function ClientShell({ serverBlock }) {
return (
<div>
<p>This is the client shell</p>
{serverBlock}
</div>
);
}
While you can’t embed a server component in a client component, the server can still pass its own rendered output as props. The server computes MyServerComponent
, then sends it over along with the client component code. It gets used by React in the browser to execute the ClientShell
component function.
NOTE: The difference with the previous example is that the server component is computed outside the client component, so the RSC framework could execute it on the server.
6. A Client Can Run Code on the Server
Server Action
"use server";
export async function getServerTime() {
return new Date().toLocaleTimeString();
}
Client Component
"use client";
import { useState } from "react";
import { getServerTime } from "./actions";
export default function TimeClient() {
const [serverTime, setServerTime] = useState("");
async function handleRefresh() {
const time = await getServerTime();
setServerTime(time);
}
return (
<div>
<button onClick={handleRefresh}>Get Server Time</button>
<p>Server says: {serverTime}</p>
</div>
);
}
The client triggers server logic through something like an API call or an action. These are regular API endpoints, so it is a regular browser-server interaction. The server computes the time and sends it back to the client, which updates the UI.
7. Passing Client Data to the Server and Back
Server Action
"use server";
export async function searchProducts(query) {
// Example server logic: database or external API call.
return [`Result 1 for ${query}`, `Result 2 for ${query}`];
}
Client Component
"use client";
import { useState } from "react";
import { searchProducts } from "./actions";
export default function ProductSearch() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
async function handleSearch() {
const response = await searchProducts(query);
setResults(response);
}
return (
<div>
<input
placeholder="Search..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button onClick={handleSearch}>Go</button>
<ul>
{results.map((r, idx) => <li key={idx}>{r}</li>)}
</ul>
</div>
);
}
The user can input data in the client component and send it to the server for processing (for example, searching a database). The server returns updated results, which React displays on the client side. Again, this is regular client-server interaction, like a typical web app.