DRAFT 🚧

The surprising complexities of React server components

Authors
Last updated
 

React server components are an exciting development that will have significant impact on architecture of React apps that care about page load performance.

This post will deep-dive into understanding and using React server components, and exploring the various nooks. There are new layers of complexity introduced here, and server components also interplay with other parts of React such as SSR and Suspense.

We at Plasmic make a visual builder for React. We’re actively researching how to make it work with React server components, and performance is in general important to our user base of Jamstack websites and headless commerce.

An important note is that React server components are still in early experimental state, so it’s important to withhold judgement about the design of server components themselves until the associated features and docs are complete.

Contents:

Pop quiz

For those of us who come in with a bit of background knowledge on RSCs, let’s start off with a quiz!

What does ClientComponent see as children ?

const ClientComponent = ({children}) => children
<ClientComponent><ServerComponent/></ClientComponent>

How many times will you see the substring li in the final HTML response from a RSC + SSR render of this ServerComponent?

// maybe need a clientcomponent wrapping?
const ServerComponent = () => (
  <ul>
    {Array(99).map(() => (
      <li />
    ))}
  </ul>
)

What HTML do you see in the following after RSC + SSR run?

ClientComponent = ({ value }) => {
  useState(value)
  return <span />
}

ServerComponent = () => (
  <div>
    <ClientComponent prop={value} />
  </div>
)

renderToString(<ServerComponent />)

What are the client bundles you’re sending in the following?

ServerPage1 = () => (
  <div>
    <ClientComponentA />
    <ClientComponentB />
  </div>
)
ServerPage2 = () => (
  <div>
    <ClientComponentB />
    <ClientComponentC />
  </div>
)

Background on bundles and hydration

Mainly: bundle- and hydration-less components. This affects page load performance.

[The usual elaboration about JS bloat and hydration and whatnot, showing animations and timelines.]

14Wdm6uBxD2ZmW4UrvnMMCZhOP_q25SXexptWXMWMvQ.png

With server components, you can hydrate just the parts of the pages you need. This animation from Marko illustrates this:

1-2OZnSbvGpKotF-ovFLamPjfJ0ikXJaVP4ZBkfciDQ.gif

It doesn’t mean serving zero JS. In fact, we’ll soon see exactly how much JS is still in fact needed.

Refresher on the new programming model

How exactly do you use React server components, and what happens with all your existing components?

There are three component types: server, client and shared. In short:

  • Server components run only on the server. They have direct access to server resources, so can issue database queries, etc. They run once per request, so don’t have useEffect, useState, useRef, or the other special built-in lifecycle hooks.
  • Client components run only on the “client.” They have useEffect, useState, useRef, etc.
  • Shared components run in either place. They can’t access server-side resources or lifecycle hooks.

Every React component you’ve ever written is either a client or shared component. The new thing is server components.

Supposedly, all client components will be renamed to .client.jsx, server components will be named .server.jsx, and shared components will be just .jsx.

Your app is expected to be a mix of these. So a React tree might look like this, where orange nodes are server components and blue nodes are client components.

image.png

To use RSCs, a server component is always the root entry point.

Server components can import/render client components, but not vice versa. That would imply loading/running the server code on the client, but the server code is supposed to be isolated from the client and has access to resources that the browser doesn’t have.

// ClientComponent.client.jsx
// not ok:
import ServerComponent from './ServerComponent.server'
const ClientComponent = () => (
  <div>
    <ServerComponent />
  </div>
)

So how can we see blue nodes nested under orange nodes? While you can’t render server components from client components, you can use composition, all from within an outer server component:

const ClientComponent = ({ children }) => children

const OuterServerComponent = () => (
  <ClientComponent>
    <ServerComponent />
  </ClientComponent>
)

We’ll dissect the above in the later section on composition.

You can re-evaluate the whole app tree from the root. Importantly, the client-side renderer PRESERVES CLIENT STATE when merging in the latest tree.

In the future, you’ll be able to re-evaluate partial server subtrees, not just from the root. (Source)

Here are the full set of rules, excerpted from the RFC:

