このドキュメントは、 React とは何であるのか、そのコアの思想は何なのか、を解説することを目的としている。
React は、 Facebook (現 Meta) によって開発された JavaScript 製の UI フレームワークである。 React は 2013 年にオープンソース化されて以来、 Web 開発における主要な UI フレームワークの一つとして広く利用されている。 しかし、React の思想は 我々にとって容易とはいいづらく、それ故に多くの誤解や混乱を生んできた。
今回は、 React の思想を正しく理解するために、トップダウン形式で体系的かつ包括的に React 思想の解説を試みる。
今回は、Web における UI フレームワークに絞って解説を行う。 そのため、React Native などについては言及を避ける。
また このドキュメントは執筆途中であり、レビューを行っていない。 そのため、誤りや間違いが含まれている可能性がある。 ファクトチェックは各自で行うこと。
また、誤りや間違いの指摘(いわゆるマサカリ) は大歓迎である。 気軽に Issue や Twitter (現 X) などで指摘してほしい。
最後に、すべての骨組みが終わり、ファクトチェックが終了した場合、正式版を Zenn などで公開する予定である。
- 宣言的 UI
- How (UI をどう描くか) を記述せず、 What (UI がどうあるべきか) を記述するスタイル
- フレームワークには、仮想的に UI の状態を表現したオブジェクトである 「要素」が存在する
- 何らかの方法で生成された「要素」を フレームワーク が受け取り
- 以下の 2 つを比較し、差分検知を行う
- フレームワーク内部に保持されている 過去の内部状態
- 今回新しく作成され フレームワークに与えられる「要素」
- その結果、以下のものが得られる
- UI の差分情報
- 今回新しく作成した 現在の内部状態
- 洗い出した差分を実 DOM に適用することで 実際の UI を更新するという設計を行う
- また、今回新しく作成した現在の内部状態は、次回以降利用するため
- フレームワーク内部に保持される
- 開発者は、その「要素」を生成する仕組み のみを実装すれば良い
- 関数で実装
- クラスで実装
- フレームワークは、要素を生成する関数やクラスを実行し、要素を取得する
- この設計により、以下の利点が得られる
- UI 更新のロジックを フレームワーク に任せることができる
- UI の状態を 宣言的に記述できるため、コードの可読性・保守性が向上する
- 具体例
- React
- 開発者は 要素を生成する仕組みを 関数(関数コンポーネント) で実装する
- React が要素生成関数 (関数コンポーネント) を呼び出し、要素を受け取る
- あとは React が 要素の差分を検知し、実 DOM に最小限の変更を加える
- Vue
- 開発者は 要素を生成する仕組みを レンダリング関数 で実装する
- Vue がレンダリング関数を呼び出し、要素を受け取る
- あとは Vue が 要素の差分を検知し、実 DOM に最小限の変更を加える
- React
- 差分検知アルゴリズム → Reconciliation
- Reconciliation とは UI のみに存在する単語ではなく、
- 宣言的なシステムを構築する際に広く用いられる概念である
- 例: Kubernetes の Reconciliation Loop
- コンポーネント合成をベースとするアーキテクチャ
- UI を 再利用可能な部品 (コンポーネント) に分割し、組み合わせて UI を構築するスタイル
- 今回の解説では「仮想 DOM」という言葉を使わない
- 「仮想 DOM」という言葉は、誤解をまねく
- 仮想 DOM という言葉は、以下の 2 つの異なる概念を混同してしまう
- React が 受け取る 要素
- React 要素 (仮想的に UI を記述したオブジェクト)
- React が 内部的に保持する 内部状態
- Fiber ツリー (仮想的に 状態を表現したデータ構造)
- React が 受け取る 要素
- React の文脈においては、
- それぞれ、「React 要素」 と 「Fiber ツリー」 と呼んでいくことにする
-
要素を生成する仕組み に 「純粋関数性」 を強く推奨する 関数型の思想
-
宣言的 UI に渡す 要素 を 生成する関数 を コンポーネント と呼ぶ
- 以後、React の要素は明示的に 「React 要素」 と呼ぶ
- React では、このコンポーネントを
- クラスではなく 関数 として定義することを推奨している
- 関数として定義されたコンポーネント = 関数コンポーネント
- そして 関数コンポーネントの 実装 において、
- 関数型プログラミング の 概念 や パターン を 多く取り入れている
-
具体的には、関数コンポーネントに対して、以下の特性を持つように設計することを要求している
- A. 同じ入力に対して同じ出力を返す関数
- つまり、入力値 以外の 外部の値から読み取りを行わない関数
- B. 関数の実行で外部の値を変更しない関数
- つまり、戻り値 以外の 外部の値に書き込みを行わない関数
- A. 同じ入力に対して同じ出力を返す関数
-
この A/B の両方を満たす関数 = 純粋関数 とし、
- 関数コンポーネントは 純粋関数 であることを強く推奨している
-
UI を記述する React 要素を生成する 関数コンポーネントが
- f(...) = React 要素 という純粋関数性 を 保つように設計する、というのは
- React の 真髄とも言える重要な設計思想である
- この設計思想があることの大きな利点は 2 つある
-
- 責務の分離
- 従来の UI フレームワークでは、
- 純粋な UI の記述
- 外部システムとのやり取り
- の 2 つの責務が混在しがちだった
- React では この 2 つの責務を明確に分離する設計を要求することで
- 開発者が 強制的に これらの責務の混在しないコードを書くことを促す
- そのため、コード品質が一定に保たれ、無秩序なコードが減る (理論上は)
- これは開発者から見て大きな利点となる
- 余談:
- しかし、この思想はしばしば
- 「React は 過度に複雑である」という批判の的にもなる
- むずかしいところ
- 余談:
-
- 純粋関数性 を前提とした 最適化 や 並列処理
- React 内部では、関数コンポーネントが純粋関数性を持つことを前提に
- 実行の並列化 (React 18 の Concurrent Features による 並列レンダリング など)
- 関数コンポーネントの 実行一時停止・再開による ユーザ操作への 応答性向上
- パフォーマンスの最適化 (React Compiler による メモ化の自動化 など)
- 関数コンポーネントの 実行停止 (サスペンド) による ロード画面表示
- を 行うことができる
- これは React 自体の性能向上や ユーザ体験の向上 に寄与する
- React は 今後も 純粋関数性を前提とした 最適化 や 並列処理 の技術を積極的に導入していく予定である
- そのため、React の 最適化 や 並列処理 の恩恵を最大限に受けるためにも
- 関数コンポーネントは 純粋関数性 を持つように設計することが重要である
- 純粋関数性を保つことは、開発者・React 双方にとって 大きな利益をもたらす
-
A/B の詳細を見ていく
- A. 同じ入力に対して同じ出力を返す関数
- 同じ入力値 state, props, context の組み合わせに対しては、常に同じ React 要素 を返すような関数
- 擬似的な式で表現すると、
- f(state, props, context) = React 要素
- props, state もまとめて状態として考えることもできるため、
- 簡単に f(state) = UI と表現されることもある
- また、 React 公式はこの性質を「idempotent (冪等)」と表現している
- 厳密な数学的定義とは異なるが、同じ入力に対して同じ出力を返す性質を指す
- そのため、以後は 冪等な関数 という表現も用いる
- この関数が A の性質を満たすためには、外部システムからの値の読み取りを避ける必要がある
- ブラウザの APi の呼び出しによる値の読み取り
document.title読み取り
- fetch による外部サーバからのデータ取得
- localStorage からのデータ取得
- ランダム値の生成と読み取り
- 現在時刻の読み取り
- ブラウザの APi の呼び出しによる値の読み取り
- B. 関数の実行で外部の値を変更しない関数
- 関数の実行によって、外部システムに影響を与える処理 を行わない関数
- この関数が B の性質を満たすためには、外部システムへの値の書き込みを避ける必要がある
- ブラウザの API の呼び出しによる値の変更
document.titleの値変更
- fetch による外部サーバへのデータ送信
- localStorage へのデータ保存
- 実 DOM ノード の直接操作
- DOM ノードの直接操作とは、React の管轄外の操作であると言えるため
- React 管轄外の外部システムに対する値の変更に該当する
- 非 React の 命令的ライブラリ の初期化・破棄
- その他、React 要素の生成に関係のない行為
setTimeoutやsetIntervalのタイマー登録
- ブラウザの API の呼び出しによる値の変更
- A. 同じ入力に対して同じ出力を返す関数
-
A/B で禁止された処理は、おおむね「外部システム」とのやり取りに該当する
-
この外部システムとのやり取りに該当する処理を、React では「副作用」と呼ぶ
-
React は、関数がこの副作用を持たないことを強く推奨している
- なお、「要素を生成する関数」である関数コンポーネントが副作用を持たない設計にすることを要求しているだけであり、
- React で記述するアプリケーション全体に対して 一切の副作用を禁止しているわけではない
- むしろ React は、副作用処理を記述する手段を提供している
- 副作用を実行するための手段 は 別途 提供されている (後述)
- なお、「要素を生成する関数」である関数コンポーネントが副作用を持たない設計にすることを要求しているだけであり、
-
なお、これ以降
- state, props, context を まとめて 状態群 と呼ぶことにする
-
-
入力値 である 状態群 の値が変化した時に React が React 要素を生成しなおす仕組み
- 関数コンポーネントは
- 入力値 である state, props, context の組み合わせ (状態群) から
- 純粋に React 要素 を生成する関数
- React では、React 要素の生成関数も React が呼び出す
- 開発者は、関数コンポーネントを定義し React に登録するだけで良い
- 入力値である 状態群の いずれかの値が変化した時に
- React 関数コンポーネントを呼び出し、
- React 要素 を生成する
- 余談: props の場合は props の値の変化を検知するというより、
- 親コンポーネントが再レンダーされるタイミングで props は 「変化した」とみなされ、
- 関数コンポーネントが再実行される
- これにより、 UI の状態変化に応じた React 要素の更新が実現される
- 入力 が 変化した際に それに反応して 出力 (React 要素) を 再生成 するという設計
- 関数コンポーネントは
-
入力値は スナップショット (イミュータブル) なデータとして扱う という設計思想
- 関数コンポーネントの実行処理における 状態群 のイミュータブル性
- 状態群 (state, props, context) は
- 関数コンポーネント実行時に導出され、固定される
- そのため、関数コンポーネントの実行の途中に、これらの値を即時に変化させることはできない
- つまり、関数コンポーネント実行時における 状態群は
- 関数コンポーネントの実行開始時に値が固定され、
- 終了まで変化しない、イミュータブル (不変) なデータとして扱われる
- 状態群 (state, props, context) は
- スナップショット という考え方
- スナップショット とは、
- 「ある時点の状態を切り取った」イミュータブル (不変) なデータのこと
- React は状態群に対して スナップショット という考え方を導入している
- React においては、状態群 がスナップショットとして扱われ、
- 一つのレンダーフェーズ (関数コンポーネントの実行) において、
- 状態群の値が変化せず一貫して同じ状態が保たれることを保証している
- 状態群 がスナップショットとして固定されることで
- 1 つの関数コンポーネント実行中において、
- 一貫して同じ状態が保たれることが保証され、レンダリングの予期しないバグを防止できる
- 余談: スナップショット と バッチ処理の関連性
- React は、状態群 をスナップショットとして扱うことで
- 必然的に バッチ処理が要件として必要になってくる
- 詳細は後述する
- 余談: JS の仕組みから イミュータブルになっている訳ではない
- state, props, context 自体は JS のオブジェクトであり、
- JS の仕組み上は ミュータブル (可変) である
- そのため、技術的には 状態群 の値を 変更することは可能である
- しかし、React の設計思想として
- 関数コンポーネント実行時における 状態群 は
- スナップショット として扱われ、イミュータブル なデータとして扱われると想定されている
- そのため、これらの値を 関数コンポーネント実行中に 変更することは React の設計思想に反する
- 開発者は、これらの状態群の値を 変更するのを避け、
- イミュータブル なデータとして 扱うように努める必要がある
- state, props, context 自体は JS のオブジェクトであり、
- 関数コンポーネントの実行処理における 状態群 のイミュータブル性
-
f(...) = React 要素 という純粋関数性 を保ち、副作用を外部に逃がすための 手段
-
React は f(...) = React 要素 という純粋関数性 を保つことを前提に設計されていることは前述した
-
React 要素 を生成する関数コンポーネントには、
- React 管轄外の 外部システム とのやり取り(副作用) の記述 を含めるべきではない
- React 管轄外 の 外部システム とのやり取りの例は 前述の通り
-
そのため React は、関数コンポーネント実行の「外部」で副作用 の実行を設定する手段を提供している
-
副作用の記法の解説をする前に、重要な仕組みである useEffect について触りだけ解説する
-
前提知識: useEffect
- まずは この useEffect の技術的な詳細を解説する
- useEffect は、技術的に見れば
- React 要素から生成された 変更が 実 DOM に反映された後 に
- 任意の処理を差し込むことができる仕組みのこと
- 以下の 2 つの処理が差し込める
- ①. セットアップ処理
- 実 DOM ノードが 反映された後 (マウント直後) に実行される処理
- ②. クリーンアップ処理
- 実 DOM ノードが 破棄された後 (アンマウント直後) に実行される処理
- ①. セットアップ処理
- この場合の処理の流れは以下の通り
-
- コンポーネントがマウントされるときに セットアップ処理 が実行される
-
- コンポーネントがアンマウントされるときに クリーンアップ処理 が実行される
-
- また、依存配列というものを指定することもできる
- 依存配列: 副作用処理が依存している入力値
- 副作用処理が依存している値 とはつまり React が値の変化を検知する必要のある値である ということ
- 依存配列には、状態群 (state, props, context) や 状態群から導出した値 など
- React 管轄内の値 のみを含める必要がある
- 依存配列を指定すると、処理が以下のようになる
- ①. セットアップ処理
- 実 DOM ノードが 反映された後 (マウント直後) に実行される (従来と同じ)
- 依存配列が変化したタイミングで、前回のクリーンアップ処理の後に実行される
- ②. クリーンアップ処理
- 実 DOM ノードが 破棄された後 (アンマウント直後) に実行される (従来と同じ)
- 依存配列が変化したタイミングで、次回のセットアップ処理の前に実行される
- ①. セットアップ処理
- この場合の処理の流れは以下の通り
-
- コンポーネントがマウントされるときに セットアップ処理 が実行される
-
- 依存配列の値が変化したタイミングで、前回のクリーンアップ処理 が実行される
-
- 続けて、今回のセットアップ処理 が実行される
-
- コンポーネントがアンマウントされるときに クリーンアップ処理 が実行される
-
- 注意点
- 余計な依存関係を増やさないように気をつける
- useEffect の内部 でのみ利用する変数・関数は
- できるだけ useEffect の内部で定義するようにする
- これにより、依存配列に含めるべき変数・関数の数を減らすことができる
- また定数であれば、そもそも 関数コンポーネントの外部で定義してしまうのも手
- 余計な依存関係を増やさないように気をつける
- 余談: useEffect の依存配列は React 管轄内の値 のみを含める必要がある
- useEffect の依存配列は 「どのような値でも変化を検知できる」わけではない
- 別の言い方をすると、RxJS のように 値に購読を設定して
- 値の変化を検知する という仕組みではない
- そのため、React 外部の値 を依存配列に含めても 変化を検知できない
- 仕組みは以下の通り
- React の仕組みとして、
- 状態群 (state, props, context) の変化に合わせて 関数コンポーネントを再実行する ことは 前述の通り
- 関数コンポーネントが再実行されたとき、
- 前回の実行時と今回の実行時の 依存配列内の値 をそれぞれ比較し
- いずれかの値が変化していた場合にのみ
- useEffect 内の処理を再実行する仕組み となっている
- そのため、依存配列に React 管轄外 である 外部システムの値を含めたとしても
- React に外部システムの値の変化を検知する仕組みがないため
- 依存配列に React 外部の値を含めることは意味がない
- 依存配列には React 管轄内の値 のみを含める必要がある
- useEffect の依存配列は 「どのような値でも変化を検知できる」わけではない
- useEffect の技術的な側面は以上である
- しかし、技術的な側面だけ見て useEffect を利用すると
- React の設計思想に反した使い方をしてしまう可能性が高い
-
副作用 の持つ特性
- 副作用が React 管轄外のシステムとのやり取りであることは前述の通り
- すべての 副作用は、何かの事象に反応して発生する トリガ処理とみなすことができる
- React が 実 DOM を反映した後・破棄した後 を トリガ として発生する副作用
- ユーザが意図を持って行った操作 を トリガ として発生する副作用
- React の 状態群 (state, props, context) や 状態群から導出した値 の値が変化したこと を トリガ として発生する副作用 など
-
React における 副作用の記述
- React において、典型的な 副作用の記述を区分してみると、いくつかのパターンに区分することができる
-
- React が DOM ノードを 反映・破棄 したタイミングで 外部のシステムを初期化・破棄 する副作用
-
- React 管轄内の値 に基づいて、外部システム に その値を反映する 副作用
-
- 何かのイベントに応じて 実行される副作用
-
- これらについて それぞれのトリガが何であるか、どのように実装するべきかを解説する
-
- React が DOM ノードを 反映・破棄 する タイミングで 外部のシステムを初期化・破棄 する副作用である場合
- 何かの事象 = React が DOM ノードを 反映・破棄 したという事象に対するトリガ といえる
- エフェクトを利用して記述せよ
- 具体的には useEffect を利用し、useEffect のセットアップ関数に 副作用処理を記述せよ
-
- React 管轄内の値 に基づいて、外部システム に その値を反映する 副作用である場合
- React 管轄内の値 = 状態群(state, props, context) および 状態群から派生させた値 など
- 1 の事象に加え、React の 管轄内の値 が変化したという事象に対するトリガ といえる
- 依存配列に React 管轄内の値 も含めてエフェクトを利用せよ
- 具体的には useEffect を利用し、依存配列に React 管轄内の値 を含めてから 1 と同様に 副作用処理を記述せよ
-
- 何かのイベントに応じて実行される副作用である場合
- 何かの事象 = ユーザが意図を持って行った操作 に対するトリガ といえる
- イベントハンドラ内に記述せよ
- 具体的には、イベントハンドラ として コールバック関数を定義し、
- その関数内に副作用処理を記述せよ
-
- 以上の 3 つが、React における 典型的な副作用処理のパターンと
- それぞれに対応する React が提供する 副作用実行手段 である
- ここから 具体的な解説を行っていく
-
- React が DOM ノードを 反映・破棄 したタイミングで 外部のシステムを初期化・破棄 する副作用
- 意図:
- DOM ノード が React によって 画面に 反映・破棄 されたタイミングというのは、
- React の コンポーネントの新規作成・破棄 が 実 DOM ノードに反映されたタイミングといえる
- 反映という事象をトリガとして 外部システム の 初期化・破棄 を 行うことで
- React 外部のシステム と React コンポーネント の ライフサイクル を同期させる という意図を持つ
- 処理の流れ
-
- セットアップ関数・クリーンアップ関数内部に、セットアップ処理・クリーンアップ処理 という 副作用を記述する
-
- useEffect へ これらの関数を渡す
-
- React が DOM ノードを 反映 したタイミングで セットアップ関数 が実行され、副作用が実行される
-
- React が DOM ノードを 破棄 したタイミングで クリーンアップ関数 が実行され、副作用が実行される
-
- 例:
- ネットワーク越しの外部サーバへの接続確立
- 初期化 = 外部サーバへの接続確立
createConnection()のような関数を呼び出し、接続を確立
- 破棄 = 外部サーバへの接続解除
connect.close()のような関数を呼び出し、接続を解除- クリーンアップ関数で実装する
- 処理の流れ
-
- コンポーネントがマウントされるときに 接続を確立
-
- コンポーネントがアンマウントされるときに 接続を解除
-
- 初期化 = 外部サーバへの接続確立
- React に対応していない コンポーネントライブラリ の初期化・破棄
- 初期化 = コンポーネントライブラリの初期化
- useRef (後述) で DOM ノード を取得し そこに React に対応していない コンポーネントライブラリ を代入する
- 破棄 = コンポーネントライブラリの破棄
- DOM ノードから コンポーネントライブラリ の
destroy()のような関数を呼び出し、破棄する - クリーンアップ関数で実装する
- DOM ノードから コンポーネントライブラリ の
- 処理の流れ
-
- コンポーネントがマウントされるときに コンポーネントライブラリを初期化
-
- コンポーネントがアンマウントされるときに コンポーネントライブラリを破棄
-
- 初期化 = コンポーネントライブラリの初期化
- 亜種: 外部サーバへのログ送信
- 外部サーバへのログ送信 のような 処理の初期化・破棄 も β の一種と捉えられるが、
- この場合は 破棄が必要ない
- 初期化 = ログ送信
fetchのような関数を呼び出し、ログを送信
- 破棄 = なし
- 処理の流れ
-
- コンポーネントがマウントされるときに ログを送信
-
- ネットワーク越しの外部サーバへの接続確立
-
- React 管轄内の値 に基づいて、外部システム に その値を反映する 副作用
- 意図:
- React 管轄内の値 (state, props, context など) が変化したという事象をトリガとして
- 外部システム に その値を反映 することで
- React 外部のシステム と React コンポーネント の 状態 を その都度同期させる という意図を持つ
- 処理の流れ
-
- セットアップ関数・クリーンアップ関数内部に、セットアップ処理・クリーンアップ処理 という 副作用を記述する
-
- useEffect へ これらの関数を渡す
-
- 依存配列に state を含める
-
- React が DOM ノードを 反映 したタイミングで セットアップ関数 が実行され、副作用が実行される
-
- state の値が変化したタイミングで、前回のクリーンアップ関数 が実行され、副作用が実行される
-
- 続けて、今回のセットアップ関数 が実行され、副作用が実行される
-
- React が DOM ノードを 破棄 したタイミングで クリーンアップ関数 が実行され、副作用が実行される
-
- 例:
- 複数回実行できる 命令的 API を持つ DOM ノード の直接操作
- 具体例:
<video>の再生制御 - React の state に合わせて 再生・一時停止 を切り替える
- DOM ノード を useRef (後述) で取得し、それに対して 再生・一時停止 を指示
- 宣言的な React の state の世界から、命令的な DOM ノード の指示へ橋渡しを行うイメージ
- この場合、依存配列に state を含める
- セットアップ関数 = state の値に合わせて 再生・一時停止 を実行
- また、この場合はクリーンアップ関数は不要
- 処理の流れ
-
- コンポーネントがマウントされるときに state の値に合わせて 再生・一時停止 を実行
-
- state の値が変化したときに 再度 state の値 に合わせて 再生・一時停止 を実行
-
- コンポーネントがアンマウントされるときは 特に何も実行しない
-
- 具体例:
- 一度しか実行できない 命令的 API を持つ DOM ノード の直接操作
- 具体例:
<dialog>の開閉制御 - React の state に合わせて 開く・閉じる を切り替える
- DOM ノード を useRef (後述) で取得し、それに対して showModal() / close() を指示
- この場合 気をつけること
- showModal() / close() は 一度しか実行してはいけない
- showModal() で 開いた dialog 要素 に対して
- 再度 showModal() を実行すると エラーになる
- そのため、依存配列に state を含めるだけでなく
- クリーンアップ関数も実装し、close() を実行するようにする
- このようにすることで、一度しか実行できない命令的 API を安全に扱うことができる
- showModal() / close() は 一度しか実行してはいけない
- セットアップ関数 = isOpen が true の場合に showModal() を実行
- クリーンアップ関数 = 常に close() を実行
- 処理の流れ
-
- コンポーネントがマウントされるときに セットアップ処理として isOpen の値が true なら showModal() を実行
-
- isOpen の値が変化したときに まず クリーンアップ処理として close() を実行
-
- 続けて セットアップ処理として isOpen の値が true なら showModal() を実行
-
- コンポーネントがアンマウントされるときに close() を実行
-
- 具体例:
- 複数回実行できる 命令的 API を持つ DOM ノード の直接操作
-
- 何かのイベントに応じて 実行される副作用
- 意図:
- ユーザが意図を持って行った操作 (クリック、入力、スクロール など) をトリガとして 発生する副作用を
- イベントハンドラ内に閉じ込めることで
- React の関数コンポーネントの実行外部に 副作用処理を逃がす という意図を持つ
- そもそもイベントハンドラ とは
- 何かのイベントが発生したときに、対応する処理を実行するコールバック関数
- イベントハンドラとは 関数コンポーネントの外部で実行される関数である
- そのため イベントハンドラ内には 外部システムに対して影響を与える処理を記述して良い
- イベントハンドラは 副作用の実行手段の一つである
- 特定のイベントに紐づく副作用は イベントハンドラに記述する必要がある
- 何かのイベントが発生したときに、対応する処理を実行するコールバック関数
- 例:
- 特定のボタンが押されたイベントに対して、処理を実行する
- キーボードが押されたイベントに対して、処理を実行する
- ウィンドウのリサイズイベントに対して、処理を実行する
- 外部サーバからの websocket 通知イベントに対して、処理を実行する
- setTimeout や setInterval のタイマーイベントに対して、処理を実行する
- 余談: setTimeout や setInterval の場合は
- 厳密には イベントハンドラ ではないが、
- 「一定時間経過後」や 「一定時間ごと」というイベントで発火する
- イベントハンドラ 的な性質を持つため、ここでは イベントハンドラ の一種として扱う
- 余談: setTimeout や setInterval の場合は
- 処理の流れ
-
- イベントハンドラ関数内部に、副作用処理を記述する
-
- 「任意のイベントハンドラ登録手段」を用いて イベントハンドラ関数を イベントハンドラに登録する
-
- ユーザが意図を持って行った操作 によって イベントハンドラ関数 が実行され、副作用が実行される
-
- 「任意のイベントハンドラ登録手段」 とは
- イベントハンドラの登録手段 は いくつか存在する
- 代表的なものとしては、以下の 2 つがある
-
- JSX の イベントハンドラ属性 を用いる手段
-
- useEffect (パターン 1) を利用する手段
- 2 の useEffect を利用する手段でイベントハンドラを登録するということは。
- 先程解説した 1 のパターンを イベントハンドラ登録に応用する ということである
- つまり、1 のパターンを 3 の内部に組み込む運用になる、ということである
- イベントハンドラ自体も副作用であり、イベントハンドラの登録も副作用であるということに気をつける
-
-
- JSX の イベントハンドラ属性 を用いる手段
- イベントハンドラを設定するための基本的な手段
- 関数コンポーネントが生成する React 要素 の 属性として
- イベントハンドラとなる関数を登録する手段
- 例: onClick 属性 に ボタンが押された時の処理を登録する
- 例:
- ボタンが押された時に新しくウィンドウを開くイベントハンドラの登録
onClick属性 に() => window.open(...)のようなイベントハンドラを登録する
- ボタンが押されたときに 外部サーバにデータを送信するイベントハンドラの登録
onClick属性 に() => fetch(...)のようなイベントハンドラを登録する
- ボタンが押された時に新しくウィンドウを開くイベントハンドラの登録
- React が管轄している要素 に対して イベントハンドラを登録する手段であるため
- 開発者は イベントハンドラの登録・解除を自力で実装する必要がなく
- React が自動で イベントハンドラの登録・解除を行ってくれる
-
- useEffect (パターン 1) を利用する手段
- 先程解説したイベントハンドラ属性は 対応する React 要素 が存在しない場合には利用できない
- 例えば
- キーボードが押されたときに処理を実行したい場合
- ウィンドウのリサイズ時に処理を実行したい場合
- 外部サーバからの websocket 通知を受け取ったときに処理を実行したい場合
- setTimeout や setInterval を用いて 時間経過後に処理を実行したい場合
- その場合、useEffect を用いて
- イベントハンドラの登録・解除を自力で実装する必要がある
- 例:
- キーボードが押されたときに処理を実行するようなイベントハンドラの登録・解除
- イベントハンドラの登録:
window.addEventListener('keydown', handler)のような関数を呼び出し、登録 - イベントハンドラの解除:
window.removeEventListener('keydown', handler)のような関数を呼び出し、解除
- イベントハンドラの登録:
- ウィンドウのリサイズ時に処理を実行するようなイベントハンドラの登録・解除
- イベントハンドラの登録:
window.addEventListener('resize', handler)のような関数を呼び出し、登録 - イベントハンドラの解除:
window.removeEventListener('resize', handler)のような関数を呼び出し、解除
- イベントハンドラの登録:
- 外部サーバからの websocket 通知を受け取ったときに処理を実行するようなイベントハンドラの登録・解除
- イベントハンドラの登録:
websocket.addEventListener('message', handler)のような関数を呼び出し、登録 - イベントハンドラの解除:
websocket.removeEventListener('message', handler)のような関数を呼び出し、解除
- イベントハンドラの登録:
- setTimeout や setInterval を用いて 時間経過後に処理を実行するようなイベントハンドラの登録・解除
- イベントハンドラの登録:
const id = setTimeout(handler, delay)やconst id = setInterval(handler, interval)のような関数を呼び出し、登録 - イベントハンドラの解除:
clearTimeout(id)やclearInterval(id)のような関数を呼び出し、解除
- イベントハンドラの登録:
- キーボードが押されたときに処理を実行するようなイベントハンドラの登録・解除
- 余談: useEffectEvent
- useEffect で イベントハンドラを登録・解除する場合、
- イベントハンドラ内 で React 管轄内の値 (state, props, context) を参照していると
- eslint により それらの値を 依存配列 に含めるように指摘される場合がある
- しかし、イベントハンドラの登録・解除 は 副作用の登録・解除 であり
- 依存配列に React 管轄内の値 を含めてしまうと 不要なタイミングで イベントハンドラの登録・解除 が発生してしまう
- その場合、あえてイベントハンドラ の コールバック関数を useEffect 外部で生成し、
- 更に useEffectEvent を用いて コールバック関数をラップする
- これにより、イベントハンドラ内 で React 管轄内の値 を安全に参照できるようになり
- かつ 依存配列に React 管轄内の値 を含める必要がなくなる
- また useEffectEvent を用いることで
- イベントハンドラ内 で参照している React 管轄内の値 (state, props, context) について
- 常に 最新の値 を参照できるようになる
- useEffect で イベントハンドラを登録・解除する場合、
- イベントハンドラ属性と useEffect の使い分け
- React 要素の中で イベントハンドラ属性を用いることが可能な場合は
- 基本的にこの手段を用いることが推奨される
- しかし 該当する React 要素 が存在しない場合は イベントハンドラ属性を用いることができないため
- useEffect を利用して 副作用を設定する必要がある
- useEffect は あくまでも イベントハンドラで副作用を設定できない場合の補完手段であるため、
- useEffect を用いる場合は 本当にイベントハンドラで副作用を設定できないのかを 注意深く検討する必要がある
- イベントハンドラ属性 → React が 管轄している要素であるため イベントハンドラの登録・解除を React が自動で行ってくれる
- useEffect → React が 管轄していない要素であるため 開発者が イベントハンドラの登録・解除を自力で実装する必要がある
- 不慣れな場合、クリーンアップ関数で イベントハンドラの解除を行うのを忘れがちになる
- パターン 1 の useEffect と同様の注意点がある
- しっかり忘れずに実装すること
- 不慣れな場合、クリーンアップ関数で イベントハンドラの解除を行うのを忘れがちになる
- React 要素の中で イベントハンドラ属性を用いることが可能な場合は
-
- 典型的な 副作用処理のパターン と それぞれに対応する 副作用実行手段 を解説した
- 実装したい副作用の内容に応じて、これらの副作用実行手段を使い分ける必要がある
- このパターンに当てはまらない場合は 臨機応変に対応しよう
- しかし よく観察してみれば これらのパターンの融合や亜種であり
- 基本的には これらのパターンに帰着できることが多い
- 余談: 今回解説しなかった useEffect の使い方: 外部システムの値 → React の状態同期
- 今回は、外部システムの値 → React の状態同期 という使い方は解説しなかった
- これらの方法は useEffect を利用するよりも
- 別の手段を用いることが推奨されるためである
- 具体的には、以下の手段がある
- 同期的な値の読み取り
localStorageやIndexedDBのような- ブラウザのストレージ からの同期的な値の読み取りにも useEffect を利用することはできるが、推奨されない
- 推奨する手段は以下の通り
- React 18 で導入された
useSyncExternalStoreの利用 - React 外部のシステムの値を同期的に読み取るための手段を提供している
- これにより、React 外部のシステムの値を React の状態として扱うことができる
- 詳細は後述
- React 18 で導入された
- 非同期なデータフェッチ
fetchのような HTTP クライアント を用いた- 非同期なデータフェッチ にも useEffect を利用することはできるが、推奨されない
- 推奨する手段は以下の通り
- React Query, SWR のような データフェッチ支援ライブラリ を利用する
- これらのライブラリは
- 外部サーバのデータを React の state と同期させるための手段を提供しており、
- 自前で useEffect を用いて データフェッチ周りの処理を実装する必要がなくなる
- また キャッシュ管理 の 他にも 再試行、ポーリング、データの永続化などの機能を提供している
- これらの処理は 独自実装すると非常に難しく コストが高いが
- これらのライブラリを利用することで ライブラリに実装を任せることができる
- また、後述する コンポーネントのサスペンド機能も提供していることが多い
- したがって、
- 非同期なデータフェッチ に useEffect を用いることは避け、
- これらの専用ライブラリを利用することが推奨される
- 副作用 パターン分岐フローチャート
- A. 副作用は 何かのイベントに応じて 実行される副作用 であるか?
- Yes → B へ
- No. → C へ
- B.
- イベントハンドラに副作用を記述する手段を用いる
- React 要素に イベントハンドラ属性を用いてイベントハンドラを登録できるか?
- Yes → イベントハンドラ属性を利用する手段を用いる (パターン 3a)
- No. → useEffect (パターン 1) を利用する手段を用いる (パターン 3b)
- C.
- 外部システムへの同期と捉える
- 副作用は React 管轄内 の値が変化したこと を トリガ として 実行される副作用 であるか?
- Yes → 依存配列に React 管轄内の値 を含めて useEffect を利用する手段を用いる (パターン 2)
- No. → D へ
- D.
- 副作用は React が DOM ノードを 反映・破棄 したタイミング
- つまり React のライフサイクルで 実行される副作用 であるか?
- Yes → useEffect を利用する手段を用いる (パターン 1)
- No. → 副作用の内容を見直す必要がある
- A. 副作用は 何かのイベントに応じて 実行される副作用 であるか?
- 余談: そもそも副作用でない場合の分岐
- 先程のフローチャートを始める前に、以下の問いを検討することが推奨される
- α. 実行したい処理は 外部システムの値を読み取る or 値に影響を与えるか?
- Yes → 副作用である → β へ
- No → 副作用ではない → React 内部の仕組みを極力利用する
- 例
- useEffect で 状態群 の変更にそって state を更新する
- 代替案: useEffect を用いずに 直接 state から値を導出する (適宜
useMemoを利用)
- 代替案: useEffect を用いずに 直接 state から値を導出する (適宜
- useEffect で state の初期化を行う
- 代替案: key 属性を用いて コンポーネントの再生成を制御する
- useEffect で アプリケーション全体の初期化を行う
- 代替案: コンポーネント外部で 初期化処理を実行する
- useEffect で 状態群 の変更にそって state を更新する
- 詳しい解説は省略する
- 例
- β. 実行したい処理は 外部システムの値 → React の状態同期 であるか?
- Yes → 副作用だが、 useEffect を用いない手段を検討する
- 例
- localStorage から値を読み取る
- 代替案:
useSyncExternalStoreを用いて同期的に値を読み取る
- 代替案:
- IndexedDB から値を読み取る
- 代替案:
useSyncExternalStoreを用いて同期的に値を読み取る
- 代替案:
- fetch を用いて 非同期にデータを取得する
- 代替案: React Query, SWR のような データフェッチ支援ライブラリ を利用する
- localStorage から値を読み取る
- 例
- No → 先程のフローチャート に従って 分岐を続ける
- Yes → 副作用だが、 useEffect を用いない手段を検討する
- このフローチャートに沿って 副作用を記述していくことが好ましい
- ただし、これは用法を意識したガイドラインであり、
- 実際の開発では いくつかの注意点がある
- 実際の開発では これらのパターンが不可分である場合がある
- 例: websocket の受信 (パターン 1 と パターン 3b が不可分)
- まず websocket 接続を確立する (パターン 1)
- 次に 受信イベントハンドラを登録する (パターン 3b)
- この場合、同一 useEffect 内で パターン 1 と パターン 3b の両方の役割を果たす方が自然である
- 無理に パターン 1 と パターン 3b を分割するメリットはなく、かえって 保守が難しくなる
- そもそも ライブラリが 提供する API 自体が これらのパターンを分割できない場合もある
- 例: websocket の受信 (パターン 1 と パターン 3b が不可分)
- では useEffect の分割境界はどこにあるのか?
- React 公式は
- 一つの useEffect ごとに、一つの外部システム への同期 となる設計を推奨している
- その指標には、「依存配列が一致する」ということが挙げられる
- 同じ外部システム にアクセスしており、かつ 依存配列が同じ場合は
- 一つの外部システムへの同期 と捉えられるため
- それらを一つの useEffect にまとめるべきである可能性が高い
- 逆に、依存配列が異なる場合や、異なる外部システム への同期である場合は
- それらを別々の useEffect に分割することが推奨される
- 以上、イベントハンドラや useEffect を用いた 副作用の記述手段について解説した
- React において、典型的な 副作用の記述を区分してみると、いくつかのパターンに区分することができる
-
これらの副作用実行手段 に加えて、
-
副作用を記述しやすくするための 補助的な手段も提供されている
- useRef と ref 属性
- コンポーネントに、任意の値を入れられるオブジェクトや 実 DOM ノード への参照を提供する手段
- 先程の 副作用処理の中で、DOM ノードを直接操作する処理で登場したもの
- useRef は、どちらかというと、副作用実行手段 というよりは 副作用の処理を記述する際に 利用するための補助的な手段である
- useRef は 2 つの責務を持つ
- どちらも「関数コンポーネントの実行外部で ミュータブルな値を保持・参照する」ための手段である
-
- 任意の値を入れられるオブジェクトの提供
- 関数コンポーネント内で
useRefを呼び出すとrefオブジェクト が得られる - この ref オブジェクトとは、
- ミュータブルに利用でき、任意の値を入れられるオブジェクト
ref.currentを持つオブジェクトである - これにより 関数コンポーネントの実行外部で ミュータブルな値を保持できるようになる
-
- 実 DOM ノード への参照の取得
- 1 で入手した ref オブジェクト を コンポーネントの
ref属性 に渡すと ref.currentが 任意の値 ではなく- ref 属性を与えた コンポーネント の 実 DOM ノード を参照するようになる
- これにより 関数コンポーネントの実行外部で 実 DOM ノード への参照を取得できるようになる
- 余談:
ref属性を与えるコンポーネントは、- 関数コンポーネントではなく DOM を持つ コンポーネントである必要がある
- DOM を持つ コンポーネント = HostComponent と呼ばれる
- 関数コンポーネントが
ref属性 を受け取る場合は、 - 関数コンポーネント内で HostComponent に
refを受け渡す必要がある
- 関数コンポーネントではなく DOM を持つ コンポーネントである必要がある
- 基本的な React のスタンスは、
- 関数コンポーネントの実行の内部では
- ミュータブルな値や 実 DOM ノード への参照を扱わないことを前提にしている
- そのため、useRef で得られた
ref.currentは、 - イベントハンドラや useEffect 内部のような 副作用実行手段 の中で利用することが想定されている
- useRef と ref 属性
-
以上、副作用実行手段および、副作用を記述しやすくするための補助的な手段について解説した
-
これらの副作用実行手段は、
- f(...) = React 要素 という純粋関数性を保ちながら、
- 外部システム との やり取りを 関数コンポーネントの外部に逃がすために提供された手段である
- 開発者は、React の思想に反しないように
- 外部システムとのやり取りを
- 副作用実行手段 を用いて
- イベントハンドラや useEffect 内部に記述し、
- 関数コンポーネントの外部で実行するように注意する必要がある
-
-
値の更新のバッチ処理
- 関数コンポーネントの実行内で 状態群をスナップショットとして扱うためには、
- 状態群 の値を途中で更新できないようになっている必要がある
- そのため、状態群 (state, props, context) の値の更新については、以下の仕組みを採用している
- state を例に解説
- 関数コンポーネント実行時に要求された 値の更新をすべて
- 「どのような更新がなされたか」を表現するデータ構造として蓄積し
- React が 状態群の値変更を検知し 関数コンポーネントの再実行を行うタイミングで
- これらの更新を 一括して適用し
- 状態群のスナップショットを新規に生成する仕組み を採用している
- 値の更新をすべて「更新オブジェクト」に蓄積し、一括して適用する仕組み = 変更のバッチ処理
- バッチ処理 = 複数の操作をまとめて一括で処理すること
- スナップショットとは 値が変化しない仕組みなので
- そのためには 値の更新をすべて蓄積しておく必要がある
- そのため、更新のバッチ処理の仕組みが自然と必要となる
- バッチ処理は 設計上 必然的に発生した仕組みであるが
- 同時に 更新のバッチ処理 によるパフォーマンス最適化効果 も得られる
- なお、props, context についても
- 基本的に props は 親コンポーネント から渡されるものであり、
- state の変更を通じて間接的に変更されるものである
- context についても、Provider コンポーネント を通じて間接的に変更されるものである
- そのため、state の変更に伴う props, context の変更も
- 間接的に 変更のバッチ処理 の対象となる
- 具体的な実装は 実装編で
- 関数コンポーネントの実行内で 状態群をスナップショットとして扱うためには、
-
更新の追跡のしやすさ
- React では、状態群 の値が変化した時に React 要素を生成しなおす仕組み を採用している
- このような設計思想により、状態更新のサイクルが単一方向のサイクルに限定される
- 例: state の変更
- useState では、データ自体 と データを変更する関数 を分離して提供している
- state が変更されると、以下の流れで UI が更新される
- state の変更関数が呼び出される
- React が関数コンポーネントを再実行
- 新しい React 要素 が生成される
- 差分検知 により 実 DOM が更新される
- 更新のサイクルが一方向に流れる
- state の更新 → UI の再生成 → UI 更新
- 例: state の変更
- これにより UI 更新の流れの予測がしやすくなる
- 双方向の状態更新 vs 単一方向の状態更新
- 双方向の状態更新
- バインドを用いて UI と状態を双方向に同期させる設計
- 例:
- Ractive.js の双方向バインディング
- Blazor の
@bind構文 - Svelte の
bind:構文 - Angular の
[()]構文 - 注意
- Svelte や Angular は仮想 DOM を利用しないが、例示のために挙げている
- これらのフレームワークでも 単一方向の状態更新 は可能であり、
- むしろそちらを推奨している場合が多い
- 例:
- メリット
- 簡単な双方向同期が容易に実装できる
- デメリット
- 同期によって状態の変更が発生してしまうため、
- 状態の変更(書き込み)が暗黙的であり、状態の変更元を追跡しづらい
- どの部分で変更が発生したかを特定しづらい
- 複雑な状態管理が難しくなる
- 一言で表現すると
- easy な実装の代わりに hard な保守性 を引き受ける設計
- バインドを用いて UI と状態を双方向に同期させる設計
- 単一方向の状態更新
- 状態の変更が一方向に流れる設計
- 例: React の状態更新サイクル
- メリット
- 状態の変更(書き込み)が明示的であり、変更元を容易に追跡できる
- どの部分で変更が発生したかを特定しやすい
- 複雑な状態管理が容易になり、デバッグや保守がしやすい
- デメリット
- 双方向同期に比べて、実装が直感的でない場合がある
- 一言で表現すると
- hard な実装の代わりに easy な保守性 を引き受ける設計
- 状態の変更が一方向に流れる設計
- 双方向の状態更新
- 近年では 双方向バインディング のデメリットが広く認識されており、
- 多くのフレームワークで 単一方向の状態更新 が推奨されている
- React は、設計から一貫して 単一方向の状態更新 を採用しており、
- 双方向バインディング問題が発生しないような設計となっている
- 余談: Elm Architecture との関連
- React の単一方向の状態更新 は、Elm Architecture と似ている
- Elm Architecture では、状態の変更が一方向に流れる設計が採用されている
- Elm Architecture について、
- 状態 (Model)
- 更新 (Update)
- ビュー (View)
- 状態更新の流れ
- 更新関数 (Update) の呼び出し → ビュー (View) の再生成 → 状態(Model) の変更
- この流れは、React の状態更新サイクルと類似している
- Elm Architecture は、 React の状態管理の仕組みである Redux に大きな影響を与えている
-
React の 全体構造
- React を支える内部構造は、大まかに以下の通りである
- React 内部状態を保持する Fiber ツリー
- React の内部状態であり、木構造で表現される
- 木構造の各ノードは Fiber ノードと呼ばれるオブジェクトで表現される
- 一番 根本には
FiberRootNodeと呼ばれる Fiber ノード - そこから、二種類の Fiber ツリーが ぶら下がる
currentFiber ツリー- 現在の UI を表現する Fiber ツリー
- レンダリングが進行している最中においては、
- 1 つ 過去の 内部状態 を表現する Fiber ツリー となる
workInProgressFiber ツリー- 次の UI を表現する Fiber ツリー
- 普段は 空の Fiber ツリー となっている
- レンダリングが進行している最中においては、
- 新しい 内部状態 を表現する 構築中 (work in progress) の Fiber ツリー となる
- レンダリングがすべて終了すると、
currentFiber ツリー に昇格される (後述)
- タスクを挿入するための ジョブキュー
- 二分ヒープ と呼ばれるデータ構造で実装されている
- 二分ヒープ は 優先度付きのキューであり、
- 優先度の高いタスクから順に取り出せるデータ構造である
- タスク管理に適している
- タスクをスケジューリングするための スケジューラ
- ジョブキュー に挿入されているタスクを
- 適切なタイミングで実行するための仕組み
- React 内部状態を保持する Fiber ツリー
- React を支える内部構造は、大まかに以下の通りである
-
React の UI 更新のライフサイクル
- React の UI 更新のライフサイクルは、以下の 5 つのフェーズで構成される
- トリガーフェーズ: タスクを挿入する
- スケジューリングフェーズ: タスクをスケジューリングする
- レンダーフェーズ: React 要素 を生成し 差分検知を行う
- コミットフェーズ: 実 DOM を更新する
- パッシブエフェクトフェーズ: 非同期的に副作用を実行する
- 何か更新が発生した際に
-
- トリガーフェーズ で
- ジョブキュー に 「関数コンポーネントの再実行 と 実 DOM 更新」 というタスクを挿入し
-
- スケジューリングフェーズ で
- ジョブキュー に挿入されたタスクを
- 優先度に応じて スケジューラ が スケジューリングし
- 適切なタイミングで 実行する
-
- レンダーフェーズ で
- 関数コンポーネントが再実行され、まず React 要素 が生成される
- 次に、過去の 内部状態である current Fiber ツリー と
- 関数コンポーネントの生成した 新しい React 要素 との間で
- 差分検知 (Reconciliation) を行い、差分を洗い出す
- 差分検知の詳細は後ほど解説
- 同時に、一つ新しい 内部状態の workInProgress Fiber ツリー を生成する
- その workInProgress Fiber ツリー に、洗い出した差分の情報を記録する
- 差分の種類として 以下のようなものがある
- ここの DOM 要素を追加せよ
- ここの DOM 要素を削除せよ
- ここの DOM 要素の属性を更新せよ
-
- コミットフェーズ で
- 3 の レンダーフェーズ で生成された Fiber ツリー をもとに
- 実 DOM に対して 最小限の変更を加える
- これが無事に終了したら、workInProgress Fiber ツリー を current Fiber ツリー に昇格させる
- また、パッシブエフェクトフェーズで実行する副作用の開始準備を行う
-
- パッシブエフェクトフェーズ で
- useEffect で登録された副作用を実行する
- useEffect で登録された副作用は 非同期的に実行されるため
- UI 更新のパフォーマンスに影響を与えない
-
- 以上の流れにより、UI の更新のライフサイクルが完了する
- 以上の解説で、それぞれのフェーズを解説する土台が整った
- React の UI 更新のライフサイクルは、以下の 5 つのフェーズで構成される
-
レンダーとコミットの二層分離による最小限の実 DOM 更新
- React 要素の 生成・差分検知と 実 DOM の更新を一度に行うと パフォーマンスが悪化する
- そのため、レンダーフェーズ と コミットフェーズ に分離している
- レンダーフェーズ → React 要素 の生成と 差分検知
- そのため 中断を可能にしている (後述)
- コミットフェーズ → 実 DOM の更新
- 実 DOM ノードを操作するため、中断不可能
- 短い時間で 一気に完了させる必要がある
- これにより、UI 更新のパフォーマンスを最適化している
-
レンダーフェーズ: key 属性による React 要素の 識別 と 差分検知
- 宣言的 UI の効率的な差分検知アルゴリズムの実現手段
- ある 木構造の差分検知を行うとき、
- 一般的に 最適なアルゴリズムで差分検知を行うと、
- O(n^3) の計算量がかかってしまうことが
- 木編集距離の研究によって知られている
- しかし、O(n^3) の計算量では 実用的なパフォーマンスを実現できない
- そこで、React では 差分検知アルゴリズムに 制約を設けることで
- 差分検知アルゴリズムの計算量を O(n) に削減している
- 差分検知アルゴリズムに設けられた 制約
- 型の一致を前提とする
- 型が違う場合は別の React 要素とみなし、
- 差分検知をやめて該当の React 要素や子 React 要素をまるごと削除し、新規に追加する
- ある親 React 要素 の下に 複数の子 React 要素がある場合、key 属性 をヒントとする
- key 属性とは、ある親の下にある兄弟 React 要素同士を識別するための一意な識別子
- key が同じ React 要素同士を対応づけ、key が異なる React 要素は別の React 要素とみなす
- 型の一致を前提とする
- この 2 つの制約により、React の差分検知アルゴリズムは O(n) の計算量で動作する
- 具体的な 差分検知アルゴリズム
- ここから先、React の差分検知アルゴリズムを、
- 内部実装にあやかって Reconciliation (和解・調停) アルゴリズム と呼ぶ
- Reconciliation アルゴリズム とは、
- 新しい React 要素 と
- 過去の 内部状態 である current Fiber ツリー
- との間で 差分検知を行い、差分を洗い出すプロセス
- 同時に 新しい workInProgress Fiber ツリー を生成するプロセスでもある
- 変更のない Fiber ノードであれば current Fiber ノード を再利用することができるため
- React は 差分検知 アルゴリズムとして 以下の 2 つを組み合わせた ハイブリッドアルゴリズム を採用している
- 位置ベースの高速な第一段階アルゴリズム (fast path)
- key ベースの第二段階アルゴリズム (slow path)
-
- 位置ベースの高速な第一段階アルゴリズム (fast path)
- 先頭の React 要素・Fiber ノード から順に
- 同じ位置の React 要素 と current Fiber ノード を比較し key が一致するかどうかをチェック
- 先頭から順に 比較していくため 単純な線形探索で済み、高速に動作する
- 最後まで一致し、React 要素・Fiber ノード 双方に 余りが発生しなかった場合
- すべての React 要素 と current Fiber ノードが対応するため、
- current Fiber ノードをすべて再利用しながら workInProgress Fiber ノードを生成して 差分検知を終了
- 最後まで一致したが、 React 要素 側に 余りが発生した場合
- まず 途中までは React 要素 と current Fiber ノード が対応しているため
- そこまでは current Fiber ノードを再利用しながら workInProgress Fiber ノードを生成
- その後、余りの React 要素 に対して、
- 再利用はせず workInProgress Fiber ノードを新規作成して 差分検知を終了
- 最後まで一致したが、 current Fiber ノード 側に 余りが発生した場合
- まず 途中までは React 要素 と current Fiber ノード が対応しているため
- そこまでは current Fiber ノードを再利用しながら workInProgress Fiber ノードを生成
- その後、余りの current Fiber ノード すべてを 削除対象とし 差分検知を終了
- 途中で不一致が発生し、React 要素・Fiber ノード 双方に 余りが発生した場合
- まず 途中までは React 要素 と current Fiber ノード が対応しているため
- そこまでは current Fiber ノードを再利用しながら workInProgress Fiber ノードを生成
- その後、第二段階アルゴリズム に移行
-
- key ベースの正確な第二段階アルゴリズム (slow path)
- 位置ベースの比較で不一致が発生した場合に実行される
- 残りの current Fiber ノード を key ごとに ハッシュマップ に格納
- ハッシュマップに格納して差分検知するため 第一段階アルゴリズム よりも メモリが必要となり、コストがかかる
- React 要素 を一つずつ処理しながら
- ハッシュマップ から key に対応する current Fiber ノード を検索
- current Fiber ノードが見つからなければ workInProgress Fiber ノードを新規作成
- 存在すれば 型・key の一致をチェック
- 一致すれば
- current Fiber ノードを再利用しながら workInProgress Fiber ノードを生成
- ハッシュマップからは削除
- 不一致であれば current Fiber ノードが見つからなかったとして扱い、workInProgress Fiber ノードを新規作成
- 一致すれば
- 最後に、React 要素 側で対応づけられなかった current Fiber ノード をすべて 削除対象とし 差分検知を終了
- React の Reconciliation アルゴリズム は このような流れで 差分検知を行い、差分を洗い出す
- key 属性 の重要性
- key 属性 は Reconciliation アルゴリズム において
- React 要素と Fiber ノードを対応づけるための重要なヒントとなる
- key 属性 を適切に設定することで
- 差分検知の精度が向上し
- 不要な再レンダーや DOM 操作を防ぐことができる
- React の開発において 開発者は key 属性を適切に設定することが求められる
- 余談: key 属性 の 不適切な利用
- key 属性 を付けていない場合
- Reconciliation アルゴリズム は fast path と slow path の両方で
- React 要素が 何番目に出現したか という位置情報 (index) のみを手がかりに
- React 要素 と Fiber ノード を対応づける
- そのため、以下のような問題が発生する
- 別の React 要素の場合でも fast path で一致してしまい、過去の Fiber ノードを誤って再利用してしまう
- slow path に移行した場合でも、index 情報のみを手がかりに React 要素 と Fiber ノードを対応づけるため
- こちらも 誤って過去の Fiber ノードを再利用してしまう可能性がある
- これにより React の管理している 内部状態が意図しない状態で再利用されてしまい、
- 別の state の混入が 予期しないバグを引き起こす可能性がある
- Reconciliation アルゴリズム は fast path と slow path の両方で
- key 属性にランダムな値を設定している場合
- 毎回異なる key により
- React 要素 と Fiber ノードをうまく対応づけられなくなり、
- Fiber ノードが 毎回新規作成されてしまう
- これにより、毎回すべての Fiber ノード が新規作成されることになり、パフォーマンスが著しく低下する
- key 属性は React 要素の一意な識別子として
- 適切に設定することが重要である
- key 属性 を付けていない場合
-
レンダーフェース: 深さ優先探索(DFS) の実装
- TODO
-
レンダーフェーズ: フックの実装と呼び出しルール、引数とのアナロジー
- TODO
-
レンダーフェーズ: useState の実装 および state 更新における バッチ処理の実装
- TODO
-
レンダーフェーズ: 中断可能なレンダー
-
レンダーフェーズ を 中断・再開 できる仕組み
- React 18 の 並行レンダリング (Concurrent Rendering) 機能 によって導入された仕組み
- 並行レンダリングの詳細は後ほど解説
-
従来のレンダーフェーズ の問題点
- 従来のレンダーフェーズ では、
- 一度レンダーを開始すると レンダー → コミットまでを 一気に完了させる必要があった
- そのため、長時間かかるレンダー処理が発生した場合に
- ユーザ操作への応答性が低下し、UX が悪化する問題があった
-
レンダーフェーズ を 中断・再開 できることで
- 長時間かかるレンダー処理を React が 小さな単位で分割して実行することが可能になる
- これにより、ユーザ操作への応答性が向上し UX が改善される
-
React の 要素生成の関数型パラダイムとの関連
- 中断可能なレンダーが正常に動作するには、関数コンポーネントが純粋関数である必要がある
- 関数コンポーネントに副作用が存在すると、
- 中断・再開 のタイミングで副作用が複数回実行される可能性があり、
- 予期しない動作を引き起こす可能性がある
- また、関数コンポーネントが 状態群 以外の外部システムの値に依存している場合も、
- 中断・再開 のタイミングで外部システムの値が変化してしまい、
- 一貫しないレンダリング結果を引き起こす可能性がある
- そのため、関数コンポーネントが純粋関数であることが
- 中断可能なレンダー の前提条件となっている
-
Fiber アーキテクチャとの関連
- 実は、並行レンダリングが可能になったのは、React のバージョン 16 で導入された Fiber アーキテクチャのおかげである
- 経緯を解説する
- React 15 まで: Stack Architecture
- レンダーでは、React 要素のツリーを 再帰構造で処理していた
- 再帰構造で追跡する = スタックを用いる
- スタックを用いた再帰処理では、一度レンダーを開始すると
- レンダー → コミット までを 一気に完了させるしかなく、
- 途中で中断・再開 することができなかった
- JS が一度走ると ブラウザは 基本的にそのタスクが終わるまで 他の処理を行えないため、
- 結果として、レンダー → コミット が ブロッキング処理となり、
- 長時間かかるレンダー処理が発生した場合に
- ユーザ操作への応答性が低下し、UX が悪化する問題があった
- React 16 以降: Fiber Architecture
- レンダーを 中断・再開 できるようにするための土台を構築した
- 今まで JS のコールスタックを用いて再帰処理していたのをやめ、
- スタック のようなデータ構造を React が独自に管理する Fiber ツリー を用いて再現し、
- 本来の再帰ではなく ループ構造による擬似的な再帰処理 を行う設計に変更した
- レンダーを 小さな単位で分割して実行できるようにし、中断・再開 を管理できるようにした
- また、優先順位や中断情報などのメタデータを React が独自に管理する仕組みを導入した
- つまり、React が レンダーフェーズ の状態を細かく管理できるようになった
- しかし、React 16/17 自体では まだデフォルトで中断可能なレンダーは提供されておらず、
- 実験的機能という位置づけであった
- レンダーを 中断・再開 できるようにするための土台を構築した
- React 18 以降: 並行レンダリング (Concurrent Rendering)
- React 18 で 新たに導入された 機能
- React 16/17 で導入した Fiber Architecture をフル活用し、
- レンダーフェーズ を 中断・再開 できる仕組みを提供する
- これにより、ユーザ操作への応答性が向上し UX が改善されることとなった
-
-
パッシブエフェクトフェーズ: useEffect の実装
- TODO
- スケジューリングフェーズ: 優先度ベースのタスクスケジューリング
- TODO
-
外部の値を React 内部に取り込むための仕組み
useSyncExternalStoreの思想- TODO
-
関数コンポーネント の実行を止め、用意が出来るまで待つ ための サスペンド状態 の思想
- 関数コンポーネント の実行段階で Promise などを用いて 非同期処理を実行する場合、
- 非同期処理が終わるまでは 関数コンポーネントの実行をやめ、
- 用意が出来てから 再び 関数コンポーネントを実行したい場合がある
- そのための仕組み が サスペンド である
- サスペンドを利用すると
- 非同期処理が終わっていない場合、関数コンポーネント の実行をそこで終了し、サスペンド状態とする
- 非同期処理が完了したタイミングで、再度 関数コンポーネント の実行を最初から始める
- コンポーネントの サスペンド により、以下の利点が得られる
- 親 コンポーネントは、子コンポーネントがサスペンド状態であることを認識できる
- これにより、親コンポーネントは 子コンポーネントがサスペンド状態であるとして、
- 代わりの UI、 fallback UI を表示することができる
- サスペンド 状態を受け取る仕組みについては 後述
- サスペンドを行うための手段
useAPI- Promise を引数に取ると、関数コンポーネントが Promise を解決するまで
- 関数コンポーネントがサスペンド状態になる
- なお、
useAPI は直接的に呼び出すことは推奨されておらず、 - ライブラリを通じて利用することが推奨されている
- データフェッチライブラリのサスペンド対応機能
- 例:
- React Query の
useSuspenseQuery - SWR の
suspense: trueオプション
- React Query の
- 例:
- 余談: レンダーの中断とは、直接的には関連していない
- コンポーネントのサスペンド → 関数コンポーネントの実行を辞めるため、実行は完全に終了する
- 中断可能なレンダー → レンダーフェーズ全体を一時的に停止し、後で再開できる
-
子 コンポーネントの特殊状態を キャッチするための仕組み レンダリング境界 (Rendering Boundaries) の思想
- 近年の React では コンポーネント に特殊状態を持たせる仕組みが増えてきている
- 例:
- サスペンド: コンポーネントが 非同期処理の完了を待つために 実行をやめる状態
- use API や データフェッチライブラリのサスペンド対応機能 などで 発生させられる
- 先程の解説を参照
- エラー: コンポーネントが 実行中に エラーを発生させた状態
- コンポーネントで 単にエラーを throw するだけで 発生させられる
- サスペンド: コンポーネントが 非同期処理の完了を待つために 実行をやめる状態
- これらの特殊状態を持つ コンポーネント が 子 コンポーネントとして存在する場合に
- 親 コンポーネント 側で それらの特殊状態を キャッチし、
- 適切に対処するための仕組み が レンダリング境界 である
- 例:
- レンダリング境界 の例
- サスペンド境界
- React の組み込み コンポーネント である
Suspenseコンポーネント - 子 コンポーネント が サスペンド 状態になった場合に
- 代わりの UI (fallback UI) を表示する
- React の組み込み コンポーネント である
- エラー境界
react-error-boundaryライブラリ のErrorBoundaryコンポーネント- 子 コンポーネント で エラー が発生した場合に
- 代わりの UI を表示する
- サスペンド境界
- レンダリング境界 により、以下の利点が得られる
- 子コンポーネント 側でハンドリングする必要がなくなった
- 今まで サスペンド・エラー と ハンドリングが 密結合 であったのを解消でき
- 親 コンポーネント 側に ハンドリング責務を移譲できるようになった
- これにより コンポーネントの関心事の分離 が促進され、
- 子 コンポーネント の 再利用性 が向上する
- 柔軟な UI 表示
- レンダリング境界 を利用することで
- 親 コンポーネント 側で 代わりの UI (fallback UI) を柔軟に定義できるため
- ユーザ体験 (UX) の向上 が期待できる
- 子コンポーネント 側でハンドリングする必要がなくなった
- 近年の React では コンポーネント に特殊状態を持たせる仕組みが増えてきている
-
サーバ側で関数コンポーネントを実行する サーバーコンポーネント (Server Components) の思想
- TODO