🪺

React Server Componentの実行順序とフレームワークでの注意点

2024/03/09に公開
2

前回はこちらで議論できて大変有意義でした、ありがとうございました。
https://zenn.dev/tell_y/articles/d6f52ea64f128b

その際、Next.jsでのlayout.tsxとpage.tsxの実行順序についてpage -> layoutの順で実行されることを初めて知ったのですが、疑問に思ったのは「これはReact Server Component(RSC)の仕様なのかNext.js(フレームワーク)の仕様なのか?」ということでした。

素のReactの挙動を理由に自分が思い描いていたのは「コンポーネントはネスト順に実行される」だったのでこれは衝撃的でフレームワークについて何も知らなかったな、と思わせる内容でした。

ここでは、Reactコンポーネントの実行順序を再確認し、RSC、フレームワークについても確認していきます。

Reactコンポーネントの実行順序

ここでの対象はクライアントコンポーネントです。
このようなネストしたツリーを用意しました。AとBはchildrenをpropsに持ちそれを返すだけのコンポーネントです。

function A ({ children }) {
  console.log('render A');
  return <>{children}</>;
};

function B ({ children }) {
  console.log('render B');
  return <>{children}</>;
};

function C () {
  console.log('render C');
  return <div>Hi</div>;
};

function App() {
  return (
    <A>
      <B>
        <C />
      </B>
    </A>
  );
}

consoleに出力される順序は、

render A
render B
render C

となります。想定通りでしたか?

さて、この実行順序は保証されるのでしょうか。何かコンポーネントの書き方が変わったり、何かの条件で変わるのでしょうか。

このような物を用意しました。Dはflagによって返却する内容を変えます。ここでは、常にflag === trueとしてHi, I'm Dが表示されます。
ふと、Dの内容を知らずにAppに書かれたツリーだけを見るとEの中身も表示されそうで、Appが実行されるとDもEも全て実行されるように思えませんか?その前提だと、consoleにはrender Eも表示されることになりますが、そうはなりません
Dが実行され、flag === falseの場合でないとEは実行されません。
Dが実行され、Eが実行されるかどうかが決まります。

function D ({ flag, children }) {
  console.log(`render D`);
  if (flag) {
    return <div>Hi, I'm D</div>;
  }
  return <>{children}</>;
};

function E () {
  console.log('render E');
  return <div>Hi</div>;
};

function App() {
  return (
    <D flag>
      <E />
    </D>
  );
}

contextを用意してみました。GにてuseContext()で値を得るためには、親が先に実行されてなければいけません。こちらの場合も、実行順はrender F, render Gとなりネストの外側からになります。

import { createContext, useContext } from 'react';

const Context = createContext('');

function F ({ children }) {
  console.log('render F');
  return <div>{children}</div>;
};

function G () {
  console.log('render G');
  const val = useContext(Context);
  return <div>{val}</div>;
};

function App() {
  return (
    <F>
      <Context.Provider value="Hi">
        <G />
      </Context.Provider>
    </D>
  );
}

Reactはネスト順にコンポーネントを実行する

上記でいくつかパターンを試してみました。みなさんが経験し、ご存知の通りの結果になったかと思います。
関数コンポーネントはピュアに保とう、関数コンポーネントは関数である、というReactの説明を受け止めると、矛盾はしていないかなと思います。
Reactが今後この実行順を変更する可能性は無いと信じていますが、0とは言い切れません。

余談
useEffect()はネストの内側から順に実行されます。

React Server Componentの実行順序

さて、本題のRSCです。
RSCはasync functionもサポートするため、なんだかクライアントコンポーネントとは違いが有りそうです。
先日このようなアンケートをとってみました。
RSCの実行順はどうなるか、直感に従って答えてね、と書いてみました。

https://twitter.com/t6adev/status/1765544058980851765

面白いことに、半数の人が「ネストの内側から、もしくは、順不同である」、と答えました。

フレームワーク無しでRSCの動作を確認したい

さて、実際に試してみたいのですが、どうやって試すと良いでしょう。ここはフレームワーク抜きに試したいところです。

ここに、React Server Componentが発表された当時からデモとして用意されたコードがあります。

https://github.com/reactjs/server-components-demo

今もRFCs (Reactの新機能を提供する際に議論する場)からリンクされており、何か変化があれば変更が加えられています。

こちらを手元で動作させてみました。(async componentにしたり、ミックスしたり、色々試しましたが割愛)
結果は、ネストの外側から実行される、でした。 クライアントコンポーネントと実行順は同じです。
(※実験ではクライアントコンポーネントは含めていません。RSCのみです)

Next.js, Wakuで試す

念の為、Next.jsとWakuでも試しましたが、結果は同じでした。
(※page内でRSCをネストして実行し確認しています)

ところで、wakuはコチラ⛩️

https://waku.gg/

wakuについての紹介動画(動画中盤〜終盤まで)
https://www.youtube.com/live/h3O_H3HYr2U?si=XT2sw3c5j6t5I-V9

実行順に関する記述を探す

react.devには特に記載はなく、RFCsの以下の箇所を見つけました。

