見出し画像

React コンポーネントの状態管理とレンダリングの内部実装をAIに教えてもらう

「React のコールバックで状態を4回変更したら、再レンダリングは何回起こる?」という質問に自信持って答えられなかったので、ChatGPT (GPT-4) に教えてもらった




これ以後は、ChatGPT の出力を元に、私が軽く書き換え・加筆をしたもの。

React のコールバックで状態を4回変更したら、再レンダリングは1回 (条件付き)

例えば、以下のようなコンポーネントがある

function ExampleComponent() {
  const [state1, setState1] = useState(0);
  const [state2, setState2] = useState(0);
  const [state3, setState3] = useState(0);
  const [state4, setState4] = useState(0);

  const handleButtonClick = () => {
    setState1(state1 + 1);
    setState2(state2 + 1);
    setState3(state3 + 1);
    setState4(state4 + 1);
  };

  console.log("ExampleComponent render");

  return <button onClick={handleButtonClick}>Click me</button>;
}

このコンポーネントで `handleButtonClick` 関数を実行すると、4つの状態更新が 1 つのバッチとしてグループ化され、コンポーネントは1回だけ再レンダリングされる。

一度だけクリックしたスクショ

ただし、状態更新がイベントハンドラーやライフサイクルメソッドの外で、非同期のコールバック内などで行われる場合、バッチ処理は保証されない。その場合、各状態更新ごとに再レンダリングが行われる可能性がある。(React 18 以降はサポートされたので、それ以前のバージョンの話)

例示されたコードは以下

export function ExampleComponent() {
  const [state1, setState1] = useState(0);
  const [state2, setState2] = useState(0);
  const [state3, setState3] = useState(0);
  const [state4, setState4] = useState(0);

  const handleButtonClick = () => {
    setTimeout(() => {
      setState1((prev) => prev + 1);
      setState2((prev) => prev + 2);
      setState3((prev) => prev + 2);
      setState4((prev) => prev + 2);
    }, 1);
  };

  console.log("ExampleComponent render");

  return <button onClick={handleButtonClick}>Click me</button>;
}
export function ExampleComponent() {
  const [state1, setState1] = useState(0);
  const [state2, setState2] = useState(0);
  const [state3, setState3] = useState(0);
  const [state4, setState4] = useState(0);

  const handleButtonClick = () => {
    void Promise.resolve().then(() => {
      setState1((prev) => prev + 1);
      setState2((prev) => prev + 2);
      setState3((prev) => prev + 2);
      setState4((prev) => prev + 2);
    });
  };

  console.log("ExampleComponent render");

  return <button onClick={handleButtonClick}>Click me</button>;
}
React 18 ではバッチ処理されてレンダリングは1回

また、React 18 以降の Concurrent Mode や `startTransition` API の導入により、再レンダリングのタイミングや方法が更に柔軟になってきている。そのため具体的なシナリオや使用しているReactのバージョンによっても異なる点があることを考慮する必要があるとのこと。


状態更新バッチ処理の大まかな流れ

ここまでの挙動を見ていくと useState の内部実装に興味が沸いたので少し追ってみた。


Reactにおける状態更新のバッチ処理は、以下のような動作をとる。

  1. 状態更新が発生する: たとえば、`setState` を連続して呼び出すなどして複数の状態更新が起きると、これらはまずキューに入る

  2. バッチ処理: Reactは、イベントハンドラーなどの一定のコンテキスト内で複数の状態更新をバッチ処理としてまとめる

  3. VDOMの再計算: バッチ処理された状態更新が適用され、新しい状態に基づいて仮想DOM (VDOM) が再計算される。このステップでは、新旧のVDOMを比較する「差分計算」(Reconciliation)が行われる

  4. DOMの更新: 差分計算の結果に基づいて、実際のDOMが更新される。このステップでのみ、ブラウザに対するDOM操作が発生する

要するに、Reactの状態更新のバッチ処理は、まとめて処理されたがVDOMを再計算へと影響を及ぼす。そしてその後、差分計算の結果に基づいて実際のDOMが更新される。