Server Components: As a general rule, Server Components run once per request on the server, so they don’t have state and can’t use features that only exist on the client. Specifically, Server Components:

  • May not use state, because they execute (conceptually) only once per request, on the server. So useState() and useReducer() are not supported.
  • May not use rendering lifecycle (effects). So useEffect() and useLayoutEffect() are not supported.
  • May not use browser-only APIs such as the DOM (unless you polyfill them on the server).
  • May not use custom hooks that depend on state or effects, or utility functions that depend on browser-only APIs.
  • May use server-only data sources such as databases, internal (micro)services, filesystems, etc.
  • May render other Server Components, native elements (div, span, etc), or Client Components.
  • Server Hooks/Utilities: Developers may also create custom hooks or utility libraries that are designed for the server. All of the rules for Server Components apply. For example, one use-case for server hooks is to provide helpers for accessing server-side data sources.

Client Components: These are standard React components, so all the rules you’re used to apply. The main new rules to consider are what they can’t do with respect to Server Components. Client Components:

  • May not import Server Components or call server hooks/utilities, because those only work on the server.
    • However, a Server Component may pass another Server Component as a child to a Client Component: <ClientTabBar><ServerTabContent /></ClientTabBar>. From the Client Component’s perspective, its child will be an already rendered tree, such as the ServerTabContent output. This means that Server and Client components can be nested and interleaved in the tree at any level.
  • May not use server-only data sources.
  • May use state.
  • May use effects.
  • May use browser-only APIs.
  • May use custom hooks and utilities that use state, effects or browser-only APIs.

Shared components

  • May not use state.
  • May not use rendering lifecycle hooks such as effects.
  • May not use browser-only APIs.
  • May not use custom hooks or utilities that depend on state, effects, or browser APIs.
  • May not use server-side data sources.
  • May not render Server Components or use server hooks.
  • May be used on the server and client.

SSR renders client components on the server

Simply describing “components that render on the server” makes it sound just like the SSR that is already in widespread use in frameworks like Next.js. But the two are very different! The key difference is that server components do not re-render on the client, whereas SSR components do re-render.

SSR is just an initial snapshot of the page as HTML. This gives you faster First Contentful Paint. But before it becomes interactive, you still need to send the full bundles of code and data—all the JS for the components on the page, as well as all the raw data that went into rendering those components (for instance, the output of getServerSideProps() from Next.js).

Instead, React server components work WITH SSR. SSR is running the client renderer—it’s evaluating ClientComponents, not ServerComponents. The sequence is:

RSC on server → SSR on server → browser

So “client components” are a misleading label, because these will likely be running on the server first as well!

I feel like there has to be better names for these, but so it is for now:

  • React server components (RSC): runs server renderer on the server
  • Server-side rendering (SSR): runs client renderer on the server
  • Client-side rendering (CSR): runs client renderer on the client (browser)

Note that the above can stream to each other, so don’t have to run in strict order. The browser can start rendering while SSR is still streaming, for instance.

Note also that when we talk about SSR, this also extends to static site generation (SSG). Shipping fewer bundles would make just as much sense for static sites!

The React team’s 2020 demo shows raw / “naked” RSC without SSR. This has a number of implications that tend to throw off folks looking toward it as a model of how React server components will ultimately work:

  • Since there’s no pre-rendered HTML, it’s initially just an empty shell page! This behavior resembles a single-page app.
  • It then performs a request to the server to fetch the initial RSC-rendered React tree.
  • The server response directly contains what has only been called “the React server component data stream.” We’ll look at this next and refer to this as the “wire format.”
  • The overall performance is not reflective of what apps will ultimately look like.

But ultimately, with SSR, apps should look quite different—you’d still get HTML down to the client as the initial payload, and component stream data would need to be embedded in there so that there isn’t another fetch to the server immediately after page load. But first we need to understand what this wire format even is.

Wire protocol and markup duplication

We know that server components must run on the server. So what gets sent to the client renderer?

Well, we know that server components can render both basic tags as well as client components:

const ServerComponent = () => (
  <div>
    <ClientComponent prop1={value1} />
  </div>
)