https://github.com/acdlite/rfcs/blob/first-class-promises/text/0000-first-class-support-for-promises.md#async-server-components

(機械翻訳)

ランタイムから見ると、非同期サーバーコンポーネントは、プロミスを返すサーバーコンポーネントとして定義されます。Reactはプロミスが解決されるのを待ち、解決された値は直接返された場合と同じようにレンダリングされます。

React Server Componentもネスト順にコンポーネントを実行する

その他にも特に並列実行される等の記述はなく(※見落としはあるかも)、試した通り、ネストの外側から実行される、として受け止めてよいでしょう。

ReactはReact、と言うことですね。

フレームワークでの注意点

さて、Next.jsのlayout.tsxとpage.tsxの実行順が layout -> page とならず、page -> layoutとなるのは何故でしょう?

命名や出力されるツリーなどを見ても、素のReactと同じ実行順であると勘違いしてもおかしくはないのではないでしょうか(過去の自分を擁護)

Reactとフレームワークの境界線を知る

Next.jsのfile base routingはフレームワークとしての機能です。そのため、ルーティングに扱われるpage/layoutファイルのコンポーネントの実行順はフレームワーク依存になります。

なのでpage -> layoutとして実行されるのはNext.jsの仕様になります。
ちなみにWakuは、layoutとpageは並列に扱うようです。なので、順不同です。

追記: Next.jsのpage/layoutの実行"開始"順について

koichikさん(もはや先生と呼ぼう)よりコメントで補足頂いた内容を追記します

Next.jsでは実行の「開始順」はpage -> layoutになりますが、pageが非同期コンポーネントであればlayoutはその完了を待たずに実行が開始されます
つまり実行の開始順は決まってますが、それらは並行に実行されます

すっかりこの視点が抜けていました。反省。

app/page.tsx
export default async function HomePage() {
  console.log('page');
  await new Promise((resolve) => setTimeout(resolve, 1000));
  console.log('page after 1 sec');
  ...
}
app/layout.tsx
export default async function RootLayout() {
  console.log('layout');
  await new Promise((resolve) => setTimeout(resolve, 1000));
  console.log('layout after 1 sec');
  ...
}

"C"は同時に出力されました、ので、pageのPromiseが解決されるのを待たずにlayoutも実行されたので、実行”開始”順はpage -> layoutですが、処理は非同期に走っていました。

page                // A
layout              // B
page after 1 sec    // C
layout after 1 sec  // C

さいごに:Reactにおける実行順へのスタンス

Reactの実行順とフレームワークの実行順について見てきました。
layout, pageはツリー的には前後関係があるように見えますが、フレームワークでの実行順に関しては注意が必要ということが少しでも伝われば幸いです。

また、そもそも実行順に依存した書き方はしない、というスタンスが良いかも知れません。

https://zenn.dev/tell_y/articles/d6f52ea64f128b#comment-68639f5511037d

コンポーネントはUIを宣言的に定義するためのものであって、実行順はReactに委ねる、というスタンスです。
今後Reactが実行順を保証しない可能性も0ではないですからね。(効率を考えると理想は並列実行であることも理解できる)

余談
useState()はReact内部でツリー全体stateを持つコンポーネントの順序を一意に決定しているので安定した呼び出しが可能になっています。ツリー順に則しているかどうかは不明ですが、内部では順序に依存することで動作を保証しているのは面白いですね。
https://react.dev/learn/state-a-components-memory

Discussion

koichikkoichik

確認お疲れ様です!少し気になったところを…

なのでpage -> layoutとして実行されるのはNext.jsの仕様になります。
ちなみにWakuは、layoutとpageは並列に扱うようです。なので、順不同です。

Next.jsでは実行の「開始順」はpage -> layoutになりますが、pageが非同期コンポーネントであればlayoutはその完了を待たずに実行が開始されます
つまり実行の開始順は決まってますが、それらは並行に実行されます
Wakuの実装詳細は知りませんが、おそらく同様で並行に扱ってはいても実行の開始順は固定なのでは?(わざわざランダムにする意味はないので)

今後Reactが実行順を保証しない可能性も0ではないですからね。(効率を考えると理想は並列実行であることも理解できる)

現在のRSCでも親子でないコンポーネントは並行に実行されます
たとえば

function P() {
  return <>
    <C name="foo" />
    <C name="bar" />
  </>
}

async function C({name}) {
  console.log(`${name} 1`)
  await setTimeout(100)
  console.log(`${name} 2`)
  return <div>{name}</div>
}

ログはfoo 1bar 1foo 2bar 2の順で表示されます
実行の開始順は常にツリーの出現順になります(上の例では常に"foo""bar"より先に開始される)

useState()はReact内部でツリー全体での順序を一位に決定しているので安定した呼び出しが可能になっています。ツリー順に則しているかどうかは不明ですが、内部では順序に依存することで動作を保証しているのは面白いですね。
https://react.dev/learn/state-a-components-memory

参照先には「ツリー全体で」に該当する記述はないのでは?
たとえば

Hooks rely on a stable call order on every render of the same component.

ここでHooksは「同じコンポーネント」のすべてのレンダリングで安定した呼び出し順に依存していることが説明されているだけかと思います