So, why Server Components?

So, why Server Components?

Featured on Hashnode

Let's talk components .. but on the server.

But .. why would you want something like that? Are good old (client) components not good enough?

Of course, they are! Client components are a perfect match for rich, interactive UIs that implement immediate feedback loops. However, as it usually goes, each concept implies a certain set of advantages, as well as a certain set of limitations.

Till late 2020, the only option to render a component was doing it client-side, in the browser. Let's discuss some of the major disadvantages of this approach and explore how the concept of React Server Components helps us push the entire ecosystem forwards by addressing these limitations.

Prerequisity

As most of the images in this article are created as black content on white background, please switch from dark mode to white mode in order to be able to read the images properly. Thanks!

Client Components limitations

1. Long TTI (Time to Interactive)

Let's recap how client-side rendering with the help of SSR works.

  1. The client requests a page from the server.
  2. The server builds the JS bundle and hopefully also some basic HTML to give the user a fast, static response.
  3. The server returns the JS assets to the client.
  4. The client renders the HTML.
  5. The client loads, parses, and executes the JS.
  6. The client hydrates the JS into HTML to achieve the desired interactive page.

img

The red horizontal line in the image indicates where TTI would sit. The heavier the JS bundle, the longer loading, parsing, executing, and hydrating the JS take.

2. Dependencies bloat the JS bundle

The logic is somewhat linear. The heavier the bundle, the longer will it take to transfer it over the network barrier to the browser and the longer will it take to render it. However, an example speakrs a thousand words, so let's bring in some code.

I borrowed this piece of code from the React Server Components demo Notes application.

import {format, isToday} from 'date-fns';
import excerpts from 'excerpts';
import marked from 'marked';

import ClientSidebarNote from './SidebarNote.client';

export default function SidebarNote({note}) {
  const updatedAt = new Date(note.updated_at);
  const lastUpdatedAt = isToday(updatedAt)
    ? format(updatedAt, 'h:mm bb')
    : format(updatedAt, 'M/d/yy');
  const summary = excerpts(marked(note.body), {words: 20});
  return (
    <ClientSidebarNote
      id={note.id}
      title={note.title}
      expandedChildren={
        <p className="sidebar-note-excerpt">{summary || <i>(No content)</i>}</p>
      }>
      <header className="sidebar-note-header">
        <strong>{note.title}</strong>
        <small>{lastUpdatedAt}</small>
      </header>
    </ClientSidebarNote>
  );
}

The code above describes quite a straightforward component. Most notably the component formats and renders a date-time value and formats and renders a piece of markdown that is a summary of a note.

To achieve this, the component uses 3 dependencies date-fns, excerpts, and marked. When we examine the dependencies closer, we can immediately see, they sum to ~80KB gZipped. Keep in mind this is only one client component and consider yourself, how large can the entire application get?

img

3. Susceptible to request waterfalls

Let's start with a quote:

Rendered JS does not guarantee an useful UI.

If we do all the work we discussed in Long time-to-interactive, to only present the user with something like this. We probably can agree we didn't do the best job in the initial user experience.

img

Now imagine, the spinner is rendered by a component tree similar to this.

import React from 'react';
import Spinner from 'components/Spinner';
import Something from 'components/Something';

const ChildComponentA = () => <Something />

const ChildComponentB = () => {
  const dataC = useDataC();

  return dataC
    ? <Something />
    : <Spinner />
}

const Root = () => {
  const dataA = useDataA();
  const dataB = useDataB({ skip: !dataA });

  if (!dataA || !dataB) {
    return <Spinner />
  }

  return (
      <>
        <ChildComponentA data={dataB} />
        <Something />
        <ChildComponentB data={dataB} />
    </>
  )
}

From the code above, we can derive the fact, that to present the user with a full user experience, we'll need to fire and wait for three sequential queries. All this, in sequence, after we fetched the JS bundle from the server and rendered the initial UI on the client.

img

It should be pretty obvious that the diagram is far from optimal. With the current set-up, we're firing three requests to the same server, for each request have to wait for the server to resolve the request by e.g. talking to DB, a close (micro) service, or maybe an FS and then returning the result. Three requests equal three round-trips from client to server and back.

4. The browser has only one thread

The browser still has only one thread reserved for JS runtime. This means, all the operations we've mentioned so far have to run with a single call stack.

5. The browser can't access server APIs

Quite obviously, the browser can access browser APIs like DOM manipulation APIs, fetch API, or Canvas/WebGL, while the server can work with e.g. environment variables, access local file-system, and can directly reach out to databases or services, which the client can only reach out via a controlled proxy.

This is all for the better, as it allows us to securely work only with exposed REST/GraphQL API to talk to the BE resources. However, there are use cases where a little more control would result in a more comfortable development experience. For E.g. developers tend to struggle to realize they only can access environment variables in Next.js in server-side code, as process.env differs on the client. You can definitely work around this by making the variable of the choice public by prepending its name with the PUBLIC_ prefix, but you always need to pay attention not to leak a private variable this way.

It would be great, if we could access process.env directly in a component, without limitations though, right?

Server components to the rescue

We managed to explore the intrinsic limitations of React's client components. These limitations are implied by the concept of rendering the component in the browser environment, or on the client if you will.

Let's now turn the list around and look into each of the limitations, one by one again, and discuss how Server Component help us counter the limitations by unlocking a completely new set of possibilities, by rendering them in the server environment.

1. Long Shorter TTI (Time to Interactive)

One of the amazing benefits of rendering components on the server is that we don't have to transfer as large JS bundles to the client anymore. If our application tree consists of 1000 components and we manage to (pre)render 500 of them server-side, we can be certain, that the JS bundle will be dramatically thinner. The less JS we need to transfer and then load, parse and execute on the client, the quicker will the initial experience be.

The rendered Server Components are not included in the JS bundle, they are rendered on the server and serialized into a special notation designed by the React.js team. This notation not only helps React@18/Next.js@13 transfer the code over the network barrier but also helps client-side React reconcile the component tree update without losing the application state.

To expand the context, let's pull up an example of a batch component update coming from the server to update the UI.

The data are again taken from the React Server Components demo Notes application.

