DEV Community

Cover image for React 18 server components deep dive
Ruslan Kh.
Ruslan Kh.

Posted on

React 18 server components deep dive

React Server Components (RSC) is a promising feature that can have a significant impact on page load performance, bundle size, and the way we write React applications. This feature is still experimental in React 18, but it's worth understanding how it works under the hood.

Separation of server and client components

A diagram that illustrates the rules that apply to server and client components

React Server Components allows the server and client (browser) to work together to render your React application. Some components in the React element tree are rendered by the server, and some are rendered by the browser. This is not the same as server-side rendering (SSR). SSR simulates an environment for rendering a React tree into raw HTML, but it does not distinguish between server and client components.

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

Life of an RSC Render

The life of a page using RSC always begins on the server, in response to an API call to render a React component. The server then serializes the root component element into JSON. The end goal here is to render the initial root server component into a tree of basic HTML tags and client component placeholders.

The server then sends this serialized tree to the browser, and the browser can do the work of deserializing it, filling the client placeholders with the actual client components, and rendering the end result.

RSC and Suspense

Suspense plays an important role in RSC. Suspense allows you to throw promises from your React components when they need something that is not yet ready (fetching data, lazily importing components, etc). These promises are captured at the suspense boundary. When a promise is thrown from rendering a Suspense subtree, React stops rendering that subtree until the promise is resolved, and then tries again.

RSC Wire Format

The server outputs a simple format with a JSON blob on each line, tagged with an ID. This format is very streamable - once the client has read a full line, it can parse a snippet of JSON and make some progress.

Consuming the RSC Format

The react-server-dom-webpack package contains the entrypoints that take the RSC response and rebuild the React element tree. When the server finishes loading the data, it outputs the rows for the module reference - which defines the module reference to the component - and the React element tree that should be swapped into where the reference is.

RSC vs. Fetching Data from Client Components

Whether RSC is better than fetching data from client components 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. If the rendering requires multiple data fetches that depend on each other in a waterfall, then it is better to do the fetching on the server - where data latency is much lower - than from the browser.

RSC and SSR

With React 18, it is possible to combine both SSR and RSC, so you can generate HTML on the server and then hydrate that HTML with RSC in the browser.

Server-Side Rendering (SSR)

Server-side rendering (SSR) is a technique where a React application is rendered on the server into a static HTML string, which is then sent to the client. This can improve performance and SEO by allowing the page to be displayed before all the JavaScript has been loaded and parsed.

import express from 'express';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import App from './App';

const server = express();

