iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🥰

The Evolution of React: CSR, SSR, and RSC – It Was Always About Whether to Send JS

に公開

0. Introduction

React Server Components (RSC) are often called confusing. However, most of the difficulty is not inherent to the concept itself.

If you have ever felt confused by where to place 'use client', the Server/Client boundary, props constraints, or double execution, it is because the official explanation order and terminology are misleading.

The mechanism itself is surprisingly simple. It only looks difficult because you are viewing it from the wrong angle.

In this article, I will organize the concept by shifting perspectives. Tracing the evolution from CSR to RSC, I will replace the names "Server Component / Client Component" with the glasses of "Static / Interactive". Doing so makes the design intent clear.

I plan to write a sequel to this article. The difficulty of the actual writing experience (leaky abstraction) will be covered in the second installment.

Now, let's start from the beginning: CSR.


1. The World of CSR — The Origin of React

Originally, React was a library for CSR (Client-Side Rendering). The mechanism was simple: the server would just return an "empty HTML with JavaScript included," and the browser would assemble everything.

Let's consider displaying an article list page (/articles) as a concrete example.

For simplicity, assume that we are using code splitting with the CSR mode of React Router DOM.

What is happening

When a user accesses /articles, the following occurs:

  1. Browser: GET /articles1st Round-trip
  2. Server: Returns index.html
    • <div id="root"></div> ← It's empty!
    • <script src="/bundle.js"></script>
  3. Browser: Receives HTML → Discovers the script tag
  4. Browser: GET /bundle.js (+ chunk for /articles) ← 2nd Round-trip
  5. Browser: Receives JavaScript → Parses and executes
  6. Renders JSX components → Generates Virtual DOM
  7. Constructs real DOM based on Virtual DOM (= mount)
  8. Executes fetch('/api/articles') inside the component → Displays loading state while fetching ← 3rd Round-trip
  9. Data returns → Updates state → Re-renders
  10. Finally, the article list is displayed

Writing this flow in actual code looks like this:

function ArticleList() {
  const [articles, setArticles] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/articles')
      .then((res) => res.json())
      .then((data) => {
        setArticles(data);
        setLoading(false);
      });
  }, []);

  if (loading) return <Loading />;
  return <ul>{articles.map((a) => <li key={a.id}>{a.title}</li>)}</ul>;
}

Having state, fetching with useEffect, displaying a loading state, and re-rendering when data arrives — what you saw in the timeline is reflected directly in the code.

The important thing here is that the initially returned HTML is empty. Therefore, the screen is first completely white. When JS is executed and the initial render runs, a loading spinner appears. After the fetch completes, it re-renders, and finally, the article list becomes visible. The user is forced to wait for this three-stage transition.

Another important point is that this requires three network round-trips. HTML, JS bundle, and data — each occurs as an independent round-trip. Heavy processing like JS parsing and execution also happens in between.

As you can see, this is what React was originally. However, having a loading state is problematic.

If the load is short, the screen flickers, which is unpleasant; conversely, if the client's performance is low, it takes forever to finish. The perceived performance depends on the browser environment.

When thinking about whether we could solve this, one suddenly notices:

This hassle happens because we try to assemble the display on the client.

If so, we should just assemble it on the server and send it.


2. The Advent of SSR — The Idea of Assembling on the Server

SSR (Server-Side Rendering) is the implementation of the idea of assembling the display on the server side.

Specifically, the server executes the React component once in advance. It fetches the article list from the database, renders the component to generate an HTML string, and then inserts that HTML into the initially empty <div id="root"> before sending it back.

What is happening

When a user accesses /articles, the following occurs:

  1. Browser: GET /articles1st Round-trip
  2. Server:
    • Fetches articles from the database
    • Executes React components on the server → Generates HTML string
    • Returns index.html with the article list HTML inserted into <div id="root">
      • <script src="/bundle.js"></script>
  3. Browser: Receives HTML → The article list is displayed immediately!
  4. Browser: GET /bundle.js (parallel loading) ← 2nd Round-trip
  5. Browser: Parses and executes JavaScript
  6. Component re-renders → Generates Virtual DOM
  7. Since the real DOM already exists, instead of constructing it from scratch, it compares the Virtual DOM with the existing real DOM and attaches event handlers (= hydration)
  8. Finally, buttons and other elements become interactive

What changed definitively here is that the screen is displayed at the 1st round-trip. In CSR, there was a three-stage process of "white screen → loading → display," but in SSR, the article list is visible from the start.

The number of round-trips is also reduced from three to two. Since data fetching is now completed on the server side, there is no longer a need to hit an API from the client.

Hollow Structures and Hydration

