Open13

React 18 & React 19の新機能を試す

mno007mno007

次回の勉強会向けに調査したことをまとめます。

CodePenにReact 19のテンプレートを作ったので、これで色んな新機能を試します。
https://codepen.io/mohno007/pen/MYgVmQE

TODO

  • React 18のuseTransitionを試す
  • React.lazyとSuspenseを試す
  • useDeferredValueを試す
  • React 19のuseTransitionを試す
  • useActionStateを試す
  • useOptimisticを試す
  • useを試す
  • useとSuspenseを組み合わせる
  • <form action={action}>を試す
  • サーバーアクションを試す
mno007mno007

Concurrent features

Reactは状態が更新(setState)されるたびに「レンダリング」を行っている。

従来のReactではレンダリングの最中は操作が行えず、ユーザはGUIがもたつくと感じることがあった。レンダリングのコストの低い小規模のアプリケーションあれば心配は無いが、巨大なアプリケーションになると大量のコンポーネントを扱うためにレンダリングに時間を要する。GUIがもたつくとユーザは応答速度に不満を感じるようになる。

React 18では、明示することでレンダリングを並行に実行できるようになった。並行にレンダリングを行っているので、ユーザの操作を受け付けられるようになった。並行レンダリング中にユーザが状態の更新(setState)を行うと、実施中のレンダリングを中止して、新たな状態に基づいてレンダリングを行う。この仕組みによって、ユーザは自身の操作が即座にGUIに反映されていると感じられ、もたつきを感じなくなる。

https://17.reactjs.org/docs/concurrent-mode-intro.html

mno007mno007

Concurrent featuresの経緯

React 17のドキュメントはdeprecatedだが、経緯や目的を知るのに参考になる。

React 18のドキュメントは使い方については紹介しているが、詳しい挙動まではあまり説明してくれていない印象がある。理解を深めたいのであれば、React 17のドキュメントが参考になりそう。

React Fiber

Concurrent featuresはReact Fiberに基づいている。

レンダリングは大まかに以下のようなステップから成る:

  1. 新しい状態に基づく仮想DOMツリーの構築
  2. 新旧仮想DOMツリーの差分検知(reconciliation)(Render Phase)
  3. 差分の反映(Commit phase)

従来のStack reconcilerの差分検知は、メインスレッドを占有し、キーボード入力やマウス入力を受け付けない状態になってしまっていた。新しいFiber reconcilerでは、定期的にスレッドを明け渡すことで、差分検知の最中であってもキーボードやマウス入力を受け付けられるようになり、更にキャンセルもできるようにもなった。

内部的にはセミコルーチン(JavaScriptのGenerator や Pythonのgenerator、RubyのFiber)のような仕組みで動作しているはず。処理が終わるたびに requestIdleCallback を呼び出すことで、メインスレッドを明け渡している。

mno007mno007

useTransition

useTransitionは並行レンダリングを有効にするための新しいフックAPI。

const [isPending, startTransition] = useTransition();

startTransitionにはコールバック関数を渡すことができる。コールバック関数内で実行されたsetStateに基づくレンダリングは並行に行われるようになる。

mno007mno007

useTransitionを使わない場合の遅いレンダリングを再現してみる