server.get('/', (req, res) => {
  const appString = ReactDOMServer.renderToString(<App />);

  res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        <title>My App</title>
      </head>
      <body>
        <div id="root">${appString}</div>
        <script src="/bundle.js"></script>
      </body>
    </html>
  `);
});

server.listen(8080);
Enter fullscreen mode Exit fullscreen mode

In this example, the App component is rendered to a string on the server, and that string is inserted into the HTML response. The client then "hydrates" this static HTML into a fully interactive React application.

React Server Components (RSC)

React Server Components (RSC) allow the server and client to work together to render a React application. Some components render on the server, and others render on the client. This can improve performance by reducing the amount of JavaScript sent to the client and allowing the server to fetch and render data directly.

Here's a simple example of RSC:

// Message.server.js

import {db} from './db.server';

function Message({id}) {
  const message = db.messages.get(id);
  return (
    <div>
      <h1>{message.title}</h1>
      <p>{message.body}</p>
    </div>
  );
}

export default Message;
Enter fullscreen mode Exit fullscreen mode
// App.client.js

import {useState} from 'react';
import Message from './Message.server';

function App() {
  const [selectedId, setSelectedId] = useState(1);
  return (
    <div>
      <button onClick={() => setSelectedId(selectedId + 1)}>
        Next
      </button>
      <Message id={selectedId} />
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

In this example, the Message server component retrieves and renders a message based on an id prop. The App client component maintains a piece of state (selectedId) and renders the Message server component with the current id.

Practical Recommendations

  • Understand the difference between server and client components: Understanding which components are rendered by the server and which are rendered by the client is critical to using RSC effectively.

  • Use Suspense with RSC: Suspense allows you to handle promises in your React components, which is integral to the functioning of RSC

  • Optimize for performance: RSC can significantly improve page load performance and reduce bundle size. However, it's important to measure and monitor these metrics to ensure that you're getting the expected benefits.

  • Consider the tradeoffs: While RSC offers many benefits, it also comes with tradeoffs. For example, server components cannot use states or effects, which can limit their use in certain scenarios.

  • Experiment with RSC in non-critical parts of your application: Given the experimental nature of RSC, it may be a good idea to start experimenting with it in non-critical parts of your application. This will give you a better understanding of how it works and how it can be used effectively.

  • Combine RSC with other React features: RSC can be combined with other React features like Suspense and Concurrent Mode to build more efficient and user-friendly applications.

Examples.

Let's look at some code examples to illustrate how React Server Components (RSC) work.

First, let's define a server component. Server components are identified by the .server.js extension:

// Message.server.js

import {db} from './db.server';

function Message({id}) {
  const message = db.messages.get(id);
  return (
    <div>
      <h1>{message.title}</h1>
      <p>{message.body}</p>
    </div>
  );
}

export default Message;
Enter fullscreen mode Exit fullscreen mode

In this example, Message is a server component that fetches a message from a database based on an id prop. Notice that we're directly importing a server module (db.server) and using it to fetch data. You can't do this in a client component.

Next, let's define a client component that uses this server component:

// App.client.js

import {useState} from 'react';
import Message from './Message.server';

function App() {
  const [selectedId, setSelectedId] = useState(1);
  return (
    <div>
      <button onClick={() => setSelectedId(selectedId + 1)}>
        Next
      </button>
      <Message id={selectedId} />
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

In this example, App is a client component that maintains a piece of state (selectedId) and renders the Message server component with the current id. When the Next button is clicked, the selectedId is incremented, causing the Message component to be rendered again with the new id.

This is a simple example, but it illustrates the key idea behind RSC: server components can be used to fetch and render data on the server, while client components maintain interactivity on the client.

Comparison

While both SSR and RSC involve rendering on the server, they serve different purposes and have different tradeoffs:

SSR is primarily concerned with improving performance and SEO by sending static HTML to the client. However, it requires sending all JavaScript to the client, which can be large and slow to parse.
RSC, on the other hand, allows the server and client to collaborate on rendering, which can reduce the amount of JavaScript sent to the client and improve performance. However, server components cannot use states or effects, which can limit their use in certain scenarios.
In summary, both SSR and RSC are powerful techniques for rendering React applications, each with its own strengths and tradeoffs. Understanding these differences can help you make more informed decisions about which technique to use in your projects.

Links:
https://react.dev/blog/2022/03/29/react-v18
https://react.dev/blog/2020/12/21/data-fetching-with-react-server-components
https://react.dev/blog/2023/03/22/react-labs-what-we-have-been-working-on-march-2023#react-server-components
https://shopify.github.io/hydrogen-v1/tutorials/react-server-components
https://www.plasmic.app/blog/how-react-server-components-work

Top comments (14)

Collapse
 
tresorama profile image
Jacopo Marrone @tresorama • Edited

Is not prohibited to import a Server Component into a Client component?
In <App> you import <Message>.

Collapse
 
iamhectorsosa profile image
Hector Sosa

It is possible to do both ways. You just need to make sure to keep state out of Server components and also you need to make sure to write the right directive at the beginning of the file.

A good example is when you have a context provider in your application. The recommendation is to put it as close as possible in your component tree, but just because you use a Theme Context or a Authentication Context it doesn't mean that the rest of the components in the tree cannot be server components. Hope that explains. Cheers

Collapse
 
tresorama profile image
Jacopo Marrone @tresorama • Edited

Before reading this post, that shows this implementation :

// hello.client.js
"use client"

import {useState} from 'react';
import Message from './Message.server';

function Hello_Client() {
  const [selectedId, setSelectedId] = useState(1);
  return (
    <div>
      <button onClick={() => setSelectedId(selectedId + 1)}>
        Next
      </button>
      <Message id={selectedId} />
    </div>
  );
}

// message.server.js

import {db} from './db.server';

function Message({id}) {
  const message = db.messages.get(id);
  return (
    <div>
      <h1>{message.title}</h1>
      <p>{message.body}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

my idea to do the same is to implement with cookies as communication between server and client tree.
Like so:

// root.server.js
import Message from './Message.server';
import Hello_Client from './hello.client';

function Root_Server() {
  const selectedMessageID = getFromCookie('message-id');
  return (
    <Hello_Client>
      <Message={selectedMessageID} />
    </Hello_Client>
  );
}

// hello.client.js
"use client";

function Hello_Client(props) {
  const handleSelect = ( ) => {
    const currentMessageID = getFromCookie('message-id');
    setCookie('message-id', currentMessageID + 1);
    revalidateRoute('/'); // tell server tree to rerender with new data
  }

  return (
    <div>
      <button onClick={handleSelect}>
        Next
      </button>
      {children}
    </div>
  );
}

// message.server.js

import {db} from './db.server';

function Message({id}) {
  const message = db.messages.get(id);
  return (
    <div>
      <h1>{message.title}</h1>
      <p>{message.body}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Are both ok ?
Is there a risk of exposing server stuff with first approach ?

Edited: added revalidateRoute() in click handler

Thread Thread
 
xakrume profile image
Ruslan Kh.

Both approaches are possible, but they have different tradeoffs:

  • React Server Components (RSC): This is the recommended approach. It allows the server and client to collaborate on rendering, improving performance and reducing the amount of JavaScript sent to the client.
  • Use cookies: This is unconventional and generally not recommended due to potential performance issues, security risks (such as XSS and CSRF attacks), and added complexity.

As for exposing server stuff, RSCs are designed to be secure. They run only on the server and have no direct access to the client. Any data returned by a server component is included in the HTML response, much like any other data included in an HTTP response.

Thread Thread
 
xakrume profile image
Ruslan Kh.

Modify the App.client.js file to use cookies:

// App.client.js

import {useState, useEffect} from 'react';
import Message from './Message.server';

function App() {
  const [selectedId, setSelectedId] = useState(() => {
    // Read the initial value from the cookie
    return Number(document.cookie.replace(/(?:(?:^|.*;\s*)selectedId\s*\=\s*([^;]*).*$)|^.*$/, "$1")) || 1;
  });

  useEffect(() => {
    // Update the cookie whenever selectedId changes
    document.cookie = `selectedId=${selectedId}`;
  }, [selectedId]);

  return (
    <div>
      <button onClick={() => setSelectedId(selectedId + 1)}>
        Next
      </button>
      <Message id={selectedId} />
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

In this example, we'll use the useState and useEffect hooks to read the initial selectedId value from a cookie and update the cookie whenever the selectedId changes.

Note that this is a simplified example. In a real application, you would want to use a more robust method for managing cookies, such as the js-cookie library.

Thread Thread
 
tresorama profile image
Jacopo Marrone @tresorama • Edited

This is from Next.js docs:

"use client" sits between server-only and client code. It's placed at the top of a file, above imports, to define the cut-off point where it crosses the boundary from the server-only to the client part. Once "use client" is defined in a file, all other modules imported into it, including child components, are considered part of the client bundle.

Since Server Components are the default, all components are part of the Server Component module graph unless defined or imported in a module that starts with the "use client" directive.

Good to know:

"use client" does not need to be defined in every file. The Client module boundary only needs to be defined once, at the "entry point", for all modules imported into it to be considered a Client Component.

I was assuming that this behavior is a React thing and not a Next thing.
There are some place where this is explained from a plain React side?

Collapse
 
pintovsa profile image
Rizaka23

Hi, Importing server components into a client component depends on the context and technology you are using. Typically, server components contain business logic and single CPU, databases and other sensitive data, and importing them directly into a client component can pose potential risks to the security and efficiency of the application.

Collapse
 
trongtai37 profile image
trongtai37

RSC is currently on experimental stage.
Some APIs and rules is changing and may be changed in the future.
It's worth mentioning this reference, which is maintained officially by React team.
github.com/reactjs/rfcs/blob/main/...

Btw, React team and Next.js team is collaborating closely, improve RSC and ship it ASAP as a playground for community before this feature is officially stable .
nextjs.org/docs/getting-started/re...

Collapse
 
iamhectorsosa profile image
Hector Sosa

Nice illustrations! Framing them would've been great to see! We've built a simple OSS tool to help with screenshots. Check it out and let us know what you think! github.com/ekqt/screenshot I'd appreciate giving it a Star on GitHub if you find it helpful! Cheers!

Collapse
 
daggett206 profile image
Andrey Smirnov

thanks for the post! I haven't found explanation for why we don't have "await" to get a db record here:

function Message({id}) {
  const message = db.messages.get(id); // where is await?
  return (
    <div>
      <h1>{message.title}</h1>
      <p>{message.body}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
muhammadazfaraslam profile image
Muhammad Azfar Aslam

Can we use react hooks like (useState or useEffect) inside Message.server.js file?

Collapse
 
raibtoffoletto profile image
Raí B. Toffoletto

No, those are features for client components.

Collapse
 
muhammadazfaraslam profile image
Muhammad Azfar Aslam

So React js is just copying Next js.

Thread Thread
 
raibtoffoletto profile image
Raí B. Toffoletto

NextJS IS a React framework ;) they aren't copying, but rather implementing it.