However, seeing the screen and the app actually working are different things. The HTML returned by the server is a hollow structure that looks complete but doesn't work yet. Nothing happens when you press a button, and form inputs do not react. We show the hollow structure to the user for now, and while they are distracted by it, we busily construct the Virtual DOM in the background.

The process of "attaching functionality" here is hydration. When JS is executed on the client side, the component is rendered again to create a Virtual DOM. This is matched against the existing real DOM to attach event handlers.

In other words, CSR and SSR use the Virtual DOM in fundamentally different ways:

  • CSR: Constructs the Virtual DOM → Real DOM from scratch
  • SSR: The real DOM already exists → Matches and connects with the Virtual DOM

What Was Solved, and Remaining Problems

Let's summarize what SSR solved so far:

  • Loading state is gone: You can see the article list at the 1st round-trip
  • One round-trip reduced: Because data fetching is completed on the server side
  • Stronger SEO: Crawlers can read the HTML filled with content from the start

However, new problems have been created.

Problem 1: Everything is executed twice

Components rendered on the server need to be rendered once more on the client side for hydration. Data fetching only happens once, but component execution still runs twice. This is structurally inelegant.

Problem 2: Sending the entire JS bundle to the client

As long as components are executed on the client side, you must send the same JS as the server side.

For example, suppose you are using a library that converts markdown to HTML for the article body. Once converted on the server side, the resulting HTML does not change. There is no point in converting it again on the client side. Even so, since it is necessary for component execution, you end up sending the entire markdown renderer library to the client.

You are delivering code that does not need to run on the client.

You could choose to stop here. In fact, there are frameworks that take the stance that "SSR is enough," such as Astro or Remix.

But what if we could cut even more?

  • Is it really necessary to execute the same component twice?
  • Couldn't the parts that finish after one execution be completed on the server, without sending the JS?

If so, you just shouldn't send the JS that doesn't need to be sent.


3. The Advent of RSC — Sending Only the Necessary JS

The idea of "not sending JS that doesn't need to be sent" is implemented by RSC (React Server Components).

The logic is clear. Components that do not need to run on the client have their execution completed on the server side. For elements that do not change after being displayed once, you only create and send the HTML, and they are not included in the JS bundle. You only send JS for the parts that need to run on the client.

To achieve this distinction, RSC adopted a structure that divides components into two types:

  • Server Component: Executed on the server, and the result is baked into the HTML. No JS is sent to the client.
  • Client Component: Rendered on the server for the initial render (included in the HTML) and also executed on the client (hydration runs). This behaves the same as traditional SSR.

What is happening

When a user accesses /articles, the following occurs:

  1. Browser: GET /articles1st Round-trip
  2. Server:
    • Fetches articles from the database
    • Executes Server Components → Bakes the result as HTML
    • For Client Components, it outputs the initial HTML while leaving markers to hydrate later
    • Returns the completed index.html (+ <script> for Client Components)
  3. Browser: Receives HTML → The article list is displayed immediately
  4. Browser: GET /bundle.js2nd Round-trip
    • However, the sent JS is reduced to only the Client Components
  5. Browser: Parses and executes JS
  6. Only the Client Component parts are hydrated and become interactive

What changed definitively here is that Server Components are not included in the JS bundle. While SSR sent all components to the client, RSC sends only what is necessary.

Strictly speaking, a special wire format called RSC Payload (React Flight) is sent between the server and the client, but I have omitted this internal implementation as it is outside the scope of this article (which focuses on the motivations and philosophy behind the evolution).

In the Case of an Article List

Let's take a look at how to implement an article list page with RSC specifically.

If you write ArticleList as a Server Component, it looks like this:

async function ArticleList() {
  const articles = await db.query('SELECT * FROM articles');
  return <ul>{articles.map((a) => <li key={a.id}>{a.title}</li>)}</ul>;
}

Compared to the CSR version seen in Chapter 1, the code is significantly reduced. The state, loading state, useEffect, and fetch are all gone. What this means is:

  • Database article fetching can be executed directly on the server side (no need to go through an API)
  • The display of article titles or body text is baked as HTML
  • The library that converts markdown to HTML only needs to run on the server side, so it is not sent to the client

This is the biggest benefit of RSC. Whereas SSR sent the markdown renderer to the client, RSC completes it within the server.

What RSC Solved, and Remaining Questions

RSC solved three main things:

  • Reduced JS bundle: Server Component code is not sent to the client
  • Unified data fetching and components: You don't need to write separate APIs; you can hit the DB directly within Server Components
  • Partially eliminated double execution: Server Components are executed only once on the server (Client Components still run twice, as before)

