React 18におけるバッチ更新ステートの仕組みを理解する
Reactでは、setState(クラスコンポーネント)やuseState(関数コンポーネント)のセッター関数でステートを更新するたびに、Reactは再レンダリングをスケジュールします。複数のステート更新が連続して呼び出されると、複数回のレンダリングが無駄になり、アプリケーションが遅くなります。最適化のため、Reactはバッチ更新ステートの仕組みを使用します—複数のステート更新をまとめて、1回のレンダリングのみで処理します。
この記事では、バッチングの仕組みをシンプルで分かりやすく説明し、関数更新ステートとその利点に焦点を当てます。
1. バッチングとは?
バッチングとは、Reactが単一の「イベント」または「タスク」内で複数回のセッター関数(setCountなど)の呼び出しをまとめて、1回のレンダリングのみで処理することです。
クラスコンポーネントの例(旧仕組み):
// 初期値 count = 0 と仮定
increment() {
this.setState({ count: this.state.count + 1 });
this.setState({ count: this.state.count + 1 });
this.setState({ count: this.state.count + 1 });
}
// 結果: count = 1、レンダリングは1回のみ
理由: すべての呼び出しが現在のレンダリング時点の
this.state.countの値を読み取るため、最終的なcountは1になります。
関数コンポーネントと関数更新の例:
// 初期値 count = 0 と仮定
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
// 結果: count = 3、レンダリングは1回のみ
関数更新により、バッチ内の最新値に基づいて各更新が保証されます。
React Docs: State and Lifecycle
2. React 18と自動バッチング
React 18以前では、バッチングはReactが管理するイベント(onClick、onChange)内でのみ発生していました。非同期タスク(setTimeout、fetch)内のステート更新はバッチ処理されませんでした。
React 18以前の例:
handleClick = () => {
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
}, 100);
};
// React 18以前: 2回の個別レンダリング
React 18は自動バッチングをすべてのケースに拡張しました:
- setTimeout、setInterval
- 非同期コールバック
- Promise
.then()と.catch() - ネイティブイベントリスナー
React 18での例:
handleClick = () => {
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
}, 100);
};
// React 18: 2つのステート更新がバッチ処理され、レンダリングは1回のみ
React Docs: Automatic Batching
3. 関数更新ステートとは何か、その利点
関数更新ステートとは、現在のステート値を受け取り、新しいステートを返す関数でセッター関数を呼び出す方法です。
setCount(prevCount => prevCount + 1);
利点:
- 最新のステートに基づく計算を保証: 複数の連続した更新がある場合。
- 「Stale Closures」を回避: 非同期タスク内で、ステートが常に最新になります。
- バッチ更新に対して安全: バッチ処理の有無に関わらず、結果は常に正確です。
例:
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(prevCount => prevCount + 1);
}, 1000);
return () => clearInterval(id);
}, []);
// `count`を依存配列に追加する必要はありません
React Docs: Functional Updates
4. バッチングが発生しない場合
-
flushSyncを使用して即座にレンダリングする場合:
import { flushSync } from 'react-dom';
flushSync(() => {
setCount(c => c + 1);
});
setFlag(f => !f); // 2回目のレンダリング
パフォーマンスに悪影響を与える可能性があるため、特別な状況でのみ使用してください。
- Reactのライフサイクル外からの更新(まれなケース)。
React Docs: flushSync
5. パフォーマンスと再レンダリング
バッチングにより、不要なレンダリング回数が減ります。React.memo、useMemo、useCallbackと組み合わせて、子コンポーネントを最適化します。
例:
import { memo, useState } from 'react';
const Child = memo(({ text, user }) => <div>{text} - {user.name}</div>);
function Parent() {
const [count, setCount] = useState(0);
const [text, setText] = useState('Hello');
const [user, setUser] = useState({ name: 'John' });
return (
<>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<Child text={text} user={user} />
</>
);
}
注意: React.memoは浅い比較(shallow)を行います。レンダリングごとに新しいオブジェクトを渡すと、コンポーネントは再レンダリングされます。
6. ベストプラクティス
- 新しいステートが古いステートに依存する場合は、常に関数更新を使用:
setCount(c => c + 1);
- 関連する複数のステートをオブジェクトにまとめるか、
useReducerを使用:
setUser(prev => ({ ...prev, name: 'New Name', age: 30 }));
- memo、useMemo、useCallbackで子コンポーネントを最適化:
- 重いコンポーネントや、propsが頻繁に変わらない場合に使用。
- useEffectの依存配列を理解する:
// GOOD: 関数更新により、`count`を依存配列に追加する必要はありません
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []); // 空の依存配列
// BAD: `count`を依存配列に追加すると、エフェクトのクリーンアップとセットアップが毎秒実行されます
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]);
関数更新により、不安定な依存関係を省略できます。
7. 結論
バッチ更新ステートは以下を実現します:
- 不要な再レンダリングを削減。
- アプリケーションのパフォーマンスを向上。
- ステートの一貫性を保証。
React 18の自動バッチングと関数更新により、開発者は安全で効率的なコードを書くことができます。
参考: