iTranslated by AI

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

Understanding React Server Components in a Nutshell

に公開3

Hello everyone. Recently, Next.js 13.4 was released, and the App Router is now considered stable. The App Router is built heavily utilizing React Server Components (RSC).

The question of "Why is Next.js calling it beta or stable when Server Components have been in alpha in React core for so long?" has also been successfully resolved with the announcement of React Canary.

What is React Canary?

It is a new release channel announced recently on the official React blog.

To provide an overview based on my understanding, in the Canary version of React, you can use "new features that are stable enough for React to officially support, although breaking changes are still expected." React Canary is primarily intended to be used through frameworks (like Next.js).

Since Canary features are expected to have breaking changes with relatively high frequency, they are not yet ready to be included in releases following semantic versioning. By having the framework layer work hard to absorb those breaking changes, general users can avoid frequent major updates. If you use Canary without a framework, you must be prepared to keep up with breaking changes yourself.

When developing major new features, it is necessary to have them actually used and get feedback, but there is a problem that features with expected breaking changes are not easily adopted. React has apparently dealt with this by having experimental new features used within Meta, but with the advent of React Canary, general users can also use new features to get feedback faster.

With the advent of React Canary, it is expected that React's development structure will change somewhat. Specifically, while there is a need to work more closely with framework developers, dependence on Meta within the company will decrease. I won't say what, but there is a feeling that something is changing. The lead framework developer is, of course, Vercel.

That said, many readers may still find React Server Components difficult. It is true that the mental model is different from what we are used to, making it feel difficult. Therefore, in this article, I will provide my own explanation to help you understand React Server Components.

Understanding React Server Components in a nutshell

In a nutshell, React Server Components are multi-stage computation.

Multi-stage computation can be understood (though there are various definitions, here I mean) as "a mechanism for processing program evaluation in multiple stages" [1] or "a system equipped with semantics where computation consists of multiple stages, with mechanisms to dynamically generate code and run it (+ a type system to do so safely)" [2].

In short, multi-stage computation deals with "programs that generate programs." Metaprogramming and macros are also close to multi-stage computation in this sense. When using the term multi-stage computation rather than macros, other than multi-stage processing, the following properties seem to be emphasized:

  1. As emphasized in the second quote above, safety can be guaranteed through static analysis even for programs spanning multiple stages.
  2. Building on the idea that it can be generalized to multiple stages beyond just two, programs at different stages have similar syntax and semantics.

The most basic mental model of RSC is that "in a React application, there are parts executed on the server and parts executed on the client side." Also, in terms of order, they are executed in the order of Server -> Client. It is safe to say that this is "processing program evaluation in multiple stages." This is a two-stage example of multi-stage computation.

Also, in RSC, both "server side" and "client side" are described as React components, so it can be said that "programs at different stages have similar syntax and semantics." Since these codes are checked by TypeScript and ESLint, the static inspection part also seems good (though perhaps not perfect). The above is why RSC is close to multi-stage computation.

By the way, in the case of two-stage computation, there is a "stage 0 program" and a "stage 1 program." Executing the stage 0 program outputs a stage 1 program, and executing the stage 1 program yields the final result. In the case of RSC, the "server side" corresponds to stage 0, and the "client side" corresponds to stage 1.

Are React Server Components PHP?

In discussions about RSC, it is often said that it is "the return of PHP" or "history repeats itself." These might be intended as mockery with negative intent, but in fact, it is an on-point observation. This is because PHP also has an architecture of "execute on server -> execute on client," meaning it is close to multi-stage computation.

Executing a PHP program outputs HTML (+ JavaScript). This is sent to the client, and the web page is completed by the client side executing (interpreting) the HTML and JavaScript. In the sense that a PHP program generates HTML + JavaScript, it can be said to be a "program that generates a program." Stage 0 is written in PHP syntax, and stage 1 is written in HTML + JavaScript. If you have written PHP, you might have output part of a JavaScript program using PHP like this:

<script>
  var foo = <?php echo json_encode($foo) ?>;