However, a question naturally arises here.

If it's a page like an article list that is "just fetching and displaying data," then the story is complete. But in reality, it is normal to have dynamic elements like like buttons, sorting, and filtering mixed on the same page. How do you handle those?

And fundamentally, what determines the boundary between Server Components and Client Components?

— I will answer this question in the next chapter.


4. Static / Interactive, Not Server / Client

So far, we have looked at the evolution from CSR to RSC.

  • CSR: Assemble everything on the client → Involves loading states
  • SSR: Assemble on the server and return → Allows for single round-trip rendering, but leaves double execution and bloated JS
  • RSC: Divide components into two types to avoid sending unnecessary JS

And we were left with a question from Chapter 3:

What determines the boundary between Server Components and Client Components?

Here, let's change our perspective.

Look at "Is JS needed?" Instead of "Where the component runs"

The names "Server Component / Client Component" seem to indicate where a component runs. However, in reality, this boundary is determined by "whether you need to send JS to the client."

Physically speaking, it is this:

When you cut a web application at the server-client boundary, do you need a JS bundle?

  • Not needed (can be carried by HTML alone) → Static
  • Needed (need to send executable code) → Interactive

Functionally, calling them Static Components / Interactive Components is more accurate and intuitive than calling them Server Components / Client Components.

The term "Static" used here does not mean fixed at build time (SSG). It is used to mean it does not need to send JS to the client, that is, there is no need to re-render on the client side.

Distinguishing Static and Interactive

Let's break down the article list page to see what is Static and what is Interactive.

Element Property Type
Article title, body, author name Does not change after display Static
Markdown rendering Finished once it becomes HTML Static
Like button (count increases on click) Holds state and requires re-render Interactive
Sort / Filter Changes display based on input Interactive
Search bar Holds input values while typing Interactive

Static elements are baked on the server and finished. Interactive elements must have JS running on the client to be meaningful, so JS must be sent.

This is the true nature of Server Components and Client Components.

Client Components Inherit SSR Behavior

There is one thing I want you to notice here.

RSC did not replace SSR.

To be precise, the only thing RSC added was the Server Component category. The behavior of Client Components is exactly the same as SSR — rendered on the server for the initial render, and hydrated on the client.

Runs on server Hydrated on client
Server Component (new category) ✅ Once, baked into HTML ❌ Does nothing as no JS is sent
Client Component (= same as SSR) ✅ For initial HTML generation ✅ Re-renders via hydration

In other words, you could say that RSC is a division between "parts that end with baking" and "parts that inherit SSR behavior" within the screen. It is a form that strips away the wasted parts from SSR.

The Mismatch in Naming — "Client" components that run on the server

By the way, you might have noticed by now:

Even though they are named "Client Components," they are also executed on the server.

It is a somewhat strange naming convention. It seems to imply "runs on the client," but it actually runs on the server as well. This is connected to the point that calling them Static / Interactive is more accurate functionally.

In the actual API, this boundary is declared with a directive called 'use client'. However, this API also carries a mismatch with the concept — and that "mismatch" is the true reason that makes the writing experience of RSC complex.

I will dissect that structure in the next installment (Part 2).


5. Summary

From CSR to RSC, React has evolved in the direction of cutting waste.

  • CSR: Assemble everything on the client → Involves loading states
  • SSR: Assemble HTML on the server and return → Displayable in 1 round-trip, but double execution and JS bloat remain
  • RSC: Divide components into Static (baked) and Interactive (with hydration) → No need to send unnecessary JS

Each stage had problems it tried to solve, and new problems that arose as a result. Each was an inevitable evolution.

The essence of RSC is summarized in this one line:

Divide the screen into "Static (things that are baked and done)" and "Interactive (things that need JS)," and do not send JS to Static.

If you reread it wearing the glasses of Static / Interactive instead of the names "Server Component / Client Component," the design intent becomes clearly visible.

But, did you notice?

Even though it looks neatly organized, there is actually a lingering unpleasantness.

  • They are called "Client Components," yet they run on the server
  • You want to create a Server Component, but the directive is on the 'use client' side
  • You cannot pass functions via props (the reason is not visible from the surface API)
  • The boundary is determined in file units (not component units)

This "mismatch between the writer's perception and the actual behavior" is the true nature that makes the writing experience of RSC complex.

While the mechanism itself is surprisingly simple as we have seen, there are several traps embedded at the API level that confuse readers.

In the next article, I will dissect this mismatch — the structure of leaky abstraction. I want to touch on why this is a problem unique to React, and how it is solved in other frameworks (Solid, Astro, Qwik, etc.).

Discussion