公式のサンプルにもあるようにビジーウェイトによって遅いレンダリングを再現する(。1コンポーネント10msが掛かるように調整してある。

https://codepen.io/mohno007/pen/pvzObqw

この場合、fastのボタンをクリックしてから画面に反映されるまで3秒掛かる。その間、ボタンをクリックすることはできない。

(脱線)遅いレンダリングのデモ

他の人も遅いレンダリングを再現するデモを作っている

mno007mno007

useTransitionを使う場合の遅いレンダリングのデモ

https://codepen.io/mohno007/pen/MYgqjOB

挙動を観察する

useTransition中の状態変化を見る。

  • 1回目のクリック(ボタンの文字がfast
    • isPending: true, display: fast
    • setDisplay('slow')を実行しているはずだが、displayfastのままになる。
  • 2回目のクリック(ボタンの文字がslow変化した後、操作可能かどうかは見ていない)
    • isPending: true, display: slow
    • setDisplay('fast')を実行しているはずだが、displayslowのままになる。

1回目のsetDisplay (fast から slow に切り替え)

このことから、ユーザが1回目のsetDisplayを行ったときのReactの挙動は次のようになるはず:

  1. ユーザがsetDisplay("slow")する
  2. isPending以外は古い状態でレンダリングを行う
    • クリック後即座に実行されるように見える
    • { isPeding: true, display: "fast" }(displayは変更前の状態になる)
  3. 新しい状態で並行レンダリングを実行する
    • その裏で { isPending: false, display: "slow" }の新しい状態でレンダリングを行う
    • console.logの記述順がMainコンポーネントの最後にもかかわらず、console.logにすぐに表示される
  4. 並行レンダリングが完了したら結果で置き換える
    • およそクリックから3秒後になる
    • { isPending: false, display: "slow" }(displayは新しい状態になる)

2回目のsetDisplay (slow から fast に切り替え)

2回目のsetDisplayのときも同じ動作になると思う:

  1. ユーザがsetDisplay("fast")する
  2. isPending以外は古い状態でレンダリングを行う
    • { isPeding: true, display: "slow" }(displayは変更前のslow
    • この間操作ができないように感じる。並行にならない・・・?
  3. 新しい状態で並行レンダリングを実行する
    • その裏で { isPending: false, display: "fast" }の新しい状態でレンダリングを行う
    • fast componentは瞬時に計算が完了するので、ほぼ待機がない
  4. 並行レンダリングが完了したら結果で置き換える
    • { isPending: false, display: "fast" }(displayは新しい状態になる)

感想・考察・推測

振る舞いだけで挙動を推測してみる。

  • isPending周りの挙動が謎だったが、これでイメージができた
  • 1回目の並行レンダリングの挙動がちょっと不思議
    • console.logの記述順がMainコンポーネントの最後にもかかわらず、console.logにすぐに表示されるのはなぜか?(SlowComponentのレンダリングが終わった後に評価されそうな気がする)
    • SlowComponentのコンポーネントの構築の部分を console.time("SlowComponent"); で計測してみると、1msしか掛かっていない(3000ms掛かりそうと推測していたが)
    • このことからコンポーネントのツリーの単位でも並行に実行していると推測している
      • 並行性によってコンポーネントのツリーの評価順は一定に定まらないのか?
      • それとも、幅優先探索的な挙動になる?
    • React Fiberのスケジューラーがいい感じにレンダリングを制御してたりする?
  • 2回目のslowからfastへの切り替えでは、操作ができないタイミング(2のこと)があった
    • 2のisPending: trueなこと以外元の状態で行うレンダリングでは中断ができなさそう。isPendingに基づいて表示を制御しているので。
    • 試しにSlowComponentをReact.memoでキャッシュすると、レンダリングに掛かる時間が非常に短くなった。DOMツリーをキャッシュできているので、isPendingの変化による再レンダリングが不要になっていると思われる。
    • isPendingに基づいたレンダリングは、React.memoを使わないとうまく機能しないケースがありそう
mno007mno007

正しい理解かはわからないけど、こんな動きをしているようにみえる。

  1. ユーザ操作によりsetStateで状態が変更される(startTransition内で)
  2. 保留状態のレンダリング(isPending: true+旧状態)と新状態のレンダリング(isPending: false+新状態)が並行実行される
  3. 保留状態のレンダリングが画面に反映される
  4. 新状態のレンダリングが画面に反映される
mno007mno007

検索クエリの例

https://codepen.io/mohno007/pen/JoPaWjw?editors=0010

setInputを起因とするレンダリングは並行でなく同期的に行われる。レンダリングに時間の掛かるコンポーネントがあると、当然ユーザの入力はレンダリング中に受け付けられないことになる。

ユーザの入力に関する部分以外についてメモ化することで、遅いコンポーネントのレンダリングを回避し、ユーザの入力は即座に画面に反映させることができる。

mno007mno007

Suspense

データやコンポーネントが揃っていない場合にフォールバック画面を表示するためのコンポーネント。

https://18.react.dev/reference/react/Suspense

遅延読み込み中のフォールバック

Suspenseは、元々React 17でReact.lazyによる「コンポーネントの遅延読み込み」を達成するために導入された。

コンポーネントの遅延読み込みは、Reactコンポーネントの読み込みを必要になるまで遅らせることである。

大規模のReactアプリケーションではJavaScriptの規模も大きくなる。JavaScriptの規模が大きくなると、サーバーからのデータの転送やコードの評価にも時間が掛かるようになる。しかし、1つのページを表示するためだけにアプリケーション全体のJavaScriptコードを取得する必要はない。ある1つのページを表示するには、そのページに関するJavaScriptコードを取得できればよく、関係のないページのコードは必要ない。

そうすると、必要になってから必要なコードをダウンロードするようにしてしまえばよい。これが、コンポーネントの遅延読み込みである。

このアイデアを実現するには、あらかじめコードを小さな単位で分割しておき、必要な場面になったときにインポートするための仕組みが必要になる。JavaScriptにはES Moduleの仕組みがあるのでこの単位で分割しておけば、動的import関数を用いて動的にコードをインポートができる。

そして、Reactで必要になった場面で動的にインポートを行ってくれるのがReact.lazyである。

https://ja.legacy.reactjs.org/docs/code-splitting.html

React.lazyを使うことで、そのコンポーネントが始めて評価されるタイミングでサーバからソースコードをダウンロードできるようになる。

<Suspense>コンポーネントを使うと、読み込み中のコンポーネントの代わりに表示するフォールバックを指定することができる。

データフェッチ中のフォールバック

Suspenseのもう一つの用途としては、Promiseがまだ完了していないときにフォールバックを表示させることである。例えば、fetch等の非同期の呼出が完了するまでの間、フォールバック画面を出すことができる。

いくつかのデータフェッチライブラリが対応している。

(脱線)Suspenseの実体

Suspenseは実体のあるコンポーネントではない。

https://sourcegraph.com/github.com/facebook/react/-/blob/packages/shared/ReactSymbols.js?L29

REACT_SUSPENSE_TYPE = Symbol.for('react.suspense');とあるようにSymbolになっている。つまり、createElement(REACT_SUSPENSE_TYPE, { fallback: ... }, [...])のように呼び出されることになる。描画や制御はreconcilerが行うと考えられる。

(脱線)Suspenseはどのように動的importの完了(Promiseの完了)を待つのか

子コンポーネントで Promisethrow すると、そのコンポーネントを包含するSuspenseが例外をキャッチして、フォールバックを表示する。Promiseがresolvedになると、子コンポーネントの描画を行う。

内部的には恐らくErrorBoundaryの仕組みを使用しているのではないかと思う(が、詳しく調べていない)

Suspenseを試す

https://codepen.io/mohno007/pen/xbKaQyj?editors=0010

mno007mno007

useTransitionによるSuspenseの抑制

React.lazyによるコンポーネントの遅延読み込みが効果的な場面の一例として、ページの遷移がある。ページによって必要となるコンポーネントは異なるので、その単位でコードを分割しておけば、遷移のタイミングでコンポーネントを遅延読み込みができる。そして、Suspenseを使って遅延読み込み中のフォールバックを出すことができる。

しかし、フォールバックを出すことが常に望ましいとは限らない。例えば、React.lazyによるコンポーネントの遅延読み込みの場合を考えてみる。画面遷移の度にコンポーネントの遅延読み込みが発生するとする。そのような画面遷移の間にフォールバックを表示すると、それまで画面で見えていたUIはフォールバックで見えなくなってしまうし、UIが見えないので操作ができなくなってしまう。

useTransitionを使えばSuspenseによるフォールバックの表示を抑制することができる。つまり、ユーザが画面を操作できる状態のまま、コンポーネントの遅延読み込み等を伴うページ遷移が行える。

公式ドキュメントでは、すでに表示されているコンテンツが隠れるのを防ぐで解説されている。

試してみる

https://codepen.io/mohno007/pen/ByBOGgo?editors=0010

mno007mno007

useDeferredValue

useDeferredValue は渡した状態に基づくレンダリングを遅延させることができる。useDeferredValueに渡した状態が変化したときは、一旦前回の状態に基づいてレンダリングを行い、その後に新しい状態を並行レンダリングするようになる。すぐに反映したい部分とそうでない部分の両方があるようなシーンで役に立つ。

そのような例としては、入力欄がある。

Controlled inputにおいて、入力欄には入力内容を即座に反映したいが、一方で入力内容に基づいた他のコンポーネントのレンダリングは並行に裏側で実行させたいということが考えられる。

startTransitionだけを使って実現しようとすると、以下のようにstateを2つ作る必要がある。

// 検索クエリを入力したら即座にGUIに反映したい
const [input, setInput] = useState('');
// 検索クエリの変化に基づいた検索結果等のレンダリングは遅延してもよい
const [searchQuery, setSearchQuery] = useState('');

const [isPending, startTransition] = useTransition();

const handleChange = (ev) => {
  setInput(ev.currentTarget.value);
  startTransition(() => {
    // 検索結果のレンダリングは並行実行して、状態に変化があればキャンセルできるようにしておく
    setSearchQuery(ev.currentTarget.value);
  });
};

return (
  <>
    <input type="text" onChange={handleChange} value={input} />
    {/* SearchListコンポーネントはsearchQueryに基づいてHTTP問い合わせの実行と取得結果のレンダリングを行う */}
    <SearchList searchQuery={searchQuery} />
  </>
);

useDeferredValueを使うと以下のようにシンプルにできる

// 検索クエリを入力したら即座にGUIに反映したい
const [input, setInput] = useState('');
// 検索クエリの変化に基づいた検索結果等のレンダリングは遅延してもよい
const searchQuery = useDeferredValue(input);

return (
  <>
    <input type="text" onChange={handleChange} value={input} />
    {/* SearchListコンポーネントはsearchQueryに基づいてHTTP問い合わせの実行と取得結果のレンダリングを行う */}
    <SearchList searchQuery={searchQuery} />
  </>
);

検索クエリの例はReact 18のドキュメントのサンプルに載っている

mno007mno007

useTransitionのPromise対応(React 19)

useTransitionの従来の役割は、startTransition内で行ったsetStateを起因とするレンダリングを並行実行するようにするものだった。

React 19ではstartTransitionにasync functionを渡すことができるようになった。Promiseがfulfillされていないときは isPending がtrueになる。

const [data, setData] = useState(null);
const [isPending, startTransition] = useTransition();

const refetchData = () => {
  startTransition(async () => {
    const fetchedData = await fetchData();
    startTransition(() => {
      setData(fetchedData);
    });
  });
};

これだけを見ると、従来の役割とは全然違うように感じられる。useTransition をPromiseの管理ができるように拡張しただけに見えるかもしれない。

このような拡張がなされた理由を理解するには、新しく導入された useOptimistic を見るとよさそうだ。

mno007mno007

useOptimistic(React 19)

楽観的更新を実現するためのフックAPI。

ユーザがデータを更新したとき、たいていの場合サーバに送信するだろう。サーバに問い合わせる以上、多少の時間がかかる。厳密性や正確性を考えるなら更新の完了を待ち、入力欄の操作を無効化したり、ローディングインジケータを表示するべきかもしれない。しかし、更新に失敗することがほとんど無い場合やミッションクリティカルでない場合、いちいち待つ必要はないかもしれない。サーバの処理が完了するのを待つのではなく、クライアントで更新後の状態を再現して表示すれば、実際には完了していないがユーザは変更がすぐに反映されたと感じるようになる。ユーザは更新中などのインジケータが表示されている間、いちいち待つ必要がなくなるので、操作が快適になる。このようなUIのパターンを楽観的更新と呼ぶ。

useOptimisticを使うと、useTransitionのisPendingの間だけ、楽観的更新された状態を表示することができる。