React Server Components (RSC) are the culmination of a long and wide architecture overhaul of React itself. It implies rethinking our understanding of best practices and unlocks an immense amount of optimizations that weren’t otherwise possible. From bundling size to safer requests, our apps can become much better for it and the end-user user experience (UX) will benefit greatly. But in order to leverage this, we must make sure to understand them.
The road to server components
The architecture shift began years ago with Concurrent React. If you have been around the React ecosystem for a while, chances are you heard of Suspense. In a nutshell, React Suspense enables developers to create slots at the rendering tree which will be filled by asynchronous components once data is available for them.
The above block will render “waiting for it” until
ServerComponent finishes fetching data and rendering on the server. It will be streamed down to the client-side and patched into the right slot by React. Finally, there will be a re-render where the fallback text or component is replaced by the contents of
Unlocking the powers of an asynchronous rendering tree were fundamental to this next evolution of React’s component-based architecture. RSC are able to only ship to the client resources that are used during rendering.
The unaware developer can still ship bloat to the client-side, but it’s significantly harder. Whatever logic runs in the function body stays in the server, while the returned JSX goes to the client.
Unused translations and condition statements, anything that isn’t returned by the component’s renderer, doesn’t get to the client, potentially resulting in a lot less code for most apps. Nevertheless, the mental model around RSC is different. While the following component would probably only bring an unfair and greedy memory consumption for client-components, if made as a server-component, this would provoke an exponential scenario of bloat sent across the wire:
Note that each component is sending the entire list across the server/client connection. For a list with 10 items, we get 10 times those 10 items, plus the one time the data is being rendered. In this contrived example, this may look a bit on the nose, but many real-world apps follow a similar pattern on their SPAs (single page applications), to re-order items in a list, for example.
So, the first concept that needs to be well understood and acknowledged is that server components and client components are completely different beasts when it comes to dealing with the serialization boundary.
To transmit data between server and client, we need to first serialize data so it can be more easily transmitted and stored. In general, different formats can be used and in React we used JSON for that. So, In short, this boundary is the HTTP bridge our data must cross when shipping from server to client. Since only serialized data can traffic within HTTP requests, we call this “bridge” the serialization boundary.
💡 Serialized data means: no functions, date objects, non-enumerables, nor circular references.
Either via Remix’s loader and action functions or Next.js’
getServerSideProps, we have dealt with the serialization boundary when doing server-side rendering (SSR) on React.
On previous fullstack React applications, this meant we had to be careful when rendering a route — that’s when our data would cross this bridge. But on React Server Components, the bridge is more like a fence surrounding our components, data is encapsulated within — it’s cheap to get data in (see more on data fetching below) but it’s poorly recommended to pass props to children unless absolutely necessary, as we’ve seen in the above section.
Following the best practices for the web, we can come to an agreement that React also needed a solution for data fetching on the server-side for RSC to be usable. Without a caching layer, requests will be duplicated and this will be a non-starter for most apps. This is why our little rendering library got fullstack powers!
If you worked with pure React (without a fullstack framework around it), chances are you’re familiar with a version of the below example for filling your component with data:
Depending on your exposure level to React, you may think that code is quite straightforward. Well, it’s not. Let’s walk through what’s going on:
gettaBoiis defined within the module scope.
We create an empty state for the first render where boi is an empty string.
React renders with a broken
<img />tag. (By the way: empty src attribute is not valid HTML for images).
useEffectkicks in asynchronously. It declares goFetch and calls it. This only happens once because
useEffect’s dependency array is empty, there are no triggers to start this side effect.
Re-render is blocked by the await and as soon as the response is fetched and parsed, it then calls
setBoi— adding a new state.
The new state triggers a re-render and the image is finally fetched, parsed, and rendered by the DOM.
Reading through the six steps involved in getting that image rendered, we can probably all agree this is not a straightforward process. It’s just the one we’re used to. React Server Components reduce the mental overload on getting this done significantly.
In order to achieve that, React reached out into the fetch API and made a few changes in order to be able to track requests firing from different components during the same render process. This means that requests are fired only once for the same render. This also means that rendering
/dashboard and bringing together two RSCs:
<UserAvatar /> and
<UserPreferences /> will reach the
/user-data endpoint only once. So, let’s bring our dog fetcher component back, this time with server components.
An asynchronous fetch request is fired, blocking function execution.
Response arrives and it’s parsed into json, this object has a “message” key that is aliased to dog.
dog is the source url used by the <img /> element.
That component is shipped as static HTML to the browser and rendered immediately on arrival.
💡 Check it live at: goodbois.vercel.app, and the code at https://github.com/contentful/fetch-patterns-react
With that, we can conclude React is leaving the realm of being a “UI rendering library” — it’s a tad more than that now and way more opinionated than it once was. Using React Server Components implicates that in architecting a caching layer, caching requires a routing system, and ideally leverages this with pre-fetching and preloading of resources to maximize deliverability performance. This is why a production-ready implementation of RSC outside of the context of a framework is difficult.
At the time of this writing, Next.js is the only framework with React Server Components ready for production use. In the following sections, we will evaluate a few different patterns to combine server and client components that can already be used in production as well as some more experimental APIs that are close(ish) to general availability.
Next.js: app directory
In order to give React Server Components’ first-class support, Next.js engaged in a complete re-engineering of its router. Routing is a big piece of any fullstack framework, but in doing so, Next.js was also able to leverage server components to offer big UX and DX improvements.
It is possible to leverage the file-system based routing to establish error boundaries and suspense boundaries for an entire route. Defining those is just the same as creating any other component, except that they obey a file-naming convention.
Within each page, it’s also possible to create more granular boundaries and pass the fallback components as you would in any other React application.
Thanks to the asynchronous nature of RSC, it is possible to call promises from within its rendering logic. This includes fetching data with much less boilerplate like we have seen above. But we can actually go a bit further and use different patterns which will increase the performance and UX of our apps:
In that case, our page fetches data with two different methods:
getAuthorPosts. But in a very procedural way. First we fire the fetch, wait for the response, then we fetch albums, wait for the response just so we can finally render the component with all the data.
This isn’t the most efficient way to fetch for multiple endpoints. Firing both requests in parallel and awaiting the result of both will yield the same results, but in a more efficient way as it has both requests in-flight simultaneously.
Though, oftentimes not all the requests are fundamental for the user experience. In such cases, it is possible to allow rendering to happen as early as possible and instruct the framework to stream down data as soon as it is ready.
In the above snippet,
authorData will be waited by the renderer while
postsData will be passed down as a promise, before forwarding the promise into a suspense boundary that will first load a fallback component in case the data isn’t made available by DOM hydration.
React hooks aren’t executed on the server. No exceptions. In SSR, the Node.js runtime will skip each and every hook and, once in the browser and hydration kicks-in, everything gets run. This is why a lot of libraries and frameworks create workarounds in order to provide pre-filled data to the static mark-up.
Server components do not have that “luxury” because their logic is not shipped to the client. Though they are hydrated, some of the conditions and a lot of the code stayed on the server. Therefore, it is not, and probably will never be, possible to run hooks on RSC.
As React becomes a fullstack framework, naturally we start having two different bundles: one for the server and one for the client. And as components have different APIs available to them depending on the runtime they render on, we must help our frameworks (and compilers) to understand to which bundle each component should go. Enters the pragma directive: use client.
By default, every component is a server component, so when preparing a component, use interactive. It’s required to add this directive on the first line of our file otherwise compilation and rendering will fail.
As an important side effect, once a rendering tree shifts toward the client-side with a client component, all its children will be client components. So, to maximize the benefits of RSC, it’s important to push user interactivity to the leaves (edges) of our rendering tree. Childless components will receive the data and mutate them in response to interactivity — while the bulk of the logic stays upward within the server components.
To implement and push mutations from any component, server, or client, most React-based frameworks have an implementation of “server actions.” These are special endpoints designated to respond to
DELETE actions being fired from a client. Their integration interface is the good old
<form> element (even if there’s some syntactic sugar involved) or its friends
Frameworks like Remix and Next.js both have their own implementations that work in some degree of similarity, but have different implementations and tradeoffs. They both are intended to address pending UI states and optimistic transitions, for example. And while Next.js’ server actions are experimental while Remix’s are completely stable, it already works in both server and client components.
Server components and its repercussions are a step in the right direction of a better, more performant, and high-quality web. Though they may not be suited for every kind of application using React nowadays, it’s important to evaluate the tradeoffs for each piece of your codebase where you intend to replace a regular client component with a server component. For greenfield apps, it will be interesting to observe the patterns going forward with the new defaults and ergonomics.