目次
概要
このブログポストでは、Reactのレンダリングとは何か、そしてReactがコンポーネントのレンダリングをどのように処理するかについての基本概念を扱います。
1. レンダリングとは何か?
レンダリングの定義
レンダリングとは、「Reactがコンポーネントに対して、現在のpropsとstateの組み合わせに基づいて、UIのどの部分をどのように表示したいか説明してほしいとリクエストするプロセス」です。
Reactはレンダリング中にコンポーネントツリーを巡回しながら、以下のことを行います。
- 更新が必要だとフラグが設定されたコンポーネントを見つけます。
- 変更フラグが設定されたコンポーネントに対して
FunctionComponent(props)またはclassComponentInstance.render()を呼び出し、DOM更新が必要かどうか確認します。 - 次の段階(コミットフェーズ)のために、2番の実行結果の出力を保存します。
レンダリングの出力例
childrenがあるコンポーネントの場合、レンダリングの結果は以下のようになります。
// JSX:
return <MyComponent a={42} b="testing">Text here</MyComponent>
// 次のように変換されます:
return React.createElement(MyComponent, {a: 42, b: "testing"}, "Text Here")
// このようにReact要素オブジェクトが生成されます:
{type: MyComponent, props: {a: 42, b: "testing"}, children: ["Text Here"]}
childrenがない場合は以下のようになります。
// 実際に動作するコンポーネント
function Greeting({ name, age }) {
return <div>Hello {name}, you are {age} years old</div>;
}
function App() {
// このJSXは...
return <Greeting name="John" age={25} />;
}
// ...このようなオブジェクトを生成します:
{ type: Greeting, props: { name: "John", age: 25 } }
参考: Reactチームは最近、Virtual DOMという用語を使わないようにしています。
Virtual DOMと聞くと、HTMLのDOM Treeを思い浮かべて、DOMツリーと同じものをメモリに持っていると考えてしまうからです。しかし、これは違います。
ReactチームではValue UIという用語を使っています。ReactではUI要素もStringやArrayと同じようにValueとして扱えます。そのため、この値を変数に保存したり、どこかに渡したり、JavaScriptで制御したりできます。
ReactのVirtual DOMはDOMツリーではなく、コンポーネントツリー(Fiberツリー、JavaScriptのObject形態を持つツリー)を扱います。
2. レンダーフェーズとコミットフェーズ
Reactは画面を表示するために、2つの概念的フェーズに分けて処理します。
レンダーフェーズ (Render Phase)
レンダーフェーズでは以下のことを行います。
- コンポーネントのレンダリングと変更の計算
- 新しいコンポーネントツリーを既存のツリーと比較
- 「リコンシリエーション(reconciliation)」というプロセスを通じてDOM変更が必要なものを収集
コミットフェーズ (Commit Phase)
コミットフェーズでは以下のことを行います。
- 更新をDOMに適用
- すべての変更は同期的に適用されます
DOM更新後
DOM更新後(画面に表示された後)には、パッシブエフェクトフェーズ(Passive Effects Phase)というフェーズが実行されます。
- refの参照先を更新します。
- クラスコンポーネント:
componentDidMountとcomponentDidUpdateを同期的に実行します。関数コンポーネント:useLayoutEffectフックを実行します。 - 関数コンポーネント:その後
useEffectフックを実行します。
重要なポイント
「レンダリング」≠「DOM更新」
コンポーネントは以下のような場合にレンダリングはされますが、DOM更新を行わないことがあります。
- コンポーネントが同じ出力を返した場合
- Reactが並行レンダリング中に作業を破棄した場合
3. Reactはレンダリングをどのように処理するか?
Reactでは、さまざまなメカニズムが再レンダリングをトリガーし、このようにトリガーされたレンダリングはキュー(Queue)に追加して処理します。
Reactで再レンダリングは以下の場合にトリガーされます。
関数コンポーネント:
useStateのsetteruseReducerのdispatch
クラスコンポーネント:
this.setState()this.forceUpdate()
その他:
- ReactDOMの最上位
render(<App>)呼び出し useSyncExternalStoreの更新- https://ja.react.dev/reference/react/useSyncExternalStore
useSyncExternalStoreは、外部ストアへのサブスクライブを可能にするReactフックです。
関数コンポーネントにはforceUpdateがありませんが、以下のようにforceUpdateの役割を実装できます。
const [, forceRender] = useReducer((c) => c + 1, 0);
4. 標準的なレンダリング動作
最も重要なルール
Reactでは、親がレンダリングされると、その中のすべての子コンポーネントが再帰的にレンダリングされます。
例: A > B > C > D
- ユーザーがBのボタンをクリックします。
setState()がBの再レンダリングをキューに追加します。- Reactはルートから検索(Render Pass)を開始します。
- Aに変更フラグがないのでそのまま通過します。
- Bに変更フラグがあるのでレンダリングします。Bは
Cを返します。 - Cには元々変更フラグがないですが、Bがレンダリングされたので、ReactはCもレンダリングします。Cは
Dを返します。 - Dにも元々変更フラグがないですが、Cがレンダリングされたので、ReactはDもレンダリングします。
重要なポイント
「Reactはpropsが変更されたかどうか気にしません。親がレンダリングされると、子コンポーネントを無条件にレンダリングします。」
例えば、ルート
<App>でsetState()を呼び出すと、ツリー内のすべてのコンポーネントが再レンダリングされます。ReactはどのDOM変更が必要か判断するためにレンダリングします。(DOM変更が必要だからレンダリングするのではありません。)
5. Reactのレンダリングルール
レンダリングは「純粋」でなければならない
レンダリングには副作用(side effect)がないようにする必要があります。
レンダーロジックでしてはいけないこと:
- 既存の変数/オブジェクトを変更すること
- ランダムな値を生成すること(
Math.random()、Date.now()) - ネットワークリクエストを送ること
- state更新をキューに追加すること
レンダーロジックでできること:
- 新しく生成されたオブジェクトを変更すること
- エラーをthrowすること
- まだ生成されていないデータをlazy初期化すること
例:純粋でないレンダリング(X)
let renderCount = 0; // 外部変数
function ImpureComponent() {
renderCount++; // ❌ 既存の変数を変更
// ❌ レンダリング中にネットワークリクエスト
fetch('/api/data').then((data) => console.log(data));
return <div>Render #{renderCount}</div>;
}
// ❌ 同じ関数を実行するが、結果が変わる。
function RandomNumber() {
const random = Math.random(); // ❌ ランダムな値を生成
return <p>{random}</p>;
}
例:純粋なレンダリング(O)
function PureComponent({ items }) {
// ✅ 新しい配列を生成して修正
const sortedItems = [...items].sort((a, b) => a.name.localeCompare(b.name));
// ✅ 条件付きエラー
if (!items || items.length === 0) {
throw new Error('Items are required');
}
// ✅ lazy初期化(一度だけ実行)
const [data] = useState(() => {
return expensiveCalculation();
});
return (
<ul>
{sortedItems.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
// 副作用はイベントハンドラーやuseEffectで処理
function ProperComponent() {
const [data, setData] = useState(null);
// ✅ ネットワークリクエストはuseEffectで
useEffect(() => {
fetch('/api/data')
.then((res) => res.json())
.then(setData);
}, []);
// ✅ ランダムな値はイベントハンドラーで
const handleClick = () => {
const randomValue = Math.random();
console.log('Random:', randomValue);
};
return (
<div>
<button onClick={handleClick}>Generate Random</button>
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
</div>
);
}
6. Fiberオブジェクト
Fiberとは?
Reactはコンポーネントインスタンスを追跡するための内部データ構造を持っています。この構造が「Fiber」オブジェクトです。
レンダリングプロセス中、Reactはこのファイバーオブジェクトのツリーを巡回しながら、新しいレンダリング結果を計算し、更新されたツリーを生成します。
Fiberオブジェクトに含まれるもの
- コンポーネントタイプ情報
- 現在のpropsとstate
- 親、兄弟、子コンポーネントへのポインタ
- 内部レンダリングメタデータ
Fiber(簡略化)
const fiberNode = {
tag: 0, // コンポーネントの種類を表す(関数コンポーネント、クラス、DOM要素など)
type: MyComponent, // 実際のReactコンポーネント関数、またはタグ名('div'など)
key: null, // key属性(リストの識別用)
stateNode: null, // 実際のDOMノードまたはクラスインスタンス
// Fiberツリー構造を形成するためのポインタ
return: null, // 親Fiberを指す
child: null, // 最初の子Fiberを指す
sibling: null, // 次の兄弟Fiberを指す
pendingProps: { name: 'React' }, // 更新中のprops(まだ反映されていない)
memoizedProps: null, // 前回のレンダーで使われたprops
memoizedState: null, // useStateやuseReducerなどで管理されるstate
alternate: null, // 前回または次回のFiberを指す(ダブルバッファリング構造)
};
ReactのコンポーネントはReactのFiberオブジェクトに対する外観(facade)だと考えると良いでしょう。
7. Fiberとレンダリング
Reactは既存のコンポーネントツリーとDOM構造を最大限に再利用して、できるだけ効率的にリレンダリングを進めようとします。
Reactが同じタイプのコンポーネントまたはHTMLノードをツリーの同じ位置にレンダリングする必要がある場合、Reactは新しくコンポーネントインスタンスを生成する代わりに既存のものを再利用しようとします。
つまり、同じ位置に同じタイプのコンポーネントをレンダリングするようにリクエストが入った場合、Reactは既存のコンポーネントインスタンスを維持し続けます。
- クラスコンポーネントの場合、既存のコンポーネントインスタンスとまったく同じインスタンスを使用します。
- 関数コンポーネントの場合、クラスコンポーネントのようにインスタンスを持っていないため、Fiberがインスタンスの代わりの役割を果たし、「このタイプのコンポーネントはここに表示される」と表します。
コンポーネントタイプ比較
Reactは===参照比較を使用してFiber内のtypeフィールドで要素を比較します。
要素のタイプが変更された場合(例:<ComponentA>から<ComponentA'>へ)、Reactは既存のツリーセクション全体を破壊し、最初から再生成します。
レンダリング中に新しいコンポーネントを生成しないでください!
レンダリング中に新しいコンポーネントを生成すると、レンダリングのたびに毎回新しいコンポーネントが生成されるため、Reactが効率的にレンダリングを管理できなくなります。
悪い例(X):
function ParentComponent() {
// レンダリングするたびに新しいコンポーネントタイプが生成されます!
function ChildComponent() {
return <div>Hi</div>;
}
return <ChildComponent />;
}
したがって、以下のように子コンポーネントを別途生成するようにすれば、Reactがレンダリングを効率的に管理できます。
function ChildComponent() {
return <div>Hi</div>;
}
function ParentComponent() {
return <ChildComponent />;
}
8. Keyとレンダリング
Keyとは何か?
ReactにおいてkeyはReactへの指示(ガイドライン)であり、実際のpropではありません(子コンポーネントには渡されません)。
Reactはkeyをコンポーネントタイプの特定インスタンスを識別するための識別子として扱います。
keyは特に可変データを持つリストをレンダリングする際に重要です。
可変データとKey
「keyはできる限りデータから取得したIDを使用すべきです。配列のインデックスはできるだけ使わないでください!」
配列インデックスを使用すると以下のような問題が発生する可能性があります。
インデックスkeyを持つ10個のアイテムリストでアイテム6-7を削除し、3つの新しい要素を追加した場合、
- [0, 1, 2, 3, 4, 5,
6, 7, 8, 9] → [0, 1, 2, 3, 4, 5, 6, 7] → [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] Reactは[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] - Reactは単に1つのアイテムが追加されたと判断し、実際には削除すべき6、7のコンポーネントインスタンスを再利用します。
- 同じインスタンスを使用しますが、まったく異なるデータを持つことになります。
- データとコンポーネントが一致しない可能性があります。
したがって、以下のように固有のIDを使用するようにすべきです。
// ✅ 固有のIDを使用
todos.map((todo) => <TodoListItem key={todo.id} todo={todo} />);
Keyの別の活用
keyはどのコンポーネントにも使用でき、keyを更新することでコンポーネントを強制的に再生成できます。(インスタンスの交代)
例:keyでコンポーネントをリセットする
function ProfileForm({ userId }) {
const [formData, setFormData] = useState({ name: '', email: '' });
// ユーザーが変わるたびにフォームがリセットされる(key変更により)
// useEffectでリセットする必要がない!
return (
<form>
<input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Name"
/>
<input
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="Email"
/>
</form>
);
}
function UserProfile() {
const [selectedUserId, setSelectedUserId] = useState(1);
return (
<div>
<button onClick={() => setSelectedUserId(1)}>User 1</button>
<button onClick={() => setSelectedUserId(2)}>User 2</button>
{/* keyが変更されるとコンポーネントが完全に再生成される */}
<ProfileForm key={selectedUserId} userId={selectedUserId} />
</div>
);
}
9. レンダーバッチ処理
レンダーバッチ処理とは、複数のsetState()呼び出しが若干の遅延を置いてキューに入り、単一のレンダーパスでレンダリングされることを意味します。
React 17以前
React 17以前のバージョンでは、イベントハンドラーでのみバッチ処理されます。したがって、以下のようにイベントハンドラー以外の更新は個別に実行されます。
setTimeout内await後- 通常のJSハンドラー
React 18以降
イベントループティック(Event Loop Tick)内のすべての更新が自動バッチングされるようになりました。
レンダーバッチの例
const [counter, setCounter] = useState(0);
const handleClick = async () => {
setCounter(0);
setCounter(1);
const data = await fetchSomeData();
setCounter(2);
setCounter(3);
};
- React 17:3回のレンダーパス(最初の2つがバッチ、await後それぞれ個別)
- React 18:2回のレンダーパス(呼び出し0-1が一緒、await後呼び出し2-3が一緒にバッチ)
10. 非同期レンダリング、クロージャ、stateスナップショット
以下のように、クロージャを意識しないことで問題が発生するケースがあります。
function MyComponent() {
const [counter, setCounter] = useState(0);
const handleClick = () => {
setCounter(counter + 1);
console.log(counter); // ❌ 元の値をログに記録
};
}
これは以下の理由で問題が発生します。
handleClickは定義された時点に存在していた変数を参照するクロージャです。- このレンダリングの間、
counterは特定の値を持ちます。 setCounter()が将来のレンダリングをキューに追加します。- 将来のレンダリングは新しい
counter変数と新しいhandleClick関数を生成します。 - しかし、現在のコピーはその新しい値を参照できません。
核心概念
「このstate変数はその時点のスナップショットです。」
以下はクロージャとstateスナップショットの例です。
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
// ❌ これは期待通りに動作しません
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// countは依然としてこのレンダリングの値(例:0)
// 3回呼び出しても結果は1です(0 + 1 = 1、3回すべて)
};
const handleClickCorrect = () => {
// ✅ 関数型更新を使用
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
// 各更新が前の値を使用するので結果は3増加
};
const handleAsyncLog = () => {
setCount(count + 1);
setTimeout(() => {
// ❌ クリック時点のcount値をログに記録(新しい値ではない)
console.log('Count in timeout:', count);
}, 3000);
};
const handleAsyncLogCorrect = () => {
setCount((prev) => {
const newCount = prev + 1;
setTimeout(() => {
// ✅ 更新された値をログに記録
console.log('Count in timeout:', newCount);
}, 3000);
return newCount;
});
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>❌ +3(動作しない)</button>
<button onClick={handleClickCorrect}>✅ +3(正しい方法)</button>
<button onClick={handleAsyncLog}>❌ Log(以前の値)</button>
<button onClick={handleAsyncLogCorrect}>✅ Log(新しい値)</button>
</div>
);
}
まとめ
- レンダリング結果はJavaScriptオブジェクト形態です。
- Virtual DOMはHTMLのDOM treeではなく、コンポーネントツリーを意味します。
- Reactで重要なポイントはVirtual DOMではなく、Value UIであるということです。
- Reactでは画面描画のためにレンダーフェーズとコミットフェーズを持っています。
- 「レンダリング」≠「DOM更新」
- 親がレンダリングされると、Reactはその中のすべての子コンポーネントを再帰的にレンダリングします。
- Reactはpropsが変更されたかどうか気にしません。親がレンダリングされると、子コンポーネントを無条件にレンダリングします。
- Reactはどのdom変更が必要か判断するためにレンダリングします。
- レンダリングは「純粋」でなければなりません。
- レンダリングプロセス中、ReactはFiberオブジェクトのツリーを巡回して、新しいレンダリング結果を計算し、更新されたツリーを生成します。
- レンダリング中に新しいコンポーネントタイプを生成してはいけません。
私のブログが役に立ちましたか?下にコメントを残してください。それは私にとって大きな大きな力になります!
アプリ広報
Dekuが開発したアプリを使ってみてください。Dekuが開発したアプリはFlutterで開発されています。興味がある方はアプリをダウンロードしてアプリを使ってくれると本当に助かります。