Skip to content

Demystifying React Server Components

Demystifying React Server Components

React Server Components (RSCs) are the latest addition to the React ecosystem, and they've caused a bit of a disruption to how we think about React. Dan Abramov recently wrote an article titled "The Two Reacts" that explores the paradigms of client component and server component mental models and leaves us with the thought on how we can cohesively combine these concepts in a meaningful way. It took me a while to finally give RSCs the proper exploration to truly understand the "new" model and grasp where React is heading. First off, the new model isn't really new so much as it introduces a few new concepts for us to consider when architecting our applications. Once I understood the pattern, I found an appreciation for the model and what it's trying to help us accomplish. In this post, I hope I can show you the progression of the React architecture in applications as I've experienced them and how I think RSCs help us improve this model and our apps.

A "Brief" History of React Rendering and Data-Fetching Patterns

One of the biggest challenges in React since its early days is "how do we server render pages?" Server-side rendering (SSR) is one of the techniques we can use to ensure users see data on initial load and helps with our site's SEO. Without SSR, users would see blank screens or loading spinners, and then a bunch of content would appear shortly after. An excellent graphic on Remix's website demonstrates this behavior from the end user's perspective and it's a problem we generally try to avoid as developers.

This problem is so vast and difficult that we've been trying to solve it since 2015. Rick Hanlon from the React Core Team reminded us just how complicated this problem was recently.

But SSR has its issues too. Sometimes SSR is slow because we need to do a lot of data fetching for our page. Because of these large payloads, we'll defer their rendering using lazy loading patterns. All of a sudden we have spinners again!

How we've managed these components has changed too. Over the years, we've seen a variety of patterns emerge for managing these problems. We had server-side pre-fetching where we had to hydrate our frontends application state. Then we tried controller-view patterned components for our lazily loaded client-side components. With the evolution of React, we were able to simplify the controller-view patterns to leverage hooks. Now, we're in a new era of multi-server entry points on a page with RSCs.

The Benefits of React Server Components

RSCs give us this new paradigm that allows us to have multiple entries into our server on a single page. Leveraging features like Next.js' streaming mode and caching, we can limit what our pages block on for SSR and optimize their performance. To illustrate this, let's look at this small block of PHP for something we might have done in the 2000s:

index.php

<?php
include "PostDAO.php"
$name = $_SESSION['name'];
$posts = PostDAO::getAll()
?>
<html>
    <body>
        <h1>Welcome back, <?= name ?></h1>
        <ul>
            <?php
                for ($i = 0; $i <= len($posts); $i++) {
            ?>
                <li><?= $posts[$i]['title'] ?></li>
            <?php
                }
            ?>
        </ul>
    </body>
</html>

For simplicity, the <?php … ?> blocks indicate server boundaries where we can execute PHP functionality. Once we exit that block, we can no longer leverage or communicate with the server. In this example, we're displaying a welcome message to a user and a list of posts. If we look at Next.js' page router leveraging getServerSideProps, we would maybe write this same page as follows:

pages/posts.jsx

import { getSession } from 'utils/session';
import { getPosts } from 'lib/posts';

export async function getServerSideProps() {
    const { name } = getSession();
    const posts = await getPosts();
    return {
        props: {
            name,
            posts,
        }
    }
}

export default function Page({ name, todos }) {
    return (
        <>
            <h1>Welcome back, {name}</h1>
            <ul>
                {posts.map((post) => (
                    <li key={post.id}>{post.title}</li>
                ))}
            </ul>
        </>
    )
}

In this case, we're having our server do a lot to fetch the data we need to render this page and all its components. This also makes the line between server and client much clearer as getServerSideProps runs before our client renders, and we're unable to go back to that function without an API route and client-side fetch.

Now, let's look at Next.js' app router with server components. This same component could be rendered as follows:

app/posts/page.tsx

import { getSession } from 'utils/session';
import { getPosts } from 'lib/todos';

export default async function Page() {
    const { name } = getSession();
    const posts = await getPosts();

    return (
        <>
            <h1>Welcome back, {name}</h1>
            <ul>
                {posts.map((post) => (
                    <li key={post.id}>{post.title}</li>
                ))}
            </ul>
        </>
    )
}

This moves us a bit closer back to our PHP version as we don't have to split our server and client functionality as explicitly. This example is relatively simple and only renders a few components that could be easily rendered as static content. This page also probably requires all the content to be rendered upfront. But, from this, we can see that we're able to simplify how server data fetching is done. If nothing else, we could use this pattern to make our SSR patterns better and client render the rest of our apps, but then we'd lose out on some of the additional benefits that we can get when we combine this with streaming. Let's look at a post page that probably has the content and a comments section. We don't need to render the comments immediately on page load or block on it because it's a secondary feature on the page. This is where we can pull in Suspense and make our page shine. Our code might look as follows:

app/posts/[slug]/page.jsx