Hence, the encoding must be able to represent more than just basic HTML tags, but indicate which ClientComponents to render, along with their props too.

This in turn means that we need assistance from the bundler, in order to dereference the correct class or function for that component. Also, the props must be JSON serializable. This means you can’t do things like:

const ServerComponent = () => (
  <ClientComponent onClick={() => {}} subcomponent={ServerComponent} date={new Class()} />
)

React server components stream protocol currently looks something like this. You have one line for every client component to be referenced, looks like:

    M1:{name:”ClientComponent”, id: “ClientComponent.123.js”,...}

And the last line is the whole app in wire format, which looks like:

    J0:[”$”,”div”,null,{”children”:[”$”, “@1, null, {...}]}]

But what about SSR? SSR needs to be able to send down HTML that the browser can immediately render, not just objects that need a React runtime to decode and render into HTML.

But you also can’t send just the HTML, since the ClientComponents must receive their props—including children props!

So the approach is to have an HTML streaming renderer that embeds the JSON chunks in the HTML response. The HTML and JSON are now duplicating the same content (more on this later). There are some potential optimizations to avoid repeating text content — “e.g. the JSON could have special markers like “$H” that tell React to reuse the HTML text node’s value.” (source)

This may seem bloated, but you can contrast this with SSR frameworks like Next.js that embed the raw data from getServerSideProps into the HTML response. The main tradeoffs here are:

  • RSC: You get denormalized, “processed” data. It can be smaller since you’re only showing what’s ultimately rendering on the screen. But it could be larger as well, if you’re rendering the raw data into significantly expanded markup.
  • SSR: You get the raw, “unprocessed” data. This can be larger since it may include more than what’s needed to render data onto the screen. But critically, you also need to load all the JS bundles to render/”process” this data.

So what happens when you have an RSC that renders a ClientComponent? Something like this:

    ClientComponent = ({value}) => {
      useState(value)
      return <span/>
    }

    ServerComponent = () =>
      <div>
        <ClientComponent prop={value}/>
      </div>

    renderToString(<ServerComponent/>)

    ==>

    <div>
      <span/>
    </div>

Controlling hydration with decomposition of client/server components

Partial hydration is often described as “islands of interactivity,” as in small atomic parts of your page that need interactivity, such as a carousel or a select dropdown. But often, interactivity can be more abstractly embedded throughout the page.

Consider the example of Hacker News collapsible comments. You have a bit of interactivity sprinkled throughout the page tree, not relegated to “leaf” components. It is effectively “wrapping” content that you don’t want to hydrate—that content is the bulk of the payload of this page, and we really don’t want to have to redundantly send a data bundle or hydrate that content.

1R1srk4m84B9GCn3jcPwJ0ZgeVPIhzHRKSJ8He94FEQ.gif

It would be trivial to write a tiny bit of vanilla Javascript for this, jQuery style:

document.addEventListener((ev) => {
  if (ev.target.classList.includes('toggle')) {
    // toggle display:none on the neighboring div
    showOrHideComment(ev.target)
  }
})

But how would we do this the declarative React way? If we look at a single comment, this might be how we write things pre-RSC.

    const ClientComment = ({text}) => {
      const [collapsed, setCollapsed] = useState(false);
      <div
        style={{display: collapsed ? 'none' : undefined}}
        onClick={() => setCollapsed(!collapsed)}
      >
        <Markdown content={text}>
      </div>
    }

But with RSC, this is non-ideal, since we are sending Markdown and the text content as data. Just to make things interesting, we’re pretending that rendering the content requires Markdown, but even if it didn’t and we were just rendering in a div, we ideally want to avoid sending down the text as data and then hydrating that. How would you fix this?

You cannot call from a ClientComponent to a ServerComponent. In fact, you must always ensure there is a path from the root server component to any component that you don’t want bundled.

