【テクニカル・上級編】 複数useStateとオブジェクト状態の使い分け – React実践ガイド

複数 `useState` か、単一オブジェクトか? ――Reactの内部挙動から紐解く状態設計の極意

こんにちは。日々、V8エンジンとReact Fiberの機嫌を伺いながら、数万ノードを抱える巨大なツリーのパフォーマンスチューニングに明け暮れているフロントエンドアーキテクトです。

コードレビューをしていると、いまだに激しい議論が巻き起こるテーマに出くわします。
「このフォームの状態、いくつかの `useState` にバラすべきか、それとも1つのオブジェクトにまとめるべきか?」

初学者向けのチュートリアルでは「関連するデータはオブジェクトにまとめよう」と教えられ、パフォーマンス本やLinterの警告では「粒度を細かくして不要な再レンダリングを防げ」と言われる。一体、我々はどちらを信じればいいのでしょうか。

結論から言いましょう。「なんとなく」で選んでいるうちは、アプリがスケールした時に必ずパフォーマンスの地雷を踏みます。

今回は、Reactのレンダリングメカニズム、Fiberの差分検出、そしてJavaScriptのメモリ効率の観点から、この永遠の問いに終止符を打ちたいと思います。

—

1. 内部挙動から見る:`useState` 分割 vs オブジェクト化

まずは、Reactが裏側で何をやっているのかを思い出してください。Reactのステート更新は、キュー(Queue)に積まれ、バッチ処理され、最終的にレンダリングフェーズへと移行します。

個別の `useState` を並べた場合

const [name, setName] = useState(”);
const [email, setEmail] = useState(”);
const [age, setAge] = useState(0);

これらは内部的には完全に独立したフックのインデックスとしてFiberノードに保持されます。
最大のメリットは、「どれか1つを更新しても、他の状態を参照しているメモ化された子コンポーネントの再レンダリングを完全に防ぎやすい」という点です。

1つのオブジェクトにまとめた場合

const [form, setForm] = useState({ name: ”, email: ”, age: 0 });

この場合、`name` を1文字変えるだけでも、新しいオブジェクトの参照を生成し、ステート全体を置き換える必要があります。

// 更新時のボイラープレート
setForm(prev => ({ …prev, name: e.target.value }));

スプレッド構文 (`…prev`) によるシャローコピーのコストは、モダンJSエンジンでは非常に高速ですが、「オブジェクトのどこか一箇所が変わっただけで、そのステートを購読しているコンポーネント全体の再レンダリングがトリガーされる」というアーキテクチャ上のトレードオフを抱えます。

—

2. 状態の「凝集度」と「ライフサイクル」を見極める

では、どのような基準で使い分けるべきか。私の現場での判断基準は明確です。「それらの値が、常に同時に更新され、不可分の関係にあるか否か」です。

パターンA:オブジェクトにまとめるべきケース(強い凝集性)

例えば、3次元の座標や、X/Y軸のオフセット、あるいは「開始日と終了日の範囲(DateRange)」のように、「片方だけが存在することが論理的にあり得ない、または同時にバリデーションされるべきデータ」は、1つのオブジェクトにまとめるべきです。

// 座標データ:XとYは文脈上、常にペアで扱われるべき
const [position, setPosition] = useState({ x: 0, y: 0 });

// 悪い例:バラバラにすると、片方だけ更新された不整合な中間状態が生まれやすくなる
const [x, setX] = useState(0);
const [y, setY] = useState(0);

これをバラバラに管理すると、非同期更新の競合や、片方の更新漏れによるバグ(Stale Stateの温床)を生むリスクが跳ね上がります。

パターンB:個別の `useState` に分けるべきケース(独立したライフサイクル)

逆に、フォームの入力値のように、各フィールドが完全に独立して動き、お互いの変更が他に影響を与えない場合は、個別の `useState` に分解するか、後述するカスタムフックに逃がすべきです。

