Simplifying React Server Components


1

An abstract image

Introduction

React is a JavaScript library for for building user interfaces created and maintained by Meta (formerly Facebook) and a community of individual developers and companies. It was originally released in 2013, and pioneered a lot of things that have since become common in frontend world, like stateful components, hooks... providing an easy way to build friendly, personalized and dynamic web applications.

A couple of months ago, the React team introduce a new kind of component Server Components that run ahead of time and are excluded from your JavaScript bundle. I've been doing a lot of experimenting with it especially in this blog, and I've answered a lot of my own questions ๐Ÿค“.

React is envolving!

Image from leerob

So, the goal of this post is to share what I've learned about latest React features, and hopefully help you get started with it.

But before we dive into the details, let's take a look at the problems React Server Components (or RSC for short) is trying to solve.

At the time of writing, React Server Components is only supported in Nextjs 13.4+, using their new "App Router". You can learn more about it here ๐Ÿ‘€.

The Problems

To put React Server Components in context, it's helpful to understand how Client Side Rendering (CSR) and Server Side Rendering (SSR) works. If you're already familiar with both, feel free to skip to the next heading!

Let's use this blog as an example to illustrate the differences between CSR and SSR ๐Ÿ’โ€โ™‚๏ธ.

Client side rendering

If the app use CSR, first the browser would send a request to the server that serves this application. Then the server would respond with an HTML file that look like this:

<!DOCTYPE html>
<html>
<body>
<div id="root"></div>
<script src="/static/bundle.js"></script>
</body>
</html>

Simplifying HTML
To make it easier to understand, I've stripped out all non-critical parts of the HTML (like the <head>).

That bundle.js script includes all the JavaScript code that is needed to render the blog. Once the JS is loaded, React will jump in and render our blog in that empty <div id="root"></div> element.

After that, our blog would dispatch some API requests to fetch the list of likes or views for this post, and update the UI accordingly.

The problem with this approach is that both rendering and data fetching take place in the browser itself. This means that the browser has to wait for the JavaScript to be downloaded and executed before it can render the page. This can lead to a poor First Contentfull Paint (FCP) and Time To Interactive (TTI) score. In other words, the user would have to stare at a blank page for a while before they can interact with the page ๐Ÿฅถ.

This problem tends to get worse as the app grows in size. The bigger the app, the more JavaScript it has to download and execute, and the longer the user has to wait.

We can improve the FCP and TTI score by using techniques like lazy load using React.lazy

And in the worst case, when the user's browser doesn't support or some how disable JavaScript, they would see a blank page forever.

Server side rendering

Server side rendering was introduced to solve the problems of CSR. Instead of sending an empty HTML file, the server would render the inital page and send that pre-rendered HTML to the browser along with the JavaScript bundle. If we take our example, when the user requests the blog, the server will first fetch the view, likes of this blog from the database, and then it will render the blog and send the HTML to the browser.

That HTML file will still includes some <script> tags, since we still need React to run on the client side to handle user interactions. But React will now works a little bit different from CSR: Instead of rendering the whole page, it will only attach event handlers and interactivity to the existing DOM elements. This process is called hydration ๐Ÿ’ฆ.

So, unlike CSR, browser doesn't have to wait for the JavaScript to be downloaded and executed before it can render the page. This means that the user would see the content of the blog immediately. This results in a better FCP, vastly improved user experience overall. Moreover, if the user's browser doesn't support JavaScript, they would still be able to see the content of the blog.

Here's a quick example of SSR when using Nextjs:

export default function Page({ data }) {
// Render data...
}

// This gets called on every request
export async function getServerSideProps() {
// Fetch data from external API
const res = await fetch(`https://.../data`);
const data = await res.json();

// Pass data to the page via props
return { props: { data } };
}

However, our blog is still not perfect. There are still some problems with SSR:

  • You have to fetch everything before you can show anything. To solve this, React create Suspense, which allows for server-side HTML streaming and selective hydration on the client. But when we use Nextjs example above to render our blog, we still have to wait for the server to fetch the data for the entire blog before any components can be shown.

  • Our blog will not become interactive until the JS code is downloaded and executed. For example, if the user clicks on the like button, they will have to wait till React finish hydrating the blog. This can lead to a poor Time To Interactive (TTI) score.

  • The majority of the JavaScript compute weight still ends up on the client, which means that the user's device has to do a lot of work.

