How React server components work: an in-depth guide

Authors
Last updated
 

React server components (RSC) is an exciting new feature that will have huge implications on page load performance, bundle size, and how we write React applications in the near future. We at Plasmic make a visual builder for React, and we care a lot about React performance — many of our customers use Plasmic to build performance-critical marketing and e-commerce sites. And so, even though RSC is still an early experimental feature in React 18, we have been digging into how it works under the hood. In this blog post, we’re excited to share what we’ve learned!

See also our tweet summary.

What are React server components?

React Server Components allows the server and the client (browser) to collaborate in rendering your React application. Consider the typical React element tree that is rendered for your page, which is usually composed of different React components rendering more React components. RSC makes it possible for some components in this tree to be rendered by the server, and some components to be rendered by the browser 🤯

Here’s a quick illustration from the React team, showing what the end goal is: a React tree, where the orange components rendered on the server, and blue components are rendered on the client.

A React tree with server components (orange) and client components (blue)

Isn’t that “server-side rendering”?

React Server Component is not server-side rendering (SSR)! It’s a bit confusing because they both have “server” in the name, and they’re both doing work on the server. But it is much easier to understand them as two separate and orthogonal features. Using RSC does not require using SSR, and vice versa! SSR simulates an environment for rendering a React tree into raw html; it does not differentiate between server and client components, and it renders them the same way!

It is possible to combine both SSR and RSC, though, so that you can do server-side rendering with server components and hydrate them properly in the browser. In a future post, we will be talking more about how they work together.

But for now, let’s ignore SSR, and focus purely on RSC.

Why would we want this?

Before React Server Components, all React components are “client” components — they are all run in the browser. When your browser visits a React page, it downloads the code for all the necessary React components, constructs the React element tree, and renders it to the DOM (or hydrates the DOM, if you’re using SSR). The browser is a good place for this, because it allows your React application to be interactive — you can install event handlers, keep track of state, mutate your React tree in response to events, and update the DOM efficiently. So why would we want to render anything on the server?

There are certain advantages that rendering on the server has over the browser:

  • The server has more direct access to your data sources — be they your databases, GraphQL endpoints, or the file system. The server can directly fetch the data you need without hopping through some public API endpoint, and it is usually more closely colocated with your data sources, so it can fetch the data more quickly than a browser can.
  • The server can cheaply make use of “heavy” code modules, like an npm package for rendering markdown to html, because the server doesn’t need to download these dependencies every time they’re used — unlike the browser, which must download all used code as javascript bundles.

In short, React Server Components makes it possible for the server and the browser to do what they do best. Server components can focus on fetching data and rendering content, and client components can focus on stateful interactivity, resulting in faster page loads, smaller javascript bundle sizes, and a better user experience.

The high-level picture

Let’s gain some intuition first about how this works.

My kids love decorating cupcakes, but they’re not so into baking them. Asking them to make and decorate cupcakes from scratch would be an (adorable) nightmare. I would need to hand them bags of flour and sugar, sticks of butter, give them access to the oven, read them a ton of instructions, and take the whole day. But hey, I can do the baking part much faster; if I do some of the work upfront — by first baking the cupcakes and making the frosting, and handing those to my kids, instead of the raw ingredients — they can get to the fun decorating part much faster! And better still, I don’t need to worry about them using the oven at all. Win!

A kid eating cake, with caption "Doing my part"

React server components is all about enabling this division of labor — let the server do what it can do better upfront, before handing things off to the browser to finish the rest. And in doing so, there are fewer things for the server to hand off — instead of a whole bag of flour and a friggin’ oven, 12 little cupcakes are much more efficient to transport!

Consider the React tree for your page, with some components to be rendered on the server and some on the client. Here’s one simplified way to think about the high-level strategy: the server can just “render” the server components as usual, turning your React components into native html elements like div and p. But whenever it encounters a “client” component meant to be rendered in the browser, it just outputs a placeholder instead, with instructions to fill in this hole with the right client component and props. Then, the browser takes that output, fills in those holes with the client components, and voila! You’re done.

This is not how it really works, and we’re about to jump into those real gnarly details soon; but it is a useful high-level picture to have in your head!

The server-client component divide

But first — what is even a server component? How do you even say which components are “for server” and which are “for client”?