</script>

Although it is very primitive (it does not satisfy conditions 1 and 2 above), this is undoubtedly an example of multi-stage computation. In PHP, it can be said that the part enclosed in <?php ~ ?> is stage 0. At the stage 0 execution phase (the phase executed as a PHP program), the stage 1 part (parts other than <?php ~ ?>) is not executed but just output. In other words, one can say "PHP is a stage 0 program, and when executed, it outputs a stage 1 program. PHP has syntax to embed stage 1 programs internally." Written this way, it has quite a bit of a multi-stage computation feel.

With an example like the following, it feels more like a "stage 0 program is outputting a stage 1 program":

<!-- stage 0 program -->
<script>
<?php
  for ($i = 0; $i < 3; $i++) {
    echo "console.log('{$i}');";
  }
?>
</script>

↓↓↓ Execution Result ↓↓↓

<!-- stage 1 program -->
<script>
console.log('0');
console.log('1');
console.log('2');
</script>

Besides that, various template engines can also be said to be a kind of multi-stage computation.

However, RSC has come full circle from PHP, so it has evolved compared to those days. The difference between RSC and PHP is that both of the two stages are written in React. In other words, if PHP was a program that output "HTML + JavaScript," RSC (server side) can be said to be a program that outputs a "React application for the client side."

Example of multi-stage computation with RSC

Let's look through a concrete example at the fact that "the server side of RSC outputs a React application for the client side."

// Server component
const App: React.FC = () => {
  return (
    <main>
      <Section heading="Chapter 1 Introduction">
        <P>
          In this article, I will explain React Server Components (RSC).
        </P>
      </Section>
      <Section heading="Chapter 2 Why RSC is necessary">
        <P>
          RSC has a background where, with the rise of React-based frameworks, React applications as a whole became too large, and the model of running the same application on both the server and client reached its limit.
        </P>
      </Section>
      ...
    </main>
  );
}
// Server component
const Section: React.FC<React.PropsWithChildren<{
  heading: string;
}>> = ({ heading, children }) => {
  return (
    <section>
      <h2 className="text-2xl font-bold text-gray-900">{heading}</h2>
      <ShowMore>{children}</ShowMore>
    </section>
  );
}

// Server component
const P: React.FC<React.PropsWithChildren> = ({ children }) => {
  return <p className="text-lg text-gray-800">{children}</p>;
};

// Client component
const ShowMore: React.FC = ({ children }) => {
  const [showMore, setShowMore] = useState(false);
  return (
    <div>
      <div style={{ blockSize: showMore ? 'auto' : '100px' }}>
        {children}
      </div>
      <button onClick={() => setShowMore(true)} hidden={showMore}>Show more</button>
    </div>
  );
};

The example above is a simple web page created with React. Since there is a button and it needs to respond to user operation, only the ShowMore component responsible for that is made into a client component. The other App, Section, and P are server components.

The application above is a stage 0 application (with some stage 1 code embedded). So, let's execute this as stage 0 and output the stage 1 program (the React application for the client).

Although the source code below is not literally output as is, conceptually, something like the following will be the client-side code, which will be sent to the browser.

const ClientApp = () => {
  return (
    <main>
      <section>
        <h2 className="text-2xl font-bold text-gray-900">Chapter 1 Introduction</h2>
        <ShowMore>
          <p className="text-lg text-gray-80">
            In this article, I will explain React Server Components (RSC).
          </p>
        </ShowMore>
      </section>
      <section>
        <h2 className="text-2xl font-bold text-gray-900">Chapter 2 Why RSC is necessary</h2>
        <ShowMore>
          <p className="text-lg text-gray-80">
            RSC has a background where, with the rise of React-based frameworks, React applications as a whole became too large, and the model of running the same application on both the server and client reached its limit.
          </p>
        </ShowMore>
      </section>
      ...
    </main>
  );
};

