RSC Overview
Apr 2, 2024
React Server Components are an exciting paradigm for building web applications that allows for seamless weaving of server driven content and client interactivity. Some describe this as a bundler feature, some describe it as magic. I'm here to make the case it can, and probably should be thought of as a wire format.
Pre-Rendering Today
In a traditional server-side rendered React application, the same components that are shipped to the browser are executed on the server to generate the initial HTML. This means that the server needs to have access to all the data required to render the components, and this data is then serialized and inlined into the HTML payload.
When the HTML is sent to the browser, React on the client side will "hydrate" the server-rendered markup. It does this by attaching event handlers to the pre-rendered HTML, and by synchronizing the React component state with the data that was inlined by the server. This allows the page to be presented to the user as fast as possible, but before it becomes interactive it must download the entirety of the module graph that ran on the server.
This may look something like this in pseudocode.
// prerender-server
import App from "./app";
import { loadData } from "./app-data";
// 1. load the data
const data = await loadData();
// 2. render the app
const renderedApp = <App data={data} />;
renderToHTMLStream(
<>
{renderedApp}
{/* 3. transport the data */}
<script>window.__DATA__ = {JSON.stringify(data)};</script>
</>
);
// browser
// 1. download app bundle
import App from "./app";
// 2. retrieve the data from the window
const data = window.__DATA__;
// 3. hydrate the app
const renderedApp = <App data={data} />;
hydrateRoot(document, renderedApp);
RSC Terminology
Before we dive into how RSC works we first need to define some terms as they may be sound familiar, but most likely have slightly different meanings in our new world.
- Server - A React server that runs or is bundled with the
react-serverimport condition and responds to network requests with the RSC transport format. - Client - A runtime that consumes an RSC payload from a server and renders it for display. A classic pre-render server, and browser entry are both clients.
The React Server
In a bit of a paradigm shift, the React server does not send data for the client to hydrate into a component, but instead sends the rendered component tree for the client to render.
// react-server
import App from "./app";
// App now loads its own data as it doesn't serialize any of it for hydration
const renderedApp = <App />;
renderToRSCStream(renderedApp);
The RSC stream contains a serialized component tree that represents the rendered application. This includes any component types that are marked as "use client", their props, and any nested children. Importantly, this does not include any data that was loaded during rendering, as that data is not needed for the client to render the application as the component tree has already been expanded.
React Clients
When a client receives the RSC stream, it can begin rendering the application immediately, without needing to wait for any additional data or code to be downloaded. This is possible because the RSC stream contains all the information needed to render the application, and the client runtime knows how to interpret and render the serialized component tree.
// prerender-client
// 1. The server expands and sends the component tree
const stream = await fetch("https://react-server.com");
// 2. Decode the rendered element
const renderedApp = await createElementFromRSCStream(stream);
// 3. render the app
renderToHTMLStream(
<>
{renderedApp}
{/* 4. inline the RSC stream for use in the browser */}
<script>window.__RSC_STREAM__ = {inlineRSCStream(stream)}</script>
</>
);
// browser-client
// 1. retrieve the RSC stream from the window
const stream = window.__RSC_STREAM__;
// 2. Decode the rendered element
const renderedApp = await createElementFromRSCStream(stream);
// 3. Render the app
hydrateRoot(document, renderedApp);
Notice above how we are no longer importing the App component in the clients, this is because the RSC stream already contains the fully rendered component tree. The clients no longer need to have access to the component source code, only the ability to interpret the RSC format.
Client Interactivity
Now that we've removed all our components from the client runtimes, we need a way to transport interactivity concerns. Introducing "use client" , a directive that denotes a serialization boundary between the server world and the client world.
When React evaluates a component tree it is operating on a structure that looks something like this:
const renderedApp = {
$$typeof: Symbol.for("react.element"),
type: "body",
props: {
children: {
$$typeof: Symbol.for("react.element"),
type: Counter,
props: {
initialCount: 2,
},
},
},
};
React walks this structure expanding any elements that have a function as the type. "use client" effectively removes the implementation of the Counter component above and replaces it with a special indicator telling the React server runtime that it should not expand this, but instead serialize the properties over the wire for a client to expand the component instead. Conceptually the above would become:
const renderedApp = {
$$typeof: Symbol.for("react.element"),
type: "body",
props: {
children: {
$$typeof: Symbol.for("react.element"),
type: {
$$typeof: Symbol.for("react.client.reference"),
$$id: "/counter.js#Counter",
},
props: {
initialCount: 2,
},
},
},
};
Now when the client receives the RSC payload that represents the above component tree, it is responsible for loading and expanding that client reference. This may look something like this:
// prerender-client
// 1. The server expands and sends the component tree
const stream = await fetch("https://react-server.com");
// 2. Decode the rendered element
const renderedApp = await createElementFromRSCStream(stream, {
// 3. Load any "use client" references
async loadClientReference(id) {
const [mod, ...exp] = id.split("#");
const loadedMod = await import(mod);
return loadedMod[exp.join("#")];
},
});
// 4. render the app
renderToHTMLStream(
<>
{renderedApp}
{/* 4. inline the RSC stream for use in the browser */}
<script>window.__RSC_STREAM__ = {inlineRSCStream(stream)}</script>
</>
);
The browser client would take on the same responsibility:
// browser-client
// 1. retrieve the RSC stream from the window
const stream = window.__RSC_STREAM__;
// 2. Decode the rendered element
const renderedApp = await createElementFromRSCStream(stream, {
// 3. Load any "use client" references
async loadClientReference(id) {
const [mod, ...exp] = id.split("#");
const loadedMod = await import(mod);
return loadedMod[exp.join("#")];
},
});
// 4. Render the app
hydrateRoot(document, renderedApp);
Conclusion
In summary, React Server Components introduce a new way of building web applications that leverages the strengths of both server-side and client-side rendering. By sending a serialized component tree from the server to the client, RSC allows for fast initial renders and efficient updates, without the need for the client to have access to all the data and code upfront.
The use of the "use client" directive enables a clear separation between server-rendered and client-rendered components, allowing developers to optimize their applications for performance and interactivity. This new architecture opens up exciting possibilities for building more efficient, scalable, and maintainable web applications.
As the React ecosystem continues to evolve, it will be interesting to see how developers adopt and build upon the concepts introduced by React Server Components, and how this new paradigm shapes the future of web development.