The React team has defined this based on the extension of the file that the component was written in: if the file ends with .server.jsx, it contains server components; if it ends with .client.jsx, it contains client components. If it has neither, then it contains components that can be used as both server and client components.

This definition is pragmatic — it is easy for both humans and bundlers to tell them apart. And specifically for bundlers, they are now able to treat client components different by inspecting their file names. As you’ll soon see, the bundler plays an important role in making RSC work.

Because server components run on the server, and client components run on the client, there are many restrictions on what each can do. But the most important one to keep in mind is that client components cannot import server components! That’s because server components cannot be run in the browser, and may have code that does not work in the browser; if client components depended on server components, then we would end up pulling those illegal dependencies into the browser bundles.

This last point can be a head-scratcher; it means client components like this are illegal:

// ClientComponent.client.jsx
// NOT OK:
import ServerComponent from './ServerComponent.server'
export default function ClientComponent() {
  return (
    <div>
      <ServerComponent />
    </div>
  )
}

But if client components cannot import server components, and so cannot instantiate server components, then how do we end up with a React tree like this, with server and client components interleaved together? How can you have server components (orange dots) under the client components (blue dots)?

A React tree with server components (orange) and client components (blue)

While you can’t import and render server components from client components, you can still use composition — that is, the client component can still take in props that are just opaque ReactNodes, and those ReactNodes may happen to be rendered by server components. For example:

// ClientComponent.client.jsx
export default function ClientComponent({ children }) {
  return (
    <div>
      <h1>Hello from client land</h1>
      {children}
    </div>
  )
}

// ServerComponent.server.jsx
export default function ServerComponent() {
  return <span>Hello from server land</span>
}

// OuterServerComponent.server.jsx
// OuterServerComponent can instantiate both client and server
// components, and we are passing in a <ServerComponent/> as
// the children prop to the ClientComponent.
import ClientComponent from './ClientComponent.client'
import ServerComponent from './ServerComponent.server'
export default function OuterServerComponent() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  )
}

This restriction will have major implications on how you organize your components to better take advantage of RSC.

Life of an RSC render

Let’s dive into the nitty gritty details of what actually happens when you try to render React server components. You won’t need to understand everything here to be able to use server components, but it should give you some intuition on how it works!

1. Server receives a request to render

Because the server needs to do some of the rendering, the life of a page using RSC always starts at the server, in response to some API call to render a React component. This “root” component is always a server component, which may render other server or client components. The server figures out which server component and what props to use, based on information passed in the request. This request usually comes in the form of a page request at a specific url, though Shopify Hydrogen has more fine-grained methods, and React team’s official demo has a raw implementation.

2. Server serializes root component element to JSON

The end goal here is to render the initial root server component into a tree of base html tags and client component “placeholders”. We can then serialize this tree, send it to the browser, and the browser can do the work of deserializing it, filling the client placeholders with the real client components, and rendering the final result.

So, following the above example — suppose we want to render <OuterServerComponent/>. Can we just do JSON.stringify(<OuterServerComponent />) to get a serialized element tree?

Almost, but not quite! 😅 Recall what a React element actually is — an object, with a type field as either a string — for a base html tag, like "div" — or a function — for a React component instance.

// React element for <div>oh my</div>
> React.createElement("div", { title: "oh my" })
{
  $$typeof: Symbol(react.element),
  type: "div",
  props: { title: "oh my" },
  ...
}

// React element for <MyComponent>oh my</MyComponent>
> function MyComponent({children}) {
    return <div>{children}</div>;
  }
> React.createElement(MyComponent, { children: "oh my" });
{
  $$typeof: Symbol(react.element),
  type: MyComponent  // reference to the MyComponent function
  props: { children: "oh my" },
  ...
}

When you have a component element — not a base html tag element — the type field references a component function, and functions are not JSON-serializable!

To properly JSON-stringify everything, React passes a special replacer function to JSON.stringify() that properly deals with these component function references; you can find it as resolveModelToJSON() in ReactFlightServer.js.

Specifically, whenever it sees a React element to be serialized,

  • If it is for a base html tag (type field is a string like "``div``"), then it is already serializable! Nothing special to do.
  • If it is for a server component, then call the server component function (stored in the type field) with its props, and serialize the result. This effectively “renders” the server component; the goal here is to turn all server components into just base html tags.
  • If it is for a client component, then… it is actually already serializable too! The type field is actually already pointing to a module reference object, not a component function. Wait, what?!