主にReactのイベントハンドラー内で行われる状態更新は、デフォルトでバッチ処理される。ただし、非Reactイベントや非同期処理のコールバック(例: setTimeout, Promise.thenなど)内での状態更新は、デフォルトではバッチ処理される (筆者注: 検証およびその後の調査で React 18 以降はサポートされたことが分かった)。


このようなバッチ処理やVDOMの仕組みは、実際のDOM操作はコストが高いため、不要な更新を極力減らすことでパフォーマンスの向上を図って設計されている。


状態更新バッチ処理の主要なコンセプト

1. Transaction

React の内部で、状態更新やライフサイクルメソッドの呼び出しは、トランザクションの中で管理される。トランザクションは、一連の操作を一つの単位としてまとめ、すべての操作が成功するか、あるいは何も実行されないようにする仕組み。このトランザクションにより、React は状態更新や副作用の処理を安全かつ一貫性を持つ。


2. Reconciliation

React のバッチ処理の核心は、Reconciliation(調整)プロセスにある。このプロセスは、新しい状態やプロップスを元に新しい仮想DOMツリーを作成し、それを前回の仮想DOMツリーと比較することで差分を特定する。この差分のみを効率的に実際のDOMに適用することで、レンダリングのパフォーマンスを最適化している。


3. Batching Strategy

React は、状態更新の際のトランザクションを始めるタイミングを制御するための バッチ処理戦略 を持っている。例えば、React イベントハンドラ内での状態更新は自動的にバッチ処理される一方で、非同期コード内ではデフォルトでバッチ処理されない (筆者注: 先述の通り、React 18 以降はサポートされた)。


4. Update Queue

React コンポーネントの状態更新は、すぐに実行されるのではなく、更新キューに追加される。トランザクションが開始されると、このキューの中のすべての状態更新が一度に処理される。これにより、複数の状態更新が一つの再レンダリングにまとめられる。


留意事項

React のバージョン16.3以降、非同期レンダリングが導入されたことで、バッチ処理の動作も変わった。`ReactDOM.unstable_batchedUpdates` APIを利用することで、通常はバッチ処理されない非同期のコードでも、明示的にバッチ処理を適用することができる。 (React 18 では当APIを利用せずともバッチ処理される。これまでの流れを把握しておくという意味で、この記述は削除しなかった)

if you're using React 18 and above, you do not need it anymore because React 18 now support automatic batching.

This means that updates inside of timeouts, promises, native event handlers or any other event will batch the same way as updates inside of React events

you can read more about it here

https://dev.to/devmoustafa97/do-you-know-unstablebatchedupdates-in-react-enforce-batching-state-update-5cn2


React の将来的な更新である Concurrent Mode では、さらに高度な非同期レンダリングや状態更新の管理が行われる予定です。これにより、React アプリケーションの応答性やパフォーマンスが向上します。

(筆者加筆: 良さそうな日本語記事があった)


useState の主要なコンセプト

`useState` の内部動作を理解するためには、まず React の Fiber architecture に関する基本的な知識が必要となる。以下は `useState` の動作を簡略化したもの。

  1. 初期化: `useState` が初めて呼ばれたとき、提供された初期値を使って状態を初期化する。

  2. 状態の保存: React は状態を "Fiber" というデータ構造に保存する。Fiber は、実際にはコンポーネントのインスタンスに関する情報を持つオブジェクトである。`useState` が呼び出されると、それぞれのフック呼び出しは順序に応じて固有のインデックスを持ち、このインデックスに基づいて状態を保存/取得する。

  3. 更新: `setCount` のような更新関数が呼び出されると、React はそのコンポーネントを再レンダリングする予定リストに追加する。この時、更新された状態とそれに関連するコンポーネントの情報がキューに追加される。

  4. 再レンダリング: 実際の再レンダリングが発生する前に、React は更新された状態を使用してコンポーネント関数を再度呼び出す。`useState` は前回のレンダリング時の状態ではなく、最新の状態を返す。

  5. バッチ処理: 通常、複数の状態更新は一つの再レンダリングでバッチ処理される。これにより、パフォーマンスが向上する。

  6. Rules of Hooks: Hook はコンポーネントのトップレベルでのみ呼び出す必要がある。これにより、React は Hook の呼び出し順序が一貫していることを保証し、状態の関連付けや更新を正確に行うことができる。