React Server Components

In order to solve the existing problems, the React team introduced a new concept called React Server Components ๐Ÿคฏ.

As described by Vercel:

RSCs individually fetch data and render entirely on the server, and the resulting HTML is streamed into the client-side React component tree, interleaving with other Server and Client Components as necessary.

import React from "react";
import { getPostsList } from "@/lib/blogs";

const Blogs = async () => {
const { posts } = await getPostsList();

return (
<div>
{posts.map((post, index) => {
<Post key={index} post={post} />;
})}
</div>
);
};

export default Blogs;

This code look absolutely unusual to me at first ๐Ÿค”. Why a functional component can be asynchronous? Shouldn't we use useEffect to fetch posts? What will happen when component re-render?... I had a lot of questions when I first saw this ...thing.

But here the key part: React Server Components never re-render. They are a new type of component that is designed to run on the server. They are not rendered on the client, and they are not included in the JavaScript bundle. Instead, they generates UI on the server and streams to the client. As far as the client is concerned, they are just plain HTML.

This mean that a lot of React APIs that we're familiar with, like useState, useEffect, useRef... along with browser APIs are not available in RSCs. I think this approach is great, since it gives us more flexibility to fetch data. For example, in traditional React, we have to use put side effect (like the posts in above example) in useEffect hook to synchronize with external systems, so that they don't repeat on every render. But if the component runs only once, we don't have to worry about that โ˜•๏ธ

At this point, you may wondering: what if we want to update the UI? For example, when the user clicks on the like button, we want to update the number of likes ..etc

Client Components

Client components are those with ability to handle state management, interact with browser APIs, and event handlers like onClick...

To use client components, we simply add the use client directive at the top of the file:

"use client";

import { useState } from "react";

export default function Counter() {
const [count, setCount] = useState(0);

return (
<button onClick={() => setCount(count + 1)}>
You pressed me {count} times!
</button>
);
}

The word "Client" here is a bit confusing. It doesn't mean that they are only rendered on the client side. Instead, they are (like RSCs) rendered on the server, but they are included in the JavaScript bundle and can be rendered on the client side as well to handle user interactions.

I think Traditional React components is a better name, but it's too long ๐Ÿ˜‚

Good to know: In Nextjs App Router, every components are server components by default. So you don't need some thing like use server at the top of the file

But, how should i decide which component should be a Server Component or a Client Component?

As a rule of thumb, if a component can be a Server component, it should be, since it doesn't run on the client and doesn't need to be included in the JavaScript bundle. So that the browser can download and execute much faster and has potentially better performance.

For example, the list of posts in our blog is a good candidate for RSCs, since it doesn't need to update the UI. On the other hand, the like button should be a Client Component, since it needs to update the UI when the user clicks on it.

As you start using RSCs, you'll get a better sense of what should be a Server Component. If the component need to update the UI and handle user interactions, it should be a Client Component. Otherwise, you can leave it as RSC.

Good to know: Every components we use in previous version of Nextjs are Client Components.

Limitations

Let get back to our Counter example, but i want to add a little bit of complexity to it. Let's say that we want to increase the count by 1 every time the user clicks on the button, and the ViewCount component receives count as props and render accordingly.

"use client";

import { useState } from "react";

export default function Counter() {
const [count, setCount] = useState(0);

const handleIncrease = () => {
setCount(count + 1);
};

return (
<>
<button onClick={handleIncrease}>Increase</button>
<ViewCount count={count} />
</>
);
}

Since the ViewCount component doesn't have the use client directive at the top of file so it's must be a Server Component ๐Ÿคทโ€โ™‚๏ธ

export default function ViewCount({ count }) {
return <p>Current: {count}</p>;
}

What will happen when we click on the button? The ViewCount component will be re-rendered, right?

"But it's a Server Component, it can't do such thing." ๐Ÿค“

The thing is, it doesn't work like that. The use client directive is not only used to created a Client Component, it also tells React to declared a boundary between Server Components and Client Components. This means that by defining the use client in a file, all modules imported into it, including child components, are considered part of the client bundle, in other words they are Client Components.

This also means that we don't have to add use client to every single file that needs to be a Client Component ๐Ÿคฉ, but instead we only need to add it when we want to create a boundary.