// 独立したUIの状態
const [isModalOpen, setIsModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [activeTab, setActiveTab] = useState(‘profile’);

これらを1つの `const [uiState, setUiState] = useState({ isModalOpen: false, isLoading: false, … })` にまとめると、モーダルの開閉ごびにローディング表示のコンポーネントまで巻き込んで再レンダリングされ、無駄なCPUサイクルを消費します。

—

3. オブジェクト状態が引き起こす「非同期バグ」の罠

ここで、オブジェクト状態を採用したときに上級エンジニアでもハマりがちな、非同期処理に起因するバグを見てみましょう。

// よくあるアンチパターン
const [state, setState] = useState({ count: 0, text: ‘hello’ });

const handleProcess = async () => {
// 非同期処理の途中で他のステート更新が走ると…
await fakeApiCall();

// ⚠️ 危険:クロージャがキャプチャした古い ‘state’ をベースに更新している
setState({ …state, count: state.count + 1 });
};

このコード、何が問題か分かりますか?
`await` の間に別のユーザーインタラクション等で `state` が更新されていた場合、スプレッド構文のベースにある `state` は古き良き幽霊(Stale Closure)となり、別の更新を握りつぶしてしまいます(Lost Update問題)。

堅牢な解決策:関数型アップデートの徹底

オブジェクトであれプリミティブであれ、前の状態に依存する更新を行う場合は、必ず関数型アップデート(Functional Update)を使用し、さらにオブジェクトの場合はプロパティの結合を安全に行う必要があります。

const handleProcessSecure = async () => {
await fakeApiCall();

// 引数の `prevState` は常に最新の信頼できる状態を保証する
setState(prevState => ({
…prevState,
count: prevState.count + 1
}));
};

このわずかな書き方の違いが、プロダクション環境での「データが消えた!」という致命的なバグを防ぎます。

—

4. パフォーマンス最適化の極み:Reducerという選択肢

「オブジェクトで状態を持ちたい。でも、複雑な更新ロジックが散らばるし、再レンダリングも制御したい」
そんな時、我々アーキテクチャチームが最終兵器として取り出すのが `useReducer` です。

`useState` で巨大なオブジェクトを管理しようとすると、コンポーネント内に `setForm(prev => ({ …prev, a: 1, b: 2 }))` のような散らかった更新ロジックが蔓延します。これを1箇所にカプセル化するのが Reducer の真価です。

import { useReducer, useCallback } from ‘useReducer’; // 実際はreactから

// 状態の初期値
const initialState = {
username: ”,
email: ”,
role: ‘guest’,
permissions: []
};

// 状態遷移の純粋関数(テスト容易性が極めて高い)
function formReducer(state, action) {
switch (action.type) {
case ‘UPDATE_FIELD’:
return { …state, [action.field]: action.value };
case ‘RESET’:
return initialState;
default:
throw new Error(`未知のアクションタイプです: ${action.type}`);
}
}

function UserProfileForm() {
const [state, dispatch] = useReducer(formReducer, initialState);

// useCallbackでディスパッチを安定化させ、子へ渡す際の不要な再描画を防ぐ
const handleChange = useCallback((field, value) => {
dispatch({ type: ‘UPDATE_FIELD’, field, value });
}, []);

return (
// フォームコンポーネント群…
<>
);
}

`useReducer` を使うと、コンポーネントの責務が「UIの描画」と「アクションのディスパッチ」に綺麗に分離され、状態更新のロジックをコンポーネント外(あるいは別ファイル)に切り出して単体テストを書くことが容易になります。これは、長期的な保守性を担保する上で絶大な効果を発揮します。

—

まとめ:アーキテクチャの指針

複数 `useState` とオブジェクト状態の使い分けにおける、実務的なファイナルアンサーは以下の通りです。

1. 完全に独立したUIの状態(開閉フラグ、タブ、入力値など)
👉 個別の `useState` に分解し、不要なレンダリングの連鎖を断ち切る。
2. 文脈上、常にセットで扱われるべき関連データ(座標、範囲、複合フォーム)
👉 オブジェクトでまとめ、必ず関数型アップデートで競合を防ぐ。
3. 状態の構造や更新ロジックが複雑化してきた場合
👉 無理に `useState` やオブジェクト職人芸をせず、`useReducer` に逃がしてロジックを純粋関数に隔離する。

「動けばいい」のフェーズを抜け出し、スケールする堅牢なアプリケーションを構築するためには、Reactが裏側でどうメモリを扱い、どうレンダリングをスケジュールしているのかを想像する力が不可欠です。

あなたの書くその一行のステート定義が、明日のアプリの軽快さを左右します。さて、今日のコードレビューでは、どんな状態設計が見つかるでしょうか。

コメント

タイトルとURLをコピーしました