ここまでで、状態管理を理解するためにはやはり React の内部まで理解する必要があることが分かった。Fiber の存在は知っているものの、説明できるほどではなかったので追加で質問していく

まずは内部的な全体像を理解する前に、馴染みがあるライフサイクルから把握していく


関数コンポーネントの開発者向け API のライフサイクル

関数コンポーネント自体には、クラスコンポーネントのようなライフサイクルメソッドは存在しない。しかし、React Hooks を使用することで、これらのライフサイクルに相当する動作を模倣することができる。

クラスコンポーネントのライフサイクル

  1. constructor(): コンポーネントがインスタンス化される際に一度だけ実行

  2. componentDidMount(): コンポーネントがDOMにマウントされた後に一度だけ実行

  3. shouldComponentUpdate(): 状態やプロップが変更されたときに実行する。再レンダリングするかどうかを判断できる

  4. componentDidUpdate(): コンポーネントが更新された後に実行

  5. componentWillUnmount(): コンポーネントがDOMからアンマウントされる前に実行


関数コンポーネントにおけるライフサイクルの模倣

関数コンポーネントでは、`useEffect` フックを利用して上記のライフサイクルメソッドに相当する動作を実現できる

  1. Mounting (マウント時)

    • `useEffect(() => {...}, [])`

    • 第二引数に空の配列を渡すと、`componentDidMount` に相当する動作になる

  2. Updating (更新時):

    • `useEffect(() => {...})`

    • 第二引数なしで `useEffect` を使用すると、コンポーネントの再レンダリングのたびに実行される。これは `componentDidUpdate` と似た動作をする

    • `useEffect(() => {...}, [deps])`: 第二引数に特定の依存性配列を渡すと、その依存性の値が変わった時のみ実行される

  3. Unmounting (アンマウント時):

    • `useEffect(() => { return () => { ... // cleanup code }; }, [])`

    • `useEffect` の中で関数を返すと、その関数はコンポーネントがアンマウントされる前に実行される。これは `componentWillUnmount` に相当する

import React, { useState, useEffect } from 'react';

function ExampleComponent() {
    const [count, setCount] = useState(0);

    // componentDidMount 相当
    useEffect(() => {
        console.log('Component did mount!');
    }, []);

    // componentDidUpdate 相当
    useEffect(() => {
        console.log('Component did update!');
    });

    // count が変更されたときのみ実行する例
    useEffect(() => {
        console.log('Count value changed!');
    }, [count]);

    // componentWillUnmount 相当
    useEffect(() => {
        return () => {
            console.log('Component will unmount!');
        };
    }, []);

    return (
        <div>
            <button onClick={() => setCount(count + 1)}>Increment</button>
        </div>
    );
}


ここで述べられているのは開発者向けAPIのライフサイクルである。追加で内部実装におけるライフサイクルも聞いた


React の内部実装に関するライフサイクル

一般的な開発者向けAPIのライフサイクルよりもかなり複雑で、Reactの内部動作、特にReconciliation(再調整)プロセスやFiberアーキテクチャについての理解が必要になる。ここでは、Reactの内部ライフサイクルの大まかな概要を説明する。


1. Render Phase (レンダリングフェーズ)

このフェーズでは、Reactはコンポーネントツリーの変更を検出し、新しいツリーを作成する。このフェーズは割り込み可能で、Reactは必要に応じて作業を中断したり再開したりすることができる。

  • beginWork: コンポーネントの更新や子のレンダリングが行われる

  • completeWork: レンダリングが完了したコンポーネントの後処理を行う


2. Commit Phase (コミットフェーズ)