Nhรฃn dรกn  Usagi, a pink rabbit, stands proudly behind Piske, a white bird, while they both flex their arms and wear a determined expression.

Server Component Patterns

In the previous example, we learned that we can use use client to create a boundary between Server Components and Client Components. But I think it introduces us another problem: What if we want to use state at a higher level? (aka global state), Doesn't that mean rest of our app components will be Client Components as well?

For example, i have a ThemeContext that allows me to toggle between light and dark mode:

"use client";

import { createContext } from "react";
import Header from "./Header";
import MainContent from "./MainContent";

export const ThemeContext = createContext({});

export default function Root() {
const [theme, setTheme] = useState("light");

const handleToggleTheme = () => {
// ...
};

return (
<body>
<ThemeContext.Provider
value={{
theme,
handleToggleTheme,
}}
>
<Header />
<MainContent />
</ThemeContext.Provider>
</body>
);
}

In this case, the Header and MainContent components will be Client Components, since they are imported into Root component.

To fix this, we can move the ThemeContext to a separate file:

// /components/ThemeContext.js
"use client";

import { createContext } from "react";

export const ThemeContext = createContext({});

export default function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");

const handleToggleTheme = () => {
// ...
};

return (
<body>
<ThemeContext.Provider
value={{
theme,
handleToggleTheme,
}}
>
{children}
</ThemeContext.Provider>
</body>
);
}

And update our Root component accordingly:

import Header from "./Header";
import MainContent from "./MainContent";
import ThemeProvider from "@/components/ThemeProvider";

export default function Root() {
return (
<body>
<ThemeProvider>
<Header />
<MainContent />
</ThemeProvider>
</body>
);
}

We can now safely remove the use client directive from Root component, because it no longer uses state and doesn't need to be a Client Component. This also means that the Header and MainContent components can be Server Components now.

You may wonder: ThemeProvider is a Client Component, and it is an ancestor of Header and MainContent components, so why they are not Client Components?

I think the best explanation is that when it comes to boundary, the parent/child relationship doesn't matter. Root is the one which imports and renders Header and MainContent so that it decides whether Header and MainContent are Client Components or not. Since Root doesn't have use client directive at the top, they are Server Components by default.

This is a bit confusing at first ๐Ÿฅด, but we will get used to it as we use RSCs more.

Benefits of React Server Components

For the first time ever, we can run server-exclusive code in React without having to use a third-party frameworks like Nextjs.

The most obvious benefit is performance. RSCs are rendered only on the server, so they are not included in the JavaScript bundle which reduces the amount of JavaScript that needs to be downloaded and executed, and the number of components that need to be hydrated.

Another advantage that worth mentioning is streaming. With traditional SSR there's a series of step that need to be completed before a user can see and interact with a page, these steps are sequential and blocking, meaning the server can only render the HTML for a page once all the data has been fetched. Moreover on the client, React can only hydrate the UI once the code for all components in that specific page has been downloaded.

Streaming allow us to split the rendering work into chunks and stream to the client as they become ready. This means that the user can see the content of the page earlier without having to wait for the entire page to be rendered thus can reduce the Time To First Byte (TTFB) and First Contentful Paint (FCP). It also helps improve Time to Interactive (TTI), especially on slower devices.

To really get a sense of the differences between traditional SSR and RSCs, let's take a look at the following graph:

Time

A

B

C

D

TTFB

FCP

TTI

A
Fetching data on server
B
Rendering HTML on server
C
Loading code on the client
D
React hydrates

TTFB:

Time to first byte

FCP:

First contentful paint

TTI:

Time to interactive

Graph from Nextjs

A quick example of steaming using React Suspense:

import { Suspense } from "react";
import { PostFeed, Weather } from "./Components";

export default function Posts() {
return (
<section>
<Suspense fallback={<p>Loading feed...</p>}>
<PostFeed />
</Suspense>
<Suspense fallback={<p>Loading weather...</p>}>
<Weather />
</Suspense>
</section>
);
}

Conclusion

Fewww, that was a lot of information ๐Ÿ˜….

React Server Components is a brand new concept in React, and it can seem cryptic and has a pretty steep learning curve. But it's a great step forward for React, and I'm excited to see how it evolves in the future.

I hope this post cover the most fundamental parts of React Server Components and provide examples of how you can use it to create a simple, yet powerful, fullstack app.

Happy reading! ๐Ÿป