M1:{"id":"./src/SearchField.client.js","chunks":["client5"],"name":""}
M2:{"id":"./src/EditButton.client.js","chunks":["client1"],"name":""}
S3:"react.suspense"
J0:["$","div",null,{"className":"main","children":[["$","section",null,{"className":"col sidebar","children":[["$","section",null,{"className":"sidebar-header","children":[["$","img",null,{"className":"logo","src":"logo.svg","width":"22px","height":"20px","alt":"","role":"presentation"}],["$","strong",null,{"children":"React Notes"}]]}],["$","section",null,{"className":"sidebar-menu","role":"menubar","children":[["$","@1",null,{}],["$","@2",null,{"noteId":null,"children":"New"}]]}],["$","nav",null,{"children":["$","$3",null,{"fallback":["$","div",null,{"children":["$","ul",null,{"className":"notes-list skeleton-container","children":[["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}],["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}],["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}]]}]}],"children":"@4"}]}]]}],["$","section","null",{"className":"col note-viewer","children":["$","$3",null,{"fallback":["$","div",null,{"className":"note skeleton-container","role":"progressbar","aria-busy":"true","children":[["$","div",null,{"className":"note-header","children":[["$","div",null,{"className":"note-title skeleton","style":{"height":"3rem","width":"65%","marginInline":"12px 1em"}}],["$","div",null,{"className":"skeleton skeleton--button","style":{"width":"8em","height":"2.5em"}}]]}],["$","div",null,{"className":"note-preview","children":[["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}]]}]]}],"children":["$","div",null,{"className":"note--empty-state","children":["$","span",null,{"className":"note-text--empty-state","children":"Click a note on the left to view something! 🥺"}]}]}]}]]}]
M5:{"id":"./src/SidebarNote.client.js","chunks":["client6"],"name":""}
J4:["$","ul",null,{"className":"notes-list","children":[["$","li","1",{"children":["$","@5",null,{"id":1,"title":"Meeting Notes","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"This is an example note. It contains Markdown!"}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"Meeting Notes"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","2",{"children":["$","@5",null,{"id":2,"title":"A note with a very long title because sometimes you need more words","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"You can write all kinds of amazing notes in this app! These note live on the server in the notes..."}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"A note with a very long title because sometimes you need more words"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","3",{"children":["$","@5",null,{"id":3,"title":"I wrote this note today","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"It was an excellent note."}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"I wrote this note today"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","4",{"children":["$","@5",null,{"id":4,"title":"Make a thing","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"It's very easy to make some words bold and other words italic with Markdown. You can even link to React's..."}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"Make a thing"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","6",{"children":["$","@5",null,{"id":6,"title":"Test Noteeeeeeeasd","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"Test note's text"}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"Test Noteeeeeeeasd"}],["$","small",null,{"children":"11/29/22"}]]}]}]}],["$","li","7",{"children":["$","@5",null,{"id":7,"title":"asdasdasd","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"asdasdasd"}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"asdasdasd"}],["$","small",null,{"children":"11/29/22"}]]}]}]}]]}]

That's a JSON-y-looking mess! At least it sure feels like that to the human eye. After all, it's a set of instructions for React runtime on how to update the application as a result of e.g. a user's action. These instructions are likely therefore not designed with human readability in mind. However, it's still pretty close to a JSON, so on a second glance, we can see some patterns there, right?

We can identify a bunch of shorter lines, e.g.:

M1:{"id":"./src/SearchField.client.js","chunks":["client5"],"name":""}
M2:{"id":"./src/EditButton.client.js","chunks":["client1"],"name":""}
M5:{"id":"./src/SidebarNote.client.js","chunks":["client6"],"name":""}

The first line is actually instructing React runtime to render a SearchField component, that is located in a file called client5. In other words, it's a pointer to the Client Component, which is located in a JS bundle chunk, called client5. Since it's a Client Component, it's yet to be rendered.

We can also clearly identify a line marking a suspense boundary, which is not that interesting.

S3:"react.suspense"

But there are also two more lines starting with J0 and J4, that look pretty expressive.

J0:["$","div",null,{"className":"main","children":[["$","section",null,{"className":"col sidebar","children":[["$","section",null,{"className":"sidebar-header","children":[["$","img",null,{"className":"logo","src":"logo.svg","width":"22px","height":"20px","alt":"","role":"presentation"}],["$","strong",null,{"children":"React Notes"}]]}],["$","section",null,{"className":"sidebar-menu","role":"menubar","children":[["$","@1",null,{}],["$","@2",null,{"noteId":null,"children":"New"}]]}],["$","nav",null,{"children":["$","$3",null,{"fallback":["$","div",null,{"children":["$","ul",null,{"className":"notes-list skeleton-container","children":[["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}],["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}],["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}]]}]}],"children":"@4"}]}]]}],["$","section","null",{"className":"col note-viewer","children":["$","$3",null,{"fallback":["$","div",null,{"className":"note skeleton-container","role":"progressbar","aria-busy":"true","children":[["$","div",null,{"className":"note-header","children":[["$","div",null,{"className":"note-title skeleton","style":{"height":"3rem","width":"65%","marginInline":"12px 1em"}}],["$","div",null,{"className":"skeleton skeleton--button","style":{"width":"8em","height":"2.5em"}}]]}],["$","div",null,{"className":"note-preview","children":[["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}]]}]]}],"children":["$","div",null,{"className":"note--empty-state","children":["$","span",null,{"className":"note-text--empty-state","children":"Click a note on the left to view something! 🥺"}]}]}]}]]}]
J4:["$","ul",null,{"className":"notes-list","children":[["$","li","1",{"children":["$","@5",null,{"id":1,"title":"Meeting Notes","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"This is an example note. It contains Markdown!"}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"Meeting Notes"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","2",{"children":["$","@5",null,{"id":2,"title":"A note with a very long title because sometimes you need more words","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"You can write all kinds of amazing notes in this app! These note live on the server in the notes..."}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"A note with a very long title because sometimes you need more words"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","3",{"children":["$","@5",null,{"id":3,"title":"I wrote this note today","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"It was an excellent note."}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"I wrote this note today"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","4",{"children":["$","@5",null,{"id":4,"title":"Make a thing","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"It's very easy to make some words bold and other words italic with Markdown. You can even link to React's..."}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"Make a thing"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","6",{"children":["$","@5",null,{"id":6,"title":"Test Noteeeeeeeasd","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"Test note's text"}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"Test Noteeeeeeeasd"}],["$","small",null,{"children":"11/29/22"}]]}]}]}],["$","li","7",{"children":["$","@5",null,{"id":7,"title":"asdasdasd","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"asdasdasd"}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"asdasdasd"}],["$","small",null,{"children":"11/29/22"}]]}]}]}]]}]

The lines J0 and J4 are in fact two atomic components - div and unordered list - that were rendered on the server and completely serialized to be dumped into the view on the client. The browser does not have to perform any work to render these. Of course apart from the reconciliation and the update itself. Very cool, right?

From the client-server communication point of view, the diagram would now look something like this.

img

The green rectangles mark differences from the pre-React@18/Next.js@13 we mentioned in Long TTI. To sum it up, notable facts are:

  • Server bundles only the Client Component to the JS bundle
  • Server renders Server Component into instructions for React runtime, not HTML
  • The updates are streamed to the client
  • The client can reconcile the changes while keeping the client's state

The bottom line is - less JS, shorter TTI!

2. Dependencies no longer bloat the bundle

0-bundle size components are another great argument for Server Components. Since the components are compiled on the server. There is no need for us to include the component dependencies inside of the JS bundle.

From the implementation point of view, when server-side React@18 encounters a Server Component in the component tree, it compiles the component into atoms. For E.g. in case your component uses date-fns/format to format a date, it'll happen on the server and the result of the operation will be transferred to the client, without the dependency itself.

Say farewell to bundling heavy dependencies to execute on the client.

To update the image we used above, we can just scratch the ~80 gZipped KBs and replace it with straight 0.

img

3. Request waterfalls with zero round-trips

A Server Component is always closer to the hardware than a Client Component. Every request that would go from client to server and back now lives fully on the server side. To put this thought into a diagram, let's update the request waterfall we discussed in to reflect that.

img

The clear difference to the Client Components architecture is that we only need to really do one round-trip. Notice the colorful arrows, that denote the actual requests on the component level that stay server-side. If the waterfall exists, it's way faster.

4. The server has more than one thread

This one is pretty obvious. While browsers still reserve only one thread for JS runtime, servers usually feature more than a single thread for Node.js. More bandwidth for Server Components!

5. The server (obviously) can access the server APIs

When rendering components on the server, we can directly access e.g. process.env variables, therefore we can seamlessly integrate corresponding logic directly into our components. That's quite slick, right?

Moreover, we can directly access local databases, file systems, or (micro)services. I'm not implying accessing a DB directly from a component is the best idea, but you get the point. However, there is a great use case for accessing an FS directly in the component e.g. when implementing a static blog by just mapping the fs into blog pages, which would not be possible on the component level before React@18.

Summary

We explored 5 pre-React@18 pain points that Client Components do suffer from. Server Components allow us to view components in a different light (meaning environment), which turns out to provide implicit solutions for all of the pain points we explored.

Hopefully, the article gave you a better idea of why React@18 pivoted towards Server Components, why Next.js@13 treats a component as a server one by default, unless told not to, and overall, how will the ecosystem look in the near future.