const ShowMore: React.FC = ({ children }) => {
  const [showMore, setShowMore] = useState(false);
  return (
    <div>
      <div style={{ blockSize: showMore ? 'auto' : '100px' }}>
        {children}
      </div>
      <button onClick={() => setShowMore(true)} hidden={showMore}>Show more</button>
    </div>
  );
};

In this way, the Section component and P component were executed as stage 0 and became just HTML. On the other hand, ShowMore is a client component, so it remains. By doing this, Section and P do not exist in the stage 1 application. Therefore, there is no need to send the definitions of the Section and P components to the browser. This reduced the bundle size a little.

However, since this is a kind of inlining, if you are repeatedly using components with large content, writing out the content in full might conversely increase the size. I feel that this problem will be overcome in the future as technologies like Partial Hydration develop.

Supplement on Partial Hydration

In so-called SSR and SSG, the HTML after full expansion has traditionally been sent to the browser. Since RSC also sends the HTML after expansion only once, if you expand RSC fully, the size will not increase.

However, judging from the behavior of the current Next.js (13.4), it seems that hydration is being performed on the client side even for the parts where RSC has been resolved into just HTML. In other words, the expanded HTML is being sent twice: the SSR'ed HTML and the code for hydration.

Representing this table-wise looks like this:

Conventional RSC (Client Component) RSC (Server Component)
SSRed Content Expanded HTML Expanded HTML Expanded HTML
Code for hydration (without partial hydration) Component Definition Component Definition Expanded HTML
Code for hydration (with partial hydration) Component Definition Component Definition None

In conventional (non-RSC) SSR, "Expanded HTML" and "Component Definitions" were sent to the client, but in the case of RSC, as it stands, for server components, "Expanded HTML x 2" is being sent. If partial hydration can be introduced successfully, for server components, it can be made into "Expanded HTML x 1," so if this is realized, assuming SSR, it can be said that "if you make it a server component, the data transfer amount will unconditionally decrease." I'm looking forward to it.

By the way, RSC has a rule that you can use client components (stage 1) from server components (stage 0), but you cannot use server components (stage 0) from client components (stage 1). Thinking about this within the framework of multi-stage computation, it is natural.

Since it takes the process of "first execute all stage 0 parts and turn them into just stage 1," at the stage 0 execution phase (the phase executed on the server side), the stage 1 code is not executed at all, and its contents are not investigated individually. If you use a stage 0 component from a stage 1 component, it could happen that "I tried executing the stage 1 code, and a dependency on stage 0 was discovered," which is problematic. In Next.js, this is prevented in advance by ESLint and runtime checks during next dev. (Aside from next dev, ESLint is static analysis, so the characteristics of multi-stage computation I explained at the beginning are showing up here).

However, as in the example above (ShowMore), you can use a stage 0 component as a "child element" of a stage 1 component using children. The fact that Dan-sensei has been actively recommending the utilization of children on Twitter and elsewhere recently is related to this, and I suppose the aim is to minimize the stage 1 part by effectively utilizing children.

With this, I think you have understood how RSC behaves, and why the various restrictions that exist on the server-side (stage 0) code exist. Ultimately, the stage 0 code is all resolved into just HTML before being sent to the client, so behavior that cannot be serialized as HTML (including all behavior that responds to user operation) is not allowed (although Server Actions announced in Next.js 13.4 seem to be a feature that breaks that restriction).

By the way, in some cases, it becomes necessary to "re-render from stage 0." For example, when transitioning to another page in Next.js. This is a concept not found in pure React (because the stage 1 code does not sense the existence of the stage 0 code). Therefore, this part will be handled by the framework. Even if you use a framework and think you don't know whether this is a React concept or a Next.js concept, you can judge correctly by going back to the principles and thinking.

How to understand and accept React Server Components

In RSC, a new concept appears where "components are categorized into server and client," and since server components have restrictions such as not being able to use useState, it seems some people find this difficult.

However, I personally understand it more as "a new stage has been added." Conventional React applications only had stage 1. Only one React application for the client existed. There was also a technology called SSR, but this forcibly runs code for the client on the server side as well.