What are “module reference” objects?

RSC introduces a new possible value for the type field of a React element, called a “module reference”; instead of a component function, it is a serializable “reference” to it.

For example, a ClientComponent element might look something like this instead:

{
  $$typeof: Symbol(react.element),
  // The type field  now has a reference object,
  // instead of the actual component function
  type: {
    $$typeof: Symbol(react.module.reference),
    // ClientComponent is the default export...
    name: "default",
    // from this file!
    filename: "./src/ClientComponent.client.js"
  },
  props: { children: "oh my" },
}

But where is this sleight of hand happening — where we are converting references to client component functions into serializable “module reference” objects?

As it turns out, it is the bundler that is performing this magic trick! The React team has published official RSC support for webpack in react-server-dom-webpack as a webpack loader or a node-register. When a server component imports something from a *.client.jsx file, instead of actually getting that thing, it is only getting a module reference object, containing the file name and export name of that thing instead. No client component function was ever part of the React tree constructed on the server!

Consider again the example above, where we are trying to serialize <OuterServerComponent />; we will end up with a JSON tree like:

{
  // The ClientComponent element placeholder with "module reference"
  $$typeof: Symbol(react.element),
  type: {
    $$typeof: Symbol(react.module.reference),
    name: "default",
    filename: "./src/ClientComponent.client.js"
  },
  props: {
    // children passed to ClientComponent, which was <ServerComponent />.
    children: {
      // ServerComponent gets directly rendered into html tags;
      // notice that there's no reference at all to the
      // ServerComponent - we're directly rendering the `span`.
      $$typeof: Symbol(react.element),
      type: "span",
      props: {
        children: "Hello from server land"
      }
    }
  }
}

The serializable React tree

At the end of this process, we hope to end up with a React tree that looks something more like this on the server, to be sent to the browser to “finish up”:

A React tree with server components rendered to native tags, and client components replaced with placeholders
All props must be serializable

Because we are serializing the whole React tree to JSON, all the props that you are passing to client components or base html tags must be serializable as well. That means from a server component, you cannot pass down an event handler as a prop!

// NOT OK: server components cannot pass functions as a prop
// to its descendents, because functions are not serializable.
function SomeServerComponent() {
  return <button onClick={() => alert('OHHAI')}>Click me!</button>
}

However, one thing to note here is that during the RSC process, when we encounter a client component, we never call client component functions, or “descend” into client components. So if you have a client component that instantiates another client component:

function SomeServerComponent() {
  return <ClientComponent1>Hello world!</ClientComponent1>;
}

function ClientComponent1({children}) {
  // It is okay to pass a function as prop from client to
  // client components
  return <ClientComponent2 onChange={...}>{children}</ClientComponent2>;
}

ClientComponent2 does not show up at all in this RSC JSON tree; instead, we will only see an element with a module reference and props for ClientComponent1. Thus, it is perfectly legal for ClientComponent1 to pass an event handler as a prop to ClientComponent2.

3. Browser reconstructs the React tree

The browser receives the JSON output from the server, and now must start reconstructing the React tree to be rendered in the browser. Whenever we encounter an element where type is a module reference, we’ll want to replace it with a reference to the real client component function.

This work again requires assistance from our bundler; it was our bundler that replaced client component functions with module references on the server, and it is now our bundler that knows how to replace those module references with the real client component functions in the browser.

The reconstructed React tree will look something like this — with just the native tags and client components swapped in:

A React tree reconstructed in the browser with only native tags and client components

Then we just render and commit this tree to the DOM as usual!

Does this work with Suspense though?

Yes! Suspense plays an integral role in all the steps above.

We have intentionally glossed over Suspense in this article, because Suspense is a huge topic on its own, and deserves its own blog post. But very briefly — Suspense allows you throw promises from your React components when it needs something that is not ready yet (fetching data, lazily importing components, etc). These promises are caught at the “Suspense boundary” — whenever a promise is thrown from rendering a Suspense sub-tree, React pauses rendering that sub-tree until the promise is resolved, and then tries again.

When we call the server component functions on the server to generate the RSC output, those functions may throw promises as they fetch the data they need. When we encounter such a thrown promise, we output a placeholder; once the promise is resolved, we try calling the server component function again, and output the completed chunk if we succeed. We are in fact creating a stream of RSC output, pausing as promises are thrown, and streaming additional chunks as they are resolved.