このフェーズでは、Render Phase で計算された変更が DOM に適用される。このフェーズは割り込みができないため、非常に高速に実行される。

  • preCommit: 実際の DOM 更新の直前に行われる準備作業

  • commitPlacement: 新しいコンポーネントを DOM に追加

  • commitUpdate: 既存の DOM ノードの更新

  • commitDeletion: 不要になった DOM ノードの削除


3. Effects

コミットフェーズの後、React はuseEffectフックやクラスコンポーネントのライフサイクルメソッドのような副作用を実行する

  • commitHookEffectList: 必要なフックの副作用を順に実行


Fiber アーキテクチャの役割

React の Fiber アーキテクチャは、コンポーネントツリーの各ノードを表すデータ構造。Fiber を使用することで、React は効果的に作業の優先順位を管理し、必要に応じて作業を中断したり再開したりすることができる。

Fiber ツリーは、現在のコンポーネントツリーと次のコンポーネントツリーの 2 つのツリーとして存在し、交互に更新・レンダリングのターゲットとして使用される。


React の内部実装のライフサイクルは非常に詳細で複雑であり、上記はその高レベルの概要を示したが、実際には多くの最適化や細かな処理が行われている。

こちらの記事も参考になる


Reactの内部実装における主要な登場人物

  • Elements

    • アプリケーションのUIの最小単位を表します。

    • コンポーネントの出力として見ることができる軽量なオブジェクト

  • Components

    • 関数またはクラスとして定義され、UIの一部を表す

  • Fibers

    • Reactの新しいReconciliationエンジンの中心となるデータ構造です。

    • 各Fiberはコンポーネントインスタンスに対応し、その作業に関する情報を持っている

  • Scheduler (スケジューラ)

    • Reactの作業の優先順位を決定する部分

    • 異なる優先順位のタスクを管理し、ブラウザのメインスレッドが空いているときに作業を実行する

  • Reconciler (再調整機)

    • 既存のツリーと新しいツリーを比較し、変更を検出する役割を持つ

    • この変更を「作業」としてFiberに記録します

  • Renderer (レンダラー)

    • Reconcilerによって検出された変更をDOMなどの実際のプラットフォームにコミットする部分

    • React DOMやReact Nativeなど、異なる環境には異なるRendererが存在する


大まかな流れ

  1. 初期マウントや`setState`、`forceUpdate`などがトリガーとなる。

  2. Scheduler

    • 作業の優先順位を決定

  3. Reconciliation

    • Reconcilerが現在のツリーと新しいツリーの間の差分を見つける

    • このプロセスで新しいFiberツリーが作成される

  4. Render Phase (レンダリングフェーズ)

    • 新しいFiberツリーが生成され、更新が必要なコンポーネントが決定される

    • この段階ではまだDOMへの変更は行われない

  5. Commit Phase (コミットフェーズ)

    • RendererがReconcilerの結果をもとに、実際のDOMの更新を行う

  6. Effectsの処理

    • このフェーズで useEffect フックがトリガーされ、その副作用が実行される

Notion で Mermaid 記法を描画
sequenceDiagram
    participant User as User
    participant Scheduler as Scheduler
    participant Reconciler as Reconciler
    participant Renderer as Renderer
    participant DOM as DOM

    User->>Reconciler: Trigger (e.g. setState, initial mount)
    Reconciler->>Scheduler: Request work priority
    Scheduler->>Reconciler: Assign priority
    Reconciler->>Reconciler: Compare current tree with new
    Reconciler->>Renderer: Hand over detected changes
    Renderer->>DOM: Update the actual DOM
    DOM->>User: Reflect the updates
    User->>Reconciler: Trigger useEffects (if any)
    Reconciler->>DOM: Execute side effects
    DOM->>User: Reflect the side effects


さいごに

React の状態更新バッチ処理から始まり、状態更新とReactそのものの内部実装に至るまで、全体像と登場人物をざっくり ChatGPT に解説してもらった。


具体的な部分はハルシネーションの可能性も捨てきれず (調査した部分もあるが全てではない)、情報が古い可能性もある。ただ大まかには外してはいないだろうという期待感はあるので、私にとっては良いまとめる機会となった


この記事が気に入ったらサポートをしてみませんか?