On the other hand, in RSC, a new stage 0 has been added to the previous React application (stage 1). Stage 0 has restrictions compared to stage 1, such as having no state. Therefore, the most natural way to accept RSC will be "for now, if you keep everything in stage 1 (client), it's the same as conventional. You add stage 0 (Server Component) to that."

However, in Next.js (the app directory), which is an easy way to use RSC currently, it is set up so that the default is stage 0, and you have to write a declaration of "use client" for stage 1 files. In other words, the default stage is different from the conventional one. I think this is a leap in mental model, and I suspect it is a cause of confusion for some people.

As for the conjecture about why the default is changed like this, I believe it's because once you understand RSC, you realize it is advantageous to move as much as possible to stage 0. Although there are exceptional cases as mentioned earlier, in general, it is advantageous in terms of transfer volume to move to stage 0. Also, since component processing is reduced, it is also advantageous in terms of runtime performance. While components on stage 0 (server) have many restrictions, to lift those restrictions, you need to move the component to stage 1 (client). This can be viewed as an act of opting in to performance degradation in exchange for unlocking the use of state, etc. People building modern web frontend applications should be conscious of bundle size increases, so personally, I think such a model is desirable.

I conjecture that the default became stage 0 because they thought if they were going to have people understand RSC anyway, it would be ideal for them to also finish the transition of mental model at the same time.

How to divide stage 0 and stage 1

Why do people use JavaScript in the frontend? It is for UX. Because communicating with the server is too slow to return feedback for user operation at the fastest speed, it is necessary to perform feedback processing with client-side JavaScript, etc. The role of React is to enable such applications to be written well.

On the other hand, people seem to have started noticing that writing the entire application in (conventional) React for that purpose has too large an overhead. There are also many components using React like just a template engine. Template engines are things that have basically been used on the server side. In other words, originally client-side JavaScript is necessary for UX, but parts unrelated to UX should be processed on the server side, yet in conventional React, everything was being processed on the client side.

In RSC, you can move "React components used as template engines" to the server side as stage 0. On the other hand, parts that respond to user operation need to be stage 1 as before.

In other words, if you are unsure whether to make a component stage 0 or stage 1, you will likely solve it by thinking about the role of that component.

React is quite excellent as a template engine. It can receive the benefits of type checking by TypeScript, and in the first place, it is very seamlessly integrated with stage 1, making it ideal as a "template engine for outputting React applications."

Furthermore, thinking of it as a template engine, you can understand why RSC is in the form it is now. When you use a template engine for the server side, you probably use it on top of some framework rather than using it raw, right? That is no exception even for RSC (server side), and it is designed with the basic use case of using it on top of frameworks like Next.js.

Example in Next.js

So far, I have explained RSC as a general theory, but finally, I will explain a bit more deeply about the behavior in Next.js. This is because, when viewing RSC as multi-stage computation, it becomes the flow of "execute stage 0 on the server side -> execute stage 1 on the client side" as I have already explained. Then, how exactly is "execute stage 0 on the server side" performed?

Actually, there are several patterns for executing stage 0. This is similar to the classification of SSR, SG (Static Generation), ISR, etc., in conventional terms. If the corresponding page does not depend on request information (so-called SG-able ones), you can just perform the execution of stage 0 at build time. If the execution of stage 0 depends on information at the time of the request, execution of stage 0 will be performed on the server side every time you actually access the page. Explaining with a table looks like this (omitting around revalidate for simplicity).

Page Type Execution of Stage 0 Execution of Stage 1
Does not depend on request information At build time Client side
Depends on request information At request time Client side

Points to note as it is confusing, after the introduction of RSC, "SSR" refers to "performing the execution of stage 1 also on the server side and embedding the generated HTML in the response and returning it." In the era before RSC, SSR meant "executing the entire application (stage 1 only) also on the server side," so if you think of RSC as "adding stage 0 to the server side," you can understand it. You can organize who executes what as follows:

Server Side Client Side
Conventional (no SSR) - stage 1
Conventional (with SSR) stage 1 stage 1
RSC (no SSR) stage 0 stage 1
RSC (with SSR) stage 0 + stage 1 stage 1