Similarly, in the browser, we are streaming down the RSC JSON output from our fetch() call above. This process, too, may end up throwing promises if it encounters a suspense placeholder in the output (where the server had encountered a thrown promise), and hasn’t yet seen the placeholder content in the stream (some details here). Or, it may also throw a promise if it encounters a client component module reference, but doesn’t yet have that client component function loaded in the browser — in that case, the bundler runtime will have to dynamically fetch the necessary chunks.

Thanks to Suspense, you have the server streaming RSC output as server components fetch their data, and you have the browser incrementally rendering the data as they become available, and dynamically fetching client component bundles as they become necessary.

The RSC Wire Format

But what exactly is the server outputting? If your eyebrow was raised when you read “JSON” and “stream”, you were right to be skeptical! So what data is the server streaming to the browser?

It is a simple format, with one JSON blob on each line, tagged with an ID. Here’s RSC output for our <OuterServerComponent/> example:

M1:{"id":"./src/ClientComponent.client.js","chunks":["client1"],"name":""}
J0:["$","@1",null,{"children":["$","span",null,{"children":"Hello from server land"}]}]

In the snippet above, the lines that start with M defines a client component module reference, with the information needed to look up the component function in the client bundles. The line starting with J defines an actual React element tree, with things like @1 referencing client components defined by the M lines.

This format is very streamable — as soon as the client has read a whole row, it can parse a snippet of JSON and make some progress. If the server had encountered suspense boundaries while rendering, you would see multiple J lines corresponding to each chunk as it gets resolved.

For example, let’s make our example a bit more interesting…

// Tweets.server.js
import { fetch } from 'react-fetch' // React's Suspense-aware fetch()
import Tweet from './Tweet.client'
export default function Tweets() {
  const tweets = fetch(`/tweets`).json()
  return (
    <ul>
      {tweets.slice(0, 2).map((tweet) => (
        <li>
          <Tweet tweet={tweet} />
        </li>
      ))}
    </ul>
  )
}

// Tweet.client.js
export default function Tweet({ tweet }) {
  return <div onClick={() => alert(`Written by ${tweet.username}`)}>{tweet.body}</div>
}

// OuterServerComponent.server.js
export default function OuterServerComponent() {
  return (
    <ClientComponent>
      <ServerComponent />
      <Suspense fallback={'Loading tweets...'}>
        <Tweets />
      </Suspense>
    </ClientComponent>
  )
}

What does the RSC stream look like in this case?

M1:{"id":"./src/ClientComponent.client.js","chunks":["client1"],"name":""}
S2:"react.suspense"
J0:["$","@1",null,{"children":[["$","span",null,{"children":"Hello from server land"}],["$","$2",null,{"fallback":"Loading tweets...","children":"@3"}]]}]
M4:{"id":"./src/Tweet.client.js","chunks":["client8"],"name":""}
J3:["$","ul",null,{"children":[["$","li",null,{"children":["$","@4",null,{"tweet":{...}}}]}],["$","li",null,{"children":["$","@4",null,{"tweet":{...}}}]}]]}]

The J0 line now has an extra child — the new Suspense boundary, where children is pointing to reference @3. It’s interesting to note here that @3 has not been defined yet at this point! As the server finishes loading the tweets, it outputs the rows for M4 — which defines the module reference to the Tweet.client.js component — and J3 — which defines another React element tree that should be swapped into where @3 is (and again, note that the J3 children are referencing the Tweet component defined in M4).

Another thing to note here, is that the bundler put ClientComponent and Tweet into two separate bundles automatically, which allows the browser to defer downloading the Tweet bundle until later!

Consuming the RSC format

How do you turn this RSC stream into actual React elements in your browser? react-server-dom-webpack contains the entrypoints that takes the RSC response and re-creates the React element tree. Here’s a simplified version of what your root client component might look like:

import { createFromFetch } from 'react-server-dom-webpack'
function ClientRootComponent() {
  // fetch() from our RSC API endpoint.  react-server-dom-webpack
  // can then take the fetch result and reconstruct the React
  // element tree
  const response = createFromFetch(fetch('/rsc?...'))
  return <Suspense fallback={null}>{response.readRoot() /* Returns a React element! */}</Suspense>
}