But you CAN nest a ServerComponent under a ClientComponent as children (all within an outer ServerComponent). So if we refactor out a Collapsible client component, we could isolate the server-only resources—the Markdown module and the content data—from the client, and the client would only need to touch interactivity:

    const ServerComment = ({text}) =>
      <Collapsible>
        <Markdown content={text}>
      </Collapsible>

    const Collapsible = ({children}) => {
      const [collapsed, setCollapsed] = useState(false);
      <div
        style={{display: collapsed ? 'none' : undefined}}
        onClick={() => setCollapsed(!collapsed)}
      >
        {children}
      </div>
    }

But you have a whole tree of these. So you need to recursively handle this:

    const ServerComment = ({node}) =>
      <Collapsible>
        <div>{node.text}</div>
        {node.child && <ServerComment node={node.child}/>}
      </div>

Key to effective use of RSCs is having heightened awareness around the hydration boundaries and knowing how to compose or decompose components to isolate client from server. This composition lets us dart in and out of hydration as we want throughout the app tree.

Understanding children props across the client/server boundary

React is a lazy evaluator.

Usually, when you pass children elements:

    <Parent>
      <Child/>
    <Parent>

The Parent sees as its children the unevaluated JSX element whose type is Child . React performs lazy evaluation of JSX elements, outside-in. Components are able to take advantage of this laziness by inspecting and manipulating these elements (for better or worse), and controlling what is further evaluated or not:

    const Parent = ({children}) =>
      // Don't actually do this, always use React.Children instead.
      children ? cloneElement(children, {...}) :

But what exactly does a ClientComponent see, when it’s passed children that was a ServerComponent?

<ClientComponent>
  <ServerComponent />
</ClientComponent>

A key design decision is that server components are fully evaluated to tags! So ClientComponent sees:

<div>...</div>

This interface—that children is just a bunch of normal HTML tags—means that ClientComponent is then free to do a bunch of dynamic stuff. It can unmount, re-mount, clone, and mount the children any number of times, since they’re just normal tags:

    const DoStuffWithChildren = ({children}) => {
      const [shown, setShown] = useState(false);
      return <div>
        <button onClick={() => setShown(!shown)}/>
        {shown && <>{children}{children}{cloneElement(children, {...})}</>}
      </div>
    }

You can contrast this with other frameworks, where either you’re not allowed to inspect/manipulate children as such, or you’re limited in what you can do with the children (potentially “breaking” the app).

However, this interface also doesn’t come for free. This whole tree of JSX elements had to come from somewhere. These tags are read from the wire stream embedded in the HTML response.

But isn’t this “hydration”?

When you have the following server component-rendered HTML being sent to the client:

<div>
  Hello
  <span>world</span>
</div>

It must come with a corresponding object stream (ignoring text deduplication):

Then this becomes an actual JSX element tree:

jsx('div', {}, 'Hello', jsx('span', {}, 'World'))

The above happens one-time, then this gets fed back to the renderer when we do:

const ClientComponent = ({ children }) => <div>{children}</div>

So from there you still have to hydrate, reconcile, and commit.

The main work we’re not doing is the userland render of the ServerComponent tree—which would drag along all the module dependencies of executing that code.

Doesn’t this all mean there is still a deserialization/JSX element/hydration cost to pay on the tag tree, even for server component rendered tags, to convert them into JSX elements? Interested to see how expensive this will be.

The above is given the state of things so far, but there is perhaps room for future optimization here, if you can assume that the initial tree rendered on the client will match the HTML sent from the server. This is similar to the

Why the HTML stream may not match children

So what happens when you have the following?

const ClientComponent = ({ children }) => {
  const [shown, setShown] = useState(false)
  return <div>{shown && children}</div>
}

;<ClientComponent>
  <ServerComponent />
</ClientComponent>

The SSR output will just be an empty div. But React must ensure that ClientComponent receives the original children props that were passed to it at SSR time, even if that doesn’t match the SSR output. So it can’t rely on the HTML output, and must include the JSON stream in the HTML as well.

The tag parsing is an optimization that would only apply when the HTML output and the children prop inputs do match up.

From Dan Abramov:

As an additional optimization on top of this inlining, we’ll want to deduplicate text content between them. E.g. we shouldn’t need to repeat all the story titles in the Flight data structure even though they have just appeared in the HTML itself. We need to add a capability in React to “skip” specially marked Flight text and keep using the one already in the DOM, thereby letting us deduplicate it.

