A New Mental Model of React 18

Photo by KOBU Agency on Unsplash

A New Mental Model of React 18

React Server Components

The journey into React has been quite a ride for me. Back in the day, before the era of React Hooks, mastering the React lifecycle was like navigating through a complex maze. But you know what? I found it intriguing, and it became a part of my coding adventure. "React is tough and a bit intricate, but it's undeniably interesting," I used to say.

Then came the era of React Hooks, and suddenly, learning React became as easy as watching a few two-hour tutorials. People were proudly proclaiming, "I learned React in just one week!" The narrative shifted from "React is tough" to "React is easy." Now, everyone seemed to have an opinion on its behavior after just a week of learning. As for me, I started saying, "React isn't tough anymore, but is it still interesting?"

The performance improvements in later versions meant we didn't need to dive deep into the intricacies of its lifecycle. Recently, I stumbled upon React Server Components (RSC) and decided to explore the hype surrounding them. Initially, it felt like trying to crack a secret code, but with time, I'm beginning to wrap my head around it. It seems like another fascinating chapter in the React story.

Quick clarification: what I'm delving into here isn't specifically about Next.js, but I've used it since React Server Components (RSC) play well with Next.js right now. I'm yet to figure out if Next.js is the ultimate choice or if Remix or another framework might steal the show.

Understanding RSC isn't about grappling with complexity; it's about embracing a whole new mental model. It's not a return to the PHP days but a shift in perspective. Instead of viewing components solely as JavaScript running on the server, RSC introduces the idea of deciding which components exclusively run on the server and which ones execute on the client side.

To truly grasp this, we need to distinguish RSC from the familiar concept of server-side rendering (SSR). So, let's dive into what SSR is all about.

SSR

Let's break down the magic of Server-Side Rendering (SSR) and why it matters in simple terms:

SSR is like having a server prepare the main dish (your web page) before serving it to your guests (users). Unlike Client-Side Rendering (CSR), where users need to wait for the entire cooking process to complete on their end, SSR sends out a partly cooked dish right away.

Picture this: your website is a recipe, and SSR is the chef doing some of the cooking in the kitchen before bringing the meal to the table. This speeds things up because the server, being closer to the database, can fetch ingredients faster.

But there's a plot twist: users can't start digging in until the chef (JavaScript) arrives and finishes the cooking process in their browser. This step, called hydration, is like making sure the guests can enjoy the full dining experience, complete with interactive elements.

Now, here's where it gets tricky. SSR does this cooking for the entire meal, meaning it preps all sections of your website, from appetizers to desserts. This can be a problem if, let's say, your comments section involves a complex recipe that takes a while to cook. It's like everyone at the table has to wait until the chef is done with everything.

But what if we could tell the chef (server) to prioritize certain dishes and serve them first, while the complex ones are still in the oven? That way, guests (users) get to enjoy some parts of the meal sooner, reducing wait times and making the dining experience more delightful. That's the essence of optimizing SSR for a smoother feast on the web! And that's what RSC tends to solve.

Assume I have an app layout like this:

If the comments section is taking too much time for a database query, the initial rendering on the server will take time, as a result, the user we see nothing on the browser until then. This can create a problem that results in users waiting more to see something happen on the screen, which increases our Time to First Byte (TTFB). Loading js bundles dynamically is also a solution but that will not include any initial HTML for the comments section initially.

Here is the diagram of SSR:

  • User Requests URL: The user initiates a request for a specific URL.

  • Server Processes Database Requests: The server begins fetching data from the database to construct the HTML during the initial rendering.

  • Complete HTML Sent: The server sends the complete HTML of the page to the browser in a single transfer.

  • Browser Renders HTML: Upon receiving the HTML, the browser renders the page, but the app remains non-interactive at this stage.

  • JavaScript Bundle Download: Simultaneously, the browser starts downloading the JavaScript bundle specified in the script tag.

  • Hydration Process Starts: Following the download, the browser begins the process of hydrating the JavaScript with the provided HTML. This hydration happens in full app in one go.

  • App Becomes Interactive: At this point, the app transforms into an interactive experience, allowing user engagement.

But if we can instruct the server about which data requests to fetch immediately and which ones may take time, defer the latter, and send out the remaining HTML page? how about streaming those deferred components?

React Server Components

So here comes the RSCs, in this new paradigm, we define components that can run only on the server, which enables us to write database queries right inside our components, use core native modules of node-like fs right inside our component.

"use server"

const Page = async ({
  params,
  searchParams: { searchParams: query },
}) => {
  const questions = await Question.find(query);
  return (
      <div>
        {questions.map((question) => (
            <QuestionCard
              title={question.title}
              tags={question.tags}
              author={question.author}
              upvotes={question.upvotes}
              views={question.views}
              answers={question.answers}
              createdAt={question.createdAt}
            />
          ))
      </div>
  );
};

One thing you can see here is I am using async for these components, and as using React's useEffect hell for so many years, writing just const questionsList = await Question.find(query); is crazy for me!

Now, please note here that these are rendered only on the server side here, meaning they generate the UI data once, so we don't need to re-render them in useEffects. This is one rule that enables us to do this crazy thing. Only UI data is sent for this component, no JS bundles for these components are sent to the client.

Note
We can't use useState, useEffect, or any other side-effects from React API in RSCs.

How does this make a difference? Assume a blog site or Docs site where we use code highlighter libraries, in traditional SSR, HTML for that is generated once on the server, but then JS bundles are also sent to the server (then hydration happens), meaning some part of this code highlighter library is also being sent. Now i am picking this example to show you that 3rd party libraries you are using might have a big size and you need only a small part of it, like code highlighter on for js language not all. In the case of RSCs, the JS bundle size is reduced as we don't need this library, we only send what's required.

We use "use server" for server components for these components at the top of the file to tell NextJS that it is a server component.

The result? RSC works its magic, significantly cutting down the TTI (time to become interactive). The initial JS bundle size is reduced as JS code for the server component is not needed.

Client Components

You might be wondering why I'm dedicating a whole section to Client components. The term might suggest components that render exclusively on the client side, right? Well, surprise – the name "Client components" is a bit misleading. In reality, these components pull double duty; they render both on the server (just once) and on the client.

Suppose we have a client component like this:

"use client";

function Counter() {
  const [count, setCount] = React.useState(0);
  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}

Here "use client" is used to specify client components. The Client component gets a special treatment. It's pre-rendered on the server, creating HTML for SSR (starting with an initial counter value of 0). The JavaScript bundles of this component tag along to the client, where they are hydrated.

Streaming SSR with Selective Hydration

So RSC is a tool with which we can do streaming SSR. but what is streaming SSR?

Earlier we were able to render our whole app using SSR with these steps in one single pass only:

Imagine heavy-hitting components like <Comments/> or <Posts/> slowing down your page load time in the browser. Well, fear not! We can easily tackle this by breaking them down into async components like these:

The general markup for this will look like this:

export default function Home() {
  return (
    <main className="w-full h-[100vh] flex flex-wrap">
      <Navbar />
      <LeftSideBar />
      <div className="w-[80%] flex-col gap-2">
        <MainContent />
        <Suspense fallback={<Loading />}>
          <Comments />
        </Suspense>
      </div>
    </main>
  );
}

Here, we're using React.Suspense, a handy tool. It allows us to delay loading the Comments section and display a loading spinner instead. This happens until the Comments section fetches its data. With the new APIs in React 18, when we wrap a component with <Suspense>, we tell the server to prioritize loading other components first. This prevents heavier components, like Comments, from slowing down the loading of the rest. As a result, the time it takes for the page to become interactive decreases even more.

As observed above, an initial loading spinner for comments appears until the new HTML and JS for the Comments section are sent to the client. And all of this happens seamlessly in the same stream.

React introduces a neat way for us to send our application in smaller, more manageable chunks. This means the client can start displaying the UI even while it's still receiving data, providing a highly efficient first-load experience. By invoking the hydrate method on the received DOM nodes, we can attach interactivity like event handlers, making the UI responsive!

Before React 18, loading a website could make the HTML generation feel frozen for a few seconds (especially with larger JS bundles) until hydration kicked in. But in React 18, we can hydrate as we receive the JS bundles. It's known as Selective Hydration. And you know what's even more exciting?

Suppose there are multiple components that are heavy and I am wrapping them in suspense

<main className="w-full h-[100vh] flex flex-wrap">
      <Navbar />
      <Suspense fallback={<Loading />}>
        <LeftSideBar />
      </Suspense>
      <div className="w-[80%] flex-col gap-2">
        <MainContent />
        <Suspense fallback={<Loading />}>
          <Comments />
        </Suspense>
      </div>
</main>

When a user opens the URL, the server initially renders the page with a placeholder for components like <LeftSideBar/> and <Comments/> that will load asynchronously. Now, let's say we receive both components. <LeftSideBar/> starts hydrating first because it's at the top of the React tree.

But here's the cool part: if the user clicks on the comments section before it has started hydrating, React will hit the pause button on <LeftSideBar/>'s hydration and prioritize <Comments/> instead. It recognizes the user's interest and gives them what they want first. Enhancing user experience! 🌐✨

So the graph looks like this:

Under the hood

When I mentioned RSC sending initial HTML to the server component, I might not have given you the full picture. You might be wondering how React manages to connect those streamed clients with the existing HTML.

If you take a peek at the "View Source" tab of the page for the app I described earlier, you'll find the HTML content. But within the script tag, there's also some interesting, somewhat weird stuff like this:

This is new React's internal data streaming format, it is a serializable representation of the virtual DOM for the SSRed HTML. As Dan Abmorov says:

"A production-ready RSC setup sends JSX chunks as they're being produced instead of a single large blob at the end. When React loads, hydration can start immediately—React starts traversing the tree using the JSX chunks that are already available instead of waiting for all of them to arrive. RSC also lets you mark some components as Client components, which means they still get SSR'd into HTML, but their code is included in the bundle. For Client components, only the JSON of their props gets serialized."

This representation encapsulates all the information, utilizing special characters and encoding to signify various aspects in the virtual DOM. React then creates a Virtual DOM out of this representation, which undergoes reconciliation with the HTML to verify if both representations align. If not, an error is thrown. The hydration error we encounter is an example of this – it signals a mismatch between the Virtual DOM built by RSC and the actual DOM (made by SSRed HTML).

Now, for client components, they are server-side rendered once, but their distinct JS bundle is sent to the client. RSC cleverly references and positions them precisely where they are needed. It's like a strategic deployment of resources for optimal rendering! 🚀✨

If you see the code that was streamed here in another script tag, the Comment Section's JSX representation

RSC has a way to reference this information in the initial Serializable representation it got from the initial HTML script tag.

When it crafts the initial plan on the server (SSR HTML), it wants the actual construction on the client (CSR HTML) to be an exact match. Any differences grab React's attention, and it makes sure both versions align perfectly. It's like keeping things consistent in the construction process

Conclusion

RSC is a new way to think about Components, but we don't know if it's the right way or not. It's certainly a good way, maybe a better way, but it's adding way too many complexities in React. Of course, frameworks like NextJS, and Remix will do major abstraction but when the real-world app is built with this, the developer has to know about these concepts at a later stage to solve weird bugs, or when the app size increases. It's hard to maintain track of server and client components. The question is, do we need this new mental model? Wouldn't it increase the computation of the server for RSC rendering (and all other stuff that Router does for maintaining RSC in sync with data change) than just give the client the required JS like we usually do? I think it all depends on the App you are building, and the use case you are trying to solve.

Did you find this article valuable?

Support Kshitiz by becoming a sponsor. Any amount is appreciated!