状態管理が難しいのは、アプリが複数の真実のソース、非同期データ、UIのやり取り、パフォーマンスのトレードオフを同時に扱うからです。バグを減らすためのパターンを学びましょう。

フロントエンドアプリでは、状態(state)とは簡単に言えばUIが依存し、時間とともに変化しうるデータです。
状態が変わると、画面はそれに合わせて更新されるべきです。画面が更新されなかったり、更新が不揃いだったり、古い値と新しい値が混在して表示されると、すぐに「状態の問題」を感じます—ボタンが無効のままになっている、合計が合わない、あるいはユーザーが行った操作を反映していないビューなどです。
状態は小さなやり取りにも大きなやり取りにも現れます。例えば:
これらのうち一部は「一時的」なもの(選択中のタブのような)であり、他は「重要」に感じるもの(カートの中身など)です。どれも現在のUI描画に影響するため、状態と呼ばれます。
普通の変数はそれが存在する場所でしか意味を持ちません。状態はこれとは異なり、ルールが伴います:
状態管理の本当の目標はデータを保存することではなく、更新を予測可能にしてUIを一貫させることです。「何が、いつ、なぜ変わったのか」に答えられるとき、状態は扱いやすくなります。それができないと、単純な機能でも予想外の挙動になります。
プロジェクト開始時は状態管理はほとんど退屈に感じられることが多いです—良い意味で。コンポーネントがひとつ、入力がひとつ、更新も明快、という状況なら、ユーザーがフィールドに入力して値を保存し、UIが再レンダリングします。すべてが見通せて、即時的で、閉じています。
テキスト入力が1つだけで、そのプレビューを表示するような例を想像してください:
この構成では、状態は基本的に「時間とともに変化する変数」です。どこに保存され、どこで更新されるかを指し示せればそれで十分です。
ローカル状態がうまく機能するのは、心のモデルとコード構造が一致しているからです:
Reactのようなフレームワークを使っていれば、アーキテクチャを深く考えなくても、デフォルトで十分なことが多いです。
アプリが「ウィジェットのあるページ」から「プロダクト」になると、状態は1箇所に留まらなくなります。
同じデータが以下のように必要とされるようになります:
プロフィール名はヘッダーに表示され、設定ページで編集され、キャッシュに入って高速ロードに使われ、ウェルカムメッセージのパーソナライズにも使われる──このようになると、問題は「この値をどう保存するか」ではなく「どこに置けばどこでも正しく表示されるか」になります。
機能が増えるごとに状態の複雑性が徐々に増すわけではなく、段階的にジャンプします。
同じデータを参照する箇所が増えるだけで、調整の問題が生じます: ビューの一貫性を保つこと、古い値の防止、どちらが更新するのかの決定、タイミング処理など。共有する状態がいくつかあり、非同期作業が絡むと、個々の機能は単純に見えても全体としては理解しにくい挙動になることがあります。
同じ「事実」を複数箇所に保存してしまうと状態は厄介になります。各コピーがずれていき、UIが自分自身と争い始めます。
ほとんどのアプリは次のような複数の「真実の場所」を持ちます:
どれもある種の状態の所有者になり得ます。問題は、それらが同じ状態を所有しようとする時に生じます。
よくあるパターン: サーバーからデータを取得してから、編集のためにローカルの状態にコピーする。例えば、ユーザープロフィールを読み込んで formState = userFromApi とする。その後、サーバーから再取得が走ったり(あるいは別のタブで更新が起きたり)すると、キャッシュとフォームの2つのバージョンが存在してしまいます。
重複は「便利そうな変換」を保存する際にも忍び込みます: items と itemsCount を両方保存したり、selectedId と selectedItem を両方持ったりする場合です。
複数の真実のソースがあると、バグの現れ方は次のようになります:
各状態について、1つの所有者を選びましょう—更新が行われる場所を決め、その他は全て**射影(projection)**として扱います(読み取り専用、派生、あるいは一方向に同期されるもの)。所有者を指せない場合は、同じ事実を二重に保存している可能性が高いです。
多くのフロントエンド状態は同期的で簡単に思えます: ユーザーがクリックして値をセットし、UIが更新される。しかし副作用はその綺麗な一連の流れを壊します。
副作用はコンポーネントの純粋な「データに基づいてレンダリングする」モデルの外に手を伸ばすあらゆる動作です:
どれも遅れて発火したり、失敗したり、何度も実行されたりします。
非同期更新は時間を変数として導入します。もはや「何が起きたか」ではなく「何がまだ起きているか」を考える必要があります。複数のリクエストが重なったり、遅いレスポンスが新しいレスポンスより後に到着したり、コンポーネントがアンマウントされた後にコールバックが状態を更新しようとすることがあります。
そのためバグはしばしば次のように見えます:
isLoading のようなブールをUIに散らかす代わりに、非同期作業を小さなステートマシンとして扱いましょう:
データとステータスを一緒に追跡し、リクエストIDやクエリキーのような識別子を持って遅延レスポンスを無視できるようにすると、「今UIは何を表示すべきか」を推測ではなく明確に決められます。
多くの状態の頭痛は単純な混同から始まります: 「ユーザーが今やっていること」を「バックエンドが正しいと言っていること」と同じ扱いにしてしまうことです。両方とも時間とともに変化しますが、従うルールが異なります。
UI状態は一時的で、インタラクション駆動です。ユーザーが期待するその瞬間の画面を描画するために存在します。
例: モーダルの開閉、アクティブなフィルタ、検索入力の下書き、ホバー/フォーカス、選択中のタブ、ページネーションUI(現在のページ、ページサイズ、スクロール位置)など。
通常、これらの状態はページまたはコンポーネントツリーに局所的です。ナビゲートするとリセットされるのは問題ないことが多いです。
サーバー状態はAPIからのデータです: ユーザープロファイル、商品リスト、権限、通知、保存された設定。これは「リモートの真実」であり、UIが何もしなくても別の誰かが編集したり、サーバーが再計算したり、バックグラウンドジョブが更新することがあります。
リモートであるため、ロード/エラー状態、キャッシュのタイムスタンプ、リトライ、無効化といったメタデータも必要になります。
UIの下書きをサーバーデータの中に入れると、再取得でローカル編集が消えることがあります。サーバーレスポンスをUI状態にルールなく保存すると、古いデータと戦ったり、重複フェッチが走ったり、画面が一貫しなくなります。
よくある失敗モード: ユーザーがフォームを編集している間にバックグラウンドで再取得が終わり、着信レスポンスが下書きを上書きしてしまう。
サーバー状態はキャッシュパターン(フェッチ、キャッシュ、無効化、フォーカス時の再取得)で管理し、共有かつ非同期として扱いましょう。
UI状態はローカルコンポーネントの状態や、真に共有が必要なUI懸念のためのコンテキストで管理し、下書きは明示的に保存するまでサーバーに戻さないようにします。
派生状態は他の状態から計算できる値です: ラインアイテムからのカート合計、元のリストと検索クエリからのフィルタ済みリスト、フィールド値とバリデーションからの canSubmit フラグなど。
こうした値を保存しておくのは便利に思えます(「total もステートに入れておこう」)。しかし、入力が複数箇所で変わるとドリフトのリスクが生じます: 保存された total がアイテムと一致しなくなったり、フィルタ済みリストが現在のクエリを反映しなくなったり、エラーを直した後も送信ボタンが無効のままだったりします。これらのバグは厄介で、個々のステート変数自体は正しいのに全体として矛盾するため検出が難しくなります。
安全なパターンは、最小限のソースだけを保存し、他は読み取り時に計算することです。Reactでは単純な関数やメモ化された計算で十分なことが多いです。
const items = useCartItems();
const total = items.reduce((sum, item) =\u003e sum + item.price * item.qty, 0);
const filtered = products.filter(p =\u003e p.name.includes(query));
大規模アプリでは「セレクター」(または計算ゲッター)がこの考え方を体系化します: 1箇所で total、filteredProducts、visibleTodos を導出するロジックを定義し、すべてのコンポーネントが同じロジックを使うようにします。
通常はレンダーごとに計算しても問題ないことが多いです。計算コストが実際に問題になる場合(高価な変換、大量のリスト、複数コンポーネントで共有される導出値など)にのみキャッシュしましょう。useMemo やセレクターのメモ化を使い、キャッシュキーが真に入力に依存することを確認してください—そうでないと、ドリフトに戻ってしまい、ただパフォーマンス目的で複雑さを増やしただけになります。
状態が厄介になるのは、誰がそれを所有しているかが不明確なときです。
状態の所有者とは、その値を更新する権利を持つアプリ内の場所です。その他の部分は(props、コンテキスト、セレクターなどを通じて)読み取ることはできますが、直接変更すべきではありません。
明確な所有権は次の2つの問いに答えます:
その境界が曖昧になると、競合する更新や「なぜこれが変わった?」という状況、再利用しにくいコンポーネントが生まれます。
状態をグローバルストア(あるいはトップレベルのコンテキスト)に入れると、どこからでもアクセスできプロップドリリングを避けられるので一見きれいに見えます。しかしトレードオフは意図しない結合です—無関係な画面が同じ値に依存するようになり、小さな変更がアプリ全体に波及します。
グローバル状態は、現在のユーザーセッション、アプリ全体の機能フラグ、共有通知キューのような本当に横断的なものに適しています。
一般的なパターンは、まずローカルで始め、兄弟コンポーネント間で調整が必要になったら最小の共通親に状態を上げることです。
一つのコンポーネントだけが必要ならそのままローカルに保つ。複数のコンポーネントが必要なら最小限の共有オーナーにリフトする。多くの離れた領域が必要なら、そのときにグローバルを検討する、という具合です。
使う場所の近くに状態を置き、共有が必要なときだけ上に持っていく。
これによりコンポーネントは理解しやすくなり、偶発的な依存が減り、将来のリファクタリングも安全に行いやすくなります。
フロントエンドアプリは“単一スレッド”に見えますが、ユーザー入力、タイマー、アニメーション、ネットワークリクエストは独立して走ります。つまり複数の更新が同時に進行し、開始順と完了順が一致しないことがあります。
よくある衝突: UIの異なる部分が同じ状態を更新する場合。
query を更新するquery を更新する個別には正しい更新でも、組み合わさるとタイミングによって上書きし合います。結果として、新しいフィルタを表示しているのに古い検索結果が表示される、といった食い違いが起きます。
レースはリクエストAを発行してすぐにリクエストBを発行したが、リクエストAの方が遅く戻ってきてしまうと発生します。
例: ユーザーが "c" → "ca" → "cat" と入力したとき、"c" のリクエストが遅く、"cat" のリクエストが早いと、UIが一度 "cat" の結果を表示した後に古い "c" の結果で上書きされることがあります。
バグは微妙で、すべてが「動いている」ように見えるが順序が間違っているだけです。
一般的には次のいずれかの戦略を使います:
AbortController を使う)簡単なリクエストIDの例:
let latestRequestId = 0;
async function fetchResults(query) {
const requestId = ++latestRequestId;
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
if (requestId !== latestRequestId) return; // stale response
setResults(data);
}
楽観的アップデートはUIを即時に感じさせますが、同時実行性が前提を壊すことがあります:
楽観性を安全にするには明確な照合ルールが必要です: 保留中のアクションを追跡し、サーバーレスポンスは順序通りに適用し、ロールバックが必要なら既知のチェックポイントまで戻す(現在の見た目に基づいて戻すのではなく)といった方針です。
状態更新は「無料」ではありません。状態が変わると、アプリはどの画面部分が影響を受けるかを判断し、それを反映するための作業を行う必要があります: 値の再計算、UIの再レンダリング、フォーマット処理の再実行、場合によっては再取得や再検証。もしその連鎖が必要以上に大きければ、ユーザーは遅延やカクつき、ボタンが反応するまで待つような体験をします。
1つのトグル操作が不必要に多くの作業を引き起こすことがあります:
その結果は単なる技術的問題ではなく体験の問題です: タイピングが遅く感じる、アニメーションがカクつく、インターフェースが「もたついている」と感じられます。
最も一般的な原因の1つは、状態が広すぎることです: 多くの無関係な情報をまとめた「大きなバケツ」オブジェクト。どれか1つのフィールドを更新するとバケツ全体が新しく見えるため、より多くのUIが目覚めてしまいます。
もう1つの罠は、計算で得られる値を状態に保存して手動で更新することです。これにより追加の更新(と余計なUI作業)が発生し、一貫性を保つための追加コストが生まれます。
状態を小さなスライスに分ける。 無関係な関心事を分離して、検索入力の変更でページ全体が更新されないようにする。
データを正規化する。 同じアイテムを複数箇所に保存するのではなく1箇所に保存して参照する。これにより重複更新が減り、1つの編集で多数のコピーを書き換えるような"change storm"を防げます。
派生値をメモ化する。 値が他の状態から計算できる場合(フィルタ済み結果など)、入力が実際に変わったときだけ再計算するようキャッシュします。
パフォーマンスを意識した状態管理の本質は封じ込めです: 更新は可能な限り小さな領域に影響させ、高価な処理は本当に必要なときだけ行う。そうなればユーザーはフレームワークを意識せず、インターフェースを信頼するようになります。
状態のバグは個人的な問題のように感じられます: UIが「おかしい」けれど、「誰がその値をいつ変えたのか?」という最も簡単な問いにも答えられない。値がひっくり返った、バナーが消えた、ボタンが無効になったときには、推測ではなくタイムラインが必要です。
最速の明快化パスは予測可能な更新フローです。reducer、イベント、ストアのいずれを使うにせよ、次を満たすパターンを目指しましょう:
setShippingMethod('express') のように)明確なアクションログがあれば、デバッグは「画面を眺める」作業から「レシートを追う」作業に変わります。単純なコンソールログ(アクション名+主要フィールド)でも、起きたことを再構築するよりずっと役立ちます。
すべての再レンダをテストしようとするのではなく、純粋なロジックの部分をテストしましょう:
この組み合わせにより「計算ミス」と実際の結線の問題の両方を検出できます。
非同期問題は隙間に隠れます。タイムラインを見える化するために最小限のメタデータを追加しましょう:
こうすれば、遅延レスポンスが新しいものを上書きしたときにすぐに証明でき、安心して修正に取りかかれます。
状態ツールの選択は、ライブラリ比較の前に設計上の決定の結果として扱うと簡単です。どこが純粋にローカルで、何を共有する必要があり、何が実際に「サーバーデータ」であるかをマッピングしてからツールを比較しましょう。
判断に役立つ制約は次のとおりです:
「Xを至る所で使う」と始めると、間違ったものを間違った場所に保存してしまいます。まずは所有権から: 誰が更新し、誰が読み、変化したときに何をするべきかを決めましょう。
多くのアプリはAPIデータ用にサーバー状態ライブラリを使い、クライアント専用のUI状態には小さなソリューションを使うことでうまくいきます。目的は明快さです: 各種類の状態が最も合理的に考えられる場所に存在すること。
状態の境界や非同期フローを反復しながら検討するなら、Koder.ai は実験のループを速く回せます。エージェントベースのワークフローでReactフロントエンド(とGo + PostgreSQLバックエンド)をチャットから生成できるため、ローカル vs グローバル、サーバーキャッシュ vs UI下書きといった所有モデルを素早くプロトタイプして、予測可能なものを採用できます。
状態を実験するときに役立つ2つの実用的機能: Planning Mode(構築前に状態モデルを概説する)とスナップショット+ロールバック("派生状態を削除する" や "リクエストIDを導入する" といったリファクタを安全に試して元に戻せる)。
状態は設計の問題として扱うと楽になります: 誰が所有し、それが何を表し、どのように変わるかを決めるのです。コンポーネントが「謎めいている」と感じ始めたら、このチェックリストを使ってください。
問う: どの部分がこのデータに責任を持つのか? できるだけ使用箇所に近く配置し、複数箇所で本当に必要になったときだけリフトする。
他の状態から計算できるものは保存しない。
items, filterText)visibleItems)はレンダー時かメモ化で計算する非同期作業は明示的にモデル化すると分かりやすい:
status: 'idle' | 'loading' | 'success' | 'error' と data、errorloading や error を散らばったブールではなく一級のUI状態として扱うisLoading, isFetching, isSaving, hasLoaded, …)ではなく単一のステータスを使う「どうしてこの状態になったの?」と問わなくて良い世界を目指しましょう。変更が5つのファイルを触らずに済むようになり、心のモデルとして1箇所を指して『ここが真実だ』と言えることを目標にしてください。