More flexible data fetching

One of the nice things about React is that it lets you colocate different concerns for the same component—from the presentational markup to behavior/logic to styling to data fetching.

But the story for efficient data fetching server-side hasn’t been so simple. Server-side data fetching is ideal for many sites because (1) it avoids needing to fetch all the way from the client browser whereas server render environments tend to have much lower latency to server-side data sources, (2) the server renderer has unfettered access to server-side resources like the database, and (3) SSR pre-rendering masks spinners that would normally be shown as part of client fetch waterfalls.

However, SSR frameworks like Next.js necessarily require up-front data fetching at the page boundary, since React rendering is synchronous and one-pass. There are tools like Apollo’s getDataFromTree and react-ssr-prepass that can help by simulating server-side Suspense, but it’s not always straightforward (or even possible) to integrate these into SSR environments, and it would be nice to have a cleaner approach.

Server components will be able to directly fetch data—React will allow triggering Suspense-style fetches. (Note that it’s unclear still whether data fetching will require React server components, or whether it’s possible to do with just SSR, as suggested in the React 18 demo. More discussion.)

Note that the React team encourages fetch-as-you-render as the optimal model. This yields the best performance, since you start fetching as early as possible, and concurrently render the React tree. However, this means you would still need to know up front the full set of data you’ll need to fetch throughout the page, which is not much different from the current approach of getServerSideProps. Unless you use Relay, this is not as ergonomic or flexible.

In practice, I think ad-hoc server-side fetching will be a major practical benefit of server-side data fetching. Server components are discussed primarily in the context of page load performance, but even if your app does not particularly care about minimizing bundle size or load times, this feature alone would be a significant improvement to developer experience, particularly for apps that perform heavy database querying.

Note that you don’t have total freedom in being able to access data. You need to ensure that data is accessed from a server component, and there must be a direct path from the root server component. And instead of passing down info , you must now pass in children. sometimes you have to do the technique above of decomposing your components so that the server parts are not using it.

Implementations in meta-frameworks

The React core implementation is in react-server-dom-webpack. react-client is the client side re-creating the real React element tree. Fizz is the new HTML-streaming server in React 18. Flight is the RSC-streaming server.

What’s interesting is some of the subtle ways the following implementations differ. They may eventually converge, but for now they are very different.

Next.js has some very early demos, but these have a number of infelicities. Suspense streaming doesn’t actually work as expected (dynamically loads via client). Various other things like asset/style loading are just not working yet. A significant blocker is a lack of server context (normal React contexts don’t work) for RSC.

Hydrogen is far along in but patches in many of the gaps with their own abstractions. It uses Vite, which doesn’t have an official react-server implementation. They’re working on getting this in. But currently there’s a lot of forked implementation, like the wire protocol, which in React core is tied into react-server-dom-webpack. Because they control the wire protocol and their own react-client, they can do tricks like having React contexts work for server components. However, the design seems to capture and keep close to the spirit of the RSC direction. Hydrogen is impressively far along on making React server components (plus other associated good things such as streaming SSR) usable in production.

Expect follow-up posts from us with more details on how these implementations of RSC work!

More open questions

Lots of ecosystem changes needed, as described above.

How much of the hydration cost do we get to avoid if we have to deserialize the DOM into JSX elements?

How will bundling work? This is an exercise left to the meta-frameworks, but you could ship one bundle per ClientComponent tree across all ServerComponents, or serve one bundle per root ServerComponent page. These all have trade-offs.

Server rendering implicitly creates a server API. This would likely be exacerbated with partial subtree re-evaluation. As you redeploy updated ServerComponents and ClientComponents, how does this impact existing client sessions that call into the server? How do you manage these lifecycles?

Will streaming work with re-evaluations?

How can this be brought to React Native?

Resources and references

  • RSC RFC
  • RSC video and demo and swyx write-up
  • RSC Q&A video and swyx write-up

Follow @plasmicapp on Twitter for the latest updates.