import { Suspense } from "react";
import { notFound } from "next/navigation";
import ReactMarkdown from "react-markdown";
import { getPostBySlug, getPostCommentsBySlug } from '@/utils/posts';

export default async function Page({ params }) {
  const { slug } = params;
  const post = await getPostBySlug(slug);

  if (!post) {
    notFound();
  }

  return (
    <article>
      <h2>{post.title}</h2>
      <p>{post.author}</p>
      <ReactMarkdown>
        {post.content}
      </ReactMarkdown>
      <Suspense>
        <PostComments slug={slug} />
      </Suspense>
    </article>
  )
}

async function PostComments({ slug }) {
  const comments = await getPostCommentsBySlug(slug);
  return (
    <div>
      <h3>Comments</h3>
      {comments.map((comment) => (
        <div key={comment.id}>
          <p>{comment.content}</p>
          <p>{comment.author}</p>
        </div>
      ))}
    </div>
  )
}

Our PostComments are a server component that renders static content again, but we don't want its render to block our page from being served to the client. What we've done here is moved the fetch operation into the comments component and wrapped it in a Suspense boundary in our page to defer its render until its content has been fetched and then stream it onto the page into its render location. We could also add a fallback to our suspense boundary if we wanted to put a skeleton loader or other indication UI. With these changes, we're writing components similarly to how we've written them on the client historically but simplified our data fetch. What happens when we start to progressively enhance our features with client-side JavaScript?

Client Components with RSCs

All our examples focused on staying in the server context which is great for simple applications, but what happens when we want to add interactivity? If you were to put a useState, useEffect, onClick, or other client-side React feature into a server component, you'd find some error messages because you can't run client code from a server context. If we think back to our PHP example, that makes a lot of sense why this is the case, but how do we work around this? For me, this is where my first really mental challenge with RSCs started. Let's use our PostComments as an example to enhance by in-place sorting the section from the server.

// `app/posts/[slug]/PostComments.server.jsx`
import { getPostCommentsBySlug } from "@/utils/posts";

export async function PostCommentsServer({ slug }) {
  const comments = await getPostCommentsBySlug(slug);
  return <PostCommentsClient slug={slug} initialComments={comments} />;
}

// `app/posts/[slug]/PostComments.client.jsx`
'use client';
import { useState } from "react";

export function PostCommentsClient({ slug, initialComments }) {
  const [comments, setComments] = useState(initialComments);

  function getComments(slug, { sortOrder }) {
    fetch(`/api/posts/${slug}/comments?sort=${sortOrder}`)
      .then((resp) => resp.json())
      .then((data) => setComments(data));
  }

  return (
    <div>
      <div>
        <h3>Comments</h3>
        <ul>
          <li>
            <button onClick={() => getComments(slug, { sortOrder: "newest" })}>
              Newest
            </button>
          </li>
          <li>
            <button onClick={() => getComments(slug, { sortOrder: "oldest" })}>
              Oldest
            </button>
          </li>
          <li>
            <button
              onClick={() => getComments(slug, { sortOrder: "most-liked" })}
            >
              Most Liked
            </button>
          </li>
        </ul>
      </div>
      {comments.map((comment) => (
        <div key={comment.id}>
          <p>{comment.content}</p>
          <p>{comment.author}</p>
        </div>
      ))}
    </div>
  );
}

In this example, we're using a server component the same way as our previous example to do the initial data fetch and stream when ready. However, we're immediately passing the results to a client component that renders the elements and enhances our code with a list of sort options. When we select the sort we want, our code makes a request to the server at a predefined route and gets the new data that we re-render in the new order on screen. This is how we might have done things without RSCs before but without a useEffect for our initial rendering. If you're familiar with the old controller-view pattern, this is relatively similar to that pattern, but we have to relegate client re-fetching to the view (client) component, where we might have had all fetch and re-fetch patterns in the controller (server) component

Another way to solve this same problem is leveraging server actions, but that would cause the page to re-render. Given the caching mechanisms in Next.js, this is probably fine, but it's not the user experience people are expecting. Server actions are a topic of their own, so I won't cover them in this post, but they're important for the holistic ecosystem experience in the new mindset.

Interleaving Nested Server & Client Components

These examples have shown a clean approach where we keep our server components at the top of our rendering tree and have a clear line where we move from server mode to client mode. But one of the advantages of RSCs is that we can interleave where our server component entry points may exist. Let's think about a product carousel on a storefront page for example.

example of a carousel with product cards

We may have built this as follows before:

export default async function Page() {
  return (
    <>
      <HomepageHero />
      <ProductCarousel title="Featured Products" collectionSlug="featured" />
      <ProductCarousel title="Best Sellers" collectionSlug="best-sellers" />
      <ProductCarousel title="Recommended Products" collectionSlug="recommended" />
    <>
  )
}

// app/ProductCarousel.jsx
'use client';

export default function ProductCarousel({ title, collectionSlug }) {
  // logic for carousel goes here
  return (
    <section>
      <h2>{title}</h2>
      <div>
        <ProductCards collectionSlug={collectionSlug} />
      </div>
    </section>
  )
}

// app/ProductCards.jsx
export default async function ProductCards({ collectionSlug }) {
  const products = await getProductsFromCollection(collectionSlug);
  return (
    <>
      {products.map((product) => (
        <div key={product.id}>
          <img src={product.img} />
          <h3>{product.title}</h3>
          <p>{product.description}</p>
        </div>
      ))}
    </>
  )
}

Here we're rendering carousels that render their cards and our tree goes server -> client -> server. This is not allowed in the new paradigm because the React compiler cannot detect that we moved back into a server component when we called ProductCards from a client component. Instead, we would need to refactor this to be:

// app/page.jsx
export default async function Page() {
  return (
    <>
      <HomepageHero />
      <ProductCarousel title="Featured Products">
        <ProductCards collectionSlug="featured" />
      </ProductCarousel>
      <ProductCarousel title="Best Sellers" >
        <ProductCards collectionSlug="best-sellers" />
      </ProductCarousel>
      <ProductCarousel title="Recommended Products" >
        <ProductCards collectionSlug="recommended" />
      </ProductCarousel>
    </>
  )
}

// app/ProductCarousel.jsx
'use client';

export default function ProductCarousel({ title, children }) {
  // logic for carousel goes here
  return (
    <section>
      <h2>{title}</h2>
      <div>
        {children}
      </div>
    </section>
  )
}

// app/ProductCards.jsx
export default async function ProductCards({ collectionSlug }) {
  const products = await getProductsFromCollection(collectionSlug);
  return (
    <>
      {products.map((product) => (
        <div key={product.id}>
          <img src={product.img} />
          <h3>{product.title}</h3>
          <p>{product.description}</p>
        </div>
      ))}
    </>
  )
}

Here, we've changed ProductCarousel to accept children that are our ProductCards server component. This allows the compiler to detect the boundary and render it appropriately. I'd also recommend adding Suspense boundaries to these carousels but I omitted it for the sake of brevity in this example.

Some Suggested Best Practices

Our team has been using these new patterns for some time and have developed some patterns we've identified as best practices for us to help keep some of these boundaries clear that I thought worth sharing.

1. Be explicit with component types

We've found that being explicit with component types is essential to expressing intent. Some components are strictly server, some are strictly client, and others can be used in both contexts. Our team likes using the server-only package to express this intent but we found we needed more. We've opted for the following naming conventions: Server only components: component-name.server.(jsx|tsx) Client only components: component-name.client.(jsx|tsx) Universal components: component-name.(jsx|tsx)

This does not apply to special framework file names but we've found this helps us delineate where we are and to help with interleaving.

2. Defer Fetch when Cache is Available

If we're trying to render a component that is a collection of other components, we've found deferring the fetch to the child allows our cache to do more for us in rendering in cases where the same element may exist in different locations. This is similar to how GraphQL's data loaders works and can ideally boost the performance of cached server components. So, in our product example above, we may only fetch the IDs of the products we need for the ProductCards and then fetch all the data per card. Yes, this is an extra fetch and causes an N+1, but if our cache is in play, end users will feel a performance gain.

3. Colocate loaders and queries

If you're using GraphQL or need loading states for components for Suspense fallback, we recommend colocating those elements in the same file or directory. This makes it easier to modify and manage related elements for your components in a more meaningful way.

4. Use Suspense to defer non-essential content

This will depend on your website and needs. Still, we recommend deferring as many non-essential elements to Suspense as possible. We define non-essential to be anything you could consider secondary to the page. On a blog post, this could be recommended posts or comments. On a product page, this could be reviews and related products. So long as the primary focus of your page is outside a Suspense boundary, your usage is probably acceptable.

Conclusion

RSCs represent a significant evolution in the React ecosystem, offering new paradigms and opportunities for improving the architecture of web applications. They enable multiple server entry points on a single page, optimizing server-side rendering, and simplifying data fetching. RSCs allow for a clearer separation between server and client functionality, enhancing both performance and maintainability.

To make the most of RSCs, developers should consider best practices such as being explicit with component types, deferring fetch when caching is available, colocating loaders and queries, and using Suspense to defer non-essential content. Embracing these practices can help harness the potential of React Server Components and pave the way for more efficient and interactive web applications.

We're excited about this development in the React ecosystem and the developments happening across teams and frameworks that are opting into using them. While Next.js offers a great solution, we're excited to see similar enhancements to Remix and RedwoodJS.

Let's innovate together!

We're ready to be your trusted technical partners in your digital innovation journey.

Whether it's modernization or custom software solutions, our team of experts can guide you through best practices and how to build scalable, performant software that lasts.

Prefer email? hi@thisdot.co