You ask react-server-dom-webpack to read the RSC response from an API endpoint. Then, response.readRoot() returns a React element that gets updated as the response stream gets processed! Before any of the stream has been read, it will immediately throw a promise — because no content is ready yet. Then, as it processes the first J0, it creates a corresponding React element tree and resolves the thrown promise. React resumes rendering, but when it encounters the not-yet-ready @3 reference, another promise gets thrown. And once it reads J3, that promise gets resolved, and React resumes rendering again, this time to completion. Therefore, as we stream the RSC response, we will continue updating and rendering the element tree we have, at chunks defined by Suspense boundaries, until we finish.

Why not just output plain HTML?

Why invent a whole new wire format? The goal on the client is to reconstruct the React element tree. It is much easier to accomplish this from this format than from html, where we’d have to parse the HTML to create the React elements. Note that the reconstruction of the React element tree is important, as this allows us to merge subsequent changes to the React tree with minimal commits to the DOM.

Is this better than just fetching data from client components?

If we need to make an API request to the server anyway to fetch this content, is this really better than making a request to fetch just the data and then doing the rendering completely in the client, as we do today?

Ultimately, it depends on what you are rendering to the screen. With RSC, you get denormalized, “processed” data that maps directly to what you are showing to the user, so you win out if you are only rendering a small slice of the data you would be fetching, or if the rendering itself requires a lot of javascript that you would like to avoid downloading to the browser. And if rendering requires multiple data fetches that depend on each other in a waterfall, then it is better that the fetching happens on the server — where data latency is much lower — than from the browser.

But… what about server-side rendering?

I know I know I know. With React 18, it is possible to combine both SSR and RSC so that you can generate html on the server, and then hydrate that html with RSC in the browser. Stay tuned for more on this topic!

Updating what your server components are rendering

What if you need your server components to render something new — for example, if you are switching between viewing the page for one product to a different product?

Again, since the rendering happens on the server, this requires another API call to the server to get the new content in RSC wire format. The good news is, once the browser receives the new content, it can construct a new React element tree, and perform the usual reconciliation diffing with the previous React tree to figure out the minimal updates necessary to the DOM, all while retaining state and event handlers in your client components. To the client components, this update would be no different from if it had happened entirely in the browser.

For now, you must re-render the entire React tree from the root server component, though in the future, it may become possible to do this for sub-trees.

Why do I need to use a meta-framework for RSC?

React team has said that RSC is initially meant to be adopted via meta-frameworks like Next.js or Shopify Hydrogen, instead of directly used in plain React projects. But why? What does a meta-framework do for you?

You don’t have to, but it’ll make your life easier. Meta-frameworks provide friendlier wrappers and abstractions, so you never have to think about generating the RSC stream in the server, and consuming it in the browser. Meta-frameworks also support server-side rendering, and they are doing the work to ensure that the server-generated html can be properly hydrated if you are using server components.

As you saw, you also need cooperation from your bundler to properly ship and use client components in the browser. There is already a webpack integration, and Shopify is working on the vite integration. These plugins need to be part of the React repo, because many of the pieces required for RSC are not published as public npm packages. Once developed, though, these pieces should be usable without a meta-framework involved.

Is RSC Ready?

React Server Components is now available as an experimental feature in Next.js and in the current Developer Preview for Shopify Hydrogen, but neither is ready for production usage. In future blog posts, we will be diving into how each of these frameworks are using RSC.

But there’s no question that React Server Component will be a big part of React’s future. It is React’s answer to faster page loads, smaller javascript bundles, and shorter time-to-interactive — a more comprehensive thesis on how to build multi-page applications using React. It may not be ready yet, but it will soon be time to start paying attention.

Plasmic on React server components

Plasmic is a page builder and CMS that lets content creators build landing pages and other parts of high-performance React websites and storefronts, and free up developers from working on content pages. It has deep support for React, letting you drag and drop your existing React components onto the canvas.

Performance enablers like React server components are highly relevant to both ourselves and our customers.

For the brave: we now have a demo of Plasmic working with Shopify Hydrogen. This means you can visually build pages within Shopify Hydrogen websites, and even drag and drop your existing React components in the editor.

Or get started with Plasmic to start building visually in your own codebase!

Many thanks to Hassan and Josh for reviewing earlier drafts of this post!

Follow @plasmicapp on Twitter for the latest updates.