Next.js needs to judge whether each page depends on request information, but the mechanism is interesting compared to the conventional (Pages Router). In the conventional method, if getServerSideProps was exported from a page module, request-time information was needed; if not, it was treated as SG-able... but it is different in the App Router.

As written in the official documentation, in the App Router, it seems it judges by "actually trying to execute the stage 0 component at build time and seeing if it tried to acquire request-time information." For example, if you called cookies() or headers() to acquire information from request headers, it is judged without question that request-time information is needed. Also, if fetch is used, it is judged by looking at cache-related options. If neither cache: 'no-cache' nor revalidate are specified, it is considered forever cacheable, and data acquired at build time will be used forever. If these options are specified, you cannot continue using data acquired at build time, so it is judged that execution of stage 0 at runtime (at request time) is necessary.

Actually, for example, if you look at the source code of headers(), you can see that if these functions are called during SG, it is judged as SG-impossible, and execution is suspended.

Summary

In this article, I explained RSC based on the idea that "React Server Components are a kind of multi-stage computation." What frameworks like Next.js are doing is like magic, but if you have grasped the basic ideas explained in this article, you should be able to understand why Next.js behaves in such ways.

脚注
  1. https://sankantsu.hatenablog.com/entry/2022/08/19/215024 ↩︎

  2. https://www.slideshare.net/bd_gfngfn/ss-232181286 ↩︎

GitHubで編集を提案

Discussion

koichikkoichik

RSCが解決されてただのHTMLになったところに対しても一応クライアント側でhydrationを行っているようです。つまり、展開後のHTMLは、SSRされたHTMLと、hydration用のコードの2回送られています。

「hydration用のコード」ってNext.jsが返すHTMLの末尾に付加されてる<script>要素のFlightプロトコルで表現されたデータのことで合ってますか?
だとしたら、これは仮想DOMツリー (Fiber) のためのものじゃないでしょうか?

現状のReactではNext.jsのようにコンポーネントにstatic/dynamicといった色づけはしていないと思われるので、仮想DOMツリー上にはCCだけでなく全てのSCの情報もFiberデータ構造として保持する必要があるのだと思ってます
でないと再レンダリングでFlightプロトコルで表現されたレスポンスを受信しても実DOMを差分更新できないように思えるからです
そしてどのSCのどの部分が更新される可能性があるかを今のReactは知りようがないのでRoot Layoutの<html>要素から全てを仮想DOMツリー上に復元 (ハイドレーション) しているのかなと

将来React Forget後継のコンパイラが完成すればstaticなSCについては再レンダリングによる更新が発生しないと確定できて、仮想DOMツリー上に当該コンポーネントのFiberデータ構造を持つ必要がないという最適化チックなことも可能になるかもしれませんね (それをこの記事ではPartial Hydrationと呼んでいるのかもしれませんが)
その場合でもdynamicなSCについてはやっぱりHTMLと共にFlightプロトコルで情報を送る必要が残る気がします

1
uhyouhyo

ありがとうございます。

「hydration用のコード」ってNext.jsが返すHTMLの末尾に付加されてる<script>要素のFlightプロトコルで表現されたデータのことで合ってますか?

そうです。

それをこの記事ではPartial Hydrationと呼んでいるのかもしれませんが

そうですね。ベーシックなアイデアとしては、例にある ShowMore 以下だけが(クライアントから見た)Reactアプリケーションとして管理されていればよいと思いましたが、よく考えるとクライアントコンポーネントの下にサーバーコンポーネントが入ることを考えるとFiberのデータ構造のレベルで改良が必要そうですね。 🥲

しかし、stage 0(サーバー側)で計算されたものを複数回クライアントに送信するというのは原理的にみて無駄なので、必然的に削減される方向に進むだろうと期待しており、この記事のような表現になっています。

4
MelodyclueMelodyclue

サーバーコンポーネントはクライアントにJavaScriptを送らないっていう認識であってますか?