Web●●●○○

状態管理

State Management ・ じょうたいかんり

画面に散らばる状態を一元管理し、UIの複雑さを予測可能に保つフロントエンド設計の要。

✦ 30秒で試す — 航海過去のある瞬間の画面へ、そっくり巻き戻せるアプリがある書き写しがすべて終わったとき、3か所の未読数は?予想してみる

概要

状態管理(state management)は、フロントエンドアプリが抱える「状態」— 入力中のフォーム、開いているモーダル、ログイン中のユーザー、サーバから取得した一覧データなど — をどこに置き、どう更新し、どうUIへ届けるかを設計する分野です。画面は状態の写像なので、状態の置き方が乱れるとUIの乱れとして直接表面化します。

小さな画面なら各コンポーネントが自分の状態を持つだけで足ります。問題は SPA が育ったときです。同じデータ(未読件数、カート内容、ユーザー情報)を複数の画面部品が参照・更新し始めると、「誰がいつ書き換えたのか」「なぜここだけ古い値なのか」が追えなくなります。状態管理は、この複雑さを予測可能に保つための規律とライブラリ(Flux、Redux、Vuex/Pinia、Zustand など)の総称です。

なぜ生まれたか

きっかけとしてよく語られるのが、Facebook のチャット未読カウントのバグです。画面のあちこちに出る未読数がしばしば食い違い、直しても別の経路から再発する。当時主流だった双方向データバインディング(モデルとビューが互いを書き換え合う方式。リアクティビティの初期の形)では、ある変更が別のモデルを更新し、それがまた別のビューとモデルを連鎖的に書き換えるため、更新の波及経路がグラフ状に絡まり、原因の特定が困難でした。

Facebook はこの反省から2014年に Flux というアーキテクチャを発表します。骨子は「状態の更新経路を一方通行に固定する」こと。翌2015年にはこれを純化した Redux が登場し、「アプリ全体の状態を単一のストアに置き、純粋関数(同じ入力には必ず同じ出力を返し、外の世界を書き換えない関数。関数型プログラミングの基本)だけで更新する」スタイルが一世を風靡しました。Redux の作者 Dan Abramov は、関数型言語 Elm のアーキテクチャから多くを借りています。状態管理という語彙が独立した設計分野として意識されるようになったのは、この Flux/Redux 以降です。

詳細

単方向データフロー — 更新経路を一方通行にする

Flux/Redux の中心にあるのが単方向データフローです。ビューは状態を直接書き換えられません。ユーザー操作は「何が起きたか」を表すアクション(例: { type: 'カートに追加', payload: 商品ID })として発行され、ストアに集約された状態は、リデューサ(現在の状態とアクションを受け取り新しい状態を返す純粋関数。関数型プログラミングでいう畳み込みの関数)だけが更新できます。新しい状態は購読しているビューへ流れ、画面が描き直される — この一巡が常に同じ向きで回ります。

アクションストアリデューサが更新を集約状態ビュー UI操作をアクションとして発行ディスパッチ新しい状態を計算状態からUIを再描画
単方向データフロー — 状態の更新はこの一方通行の循環しか通れない

この規律の見返りは追跡可能性です。状態が変わる経路はアクション→リデューサただ1本なので、アクションのログを見れば「いつ・何が・なぜ変わったか」が全部わかります。2つの変更が同時に起きても、ストアが1つずつ順に処理するので、画面のあちこちで値が食い違うことが原理的に起きません。描画側は仮想DOMやリアクティビティが状態→UIの反映を引き受けるため、開発者は状態の設計に集中できます。

下の部品で確かめてください。双方向バインディングのまま「新着」とチャット窓の「既読」をすれ違わせると、3か所の未読数が食い違ったまま止まります。単方向に切り替えると、アクションがログに1列に積まれ、スライダーで過去の時点に戻すと全部品がそろって戻ります。

⚡ 体験: すれ違う更新で、3か所の未読数がそろうかを見る
更新の経路
新着書き写し合う書き写し合うサーバ押すと新着を送るビューヘッダーのバッジ未読 3ビューサイドバー未読 3ビュー(押すと既読)チャット窓未読 3書き写しの連鎖 0 回

アクションのログ(ストアが処理した順)

双方向バインディングには、どこから変更が来たかの記録がありません。部品同士が直接書き換え合うので、過去の時点にも戻れません。

サーバの「新着」を押し、それが画面に届ききる前にチャット窓を押して既読にしてください。3か所の未読数がどうなるかを見ます。

不変更新 — 新しいオブジェクトを返す理由

Redux のリデューサには、もう一つ大事な約束があります。受け取った状態を書き換えず、変更後の状態を新しいオブジェクトとして返すことです(state.count++ ではなく { ...state, count: state.count + 1 })。これがイミュータビリティ(不変性)の実践で、2つの効き目があります。

一つは変更の検知が安くなることです。中身を書き換える方式では、変わったかどうかを知るには中身を全部比べるしかありません。新しいオブジェクトを返す方式なら、「前の状態と同じ参照か」を比べるだけで変更の有無が分かります(参照比較。=== の1回で済む)。変わっていない部分は古いオブジェクトをそのまま共有するので、画面の部品は自分が読んでいる部分の参照だけを見て、再描画が必要かを判断できます。もう一つは、古い状態が壊されずに残ることです。アクションごとの状態を並べておけば、過去の任意の時点をそのまま取り出せます。Redux DevTools の「タイムトラベルデバッグ」(過去の時点の状態を再現する機能)はこの性質の上に成り立っています。

逆に、うっかり状態を直接書き換えると、参照が変わらないので画面が更新されない、過去の履歴まで書き換わる、という分かりにくいバグになります。現在の Redux Toolkit は Immer というライブラリを組み込み、見た目は書き換えのように書いても裏で新しいオブジェクトを作るので、このミスが起きにくくなっています。

イベントソーシングとの思想的つながり

「現在の状態は、過去に起きた出来事の列をリデューサで畳み込んだ結果である」という Redux の見方は、バックエンドのイベントソーシング(状態そのものではなく出来事の履歴を記録の主体にする設計)のフロントエンド版といえます。アクション=イベント、リデューサ=イベントの適用関数、タイムトラベル=リプレイに対応し、読み取り用に整形した派生状態(セレクタ)を分ける発想は CQRS にも通じます。フロントとバックで同じ思想が独立に有効だったことは、「変更を出来事として一本道に通す」設計の普遍性を示しています。

状態の分類 — すべてを同じ箱に入れない

現代の実務では、状態を性質で分類してから置き場所を決めるのが定石です。第一にローカル状態。入力中のテキストやアコーディオンの開閉など、1つのコンポーネントに閉じた状態で、コンポーネント自身が持てば十分です。第二にグローバル状態。ログインユーザーやテーマ設定など、画面をまたいで共有されるアプリ固有の状態で、ここが Redux や Zustand、Pinia といったストアの出番です。

第三がサーバ状態で、これは扱いが根本的に異なります。API から取得した一覧や詳細データは、真実がサーバ側にある「リモートデータのキャッシュ」であり、鮮度・再取得・楽観的更新・重複リクエストの排除といった固有の問題を持ちます。かつてはこれも Redux に詰め込んでいましたが、TanStack Query や SWR のようなサーバ状態専用ライブラリに任せる構成が主流になり、結果として「グローバルストアに残る状態は意外と少ない」ことが広く認識されました。

やりすぎ問題 — 何でもグローバルに置かない

Redux 全盛期の反省として語られるのが「やりすぎ」です。フォームの入力値からモーダルの開閉フラグまで何でも単一ストアに置いた結果、1つの入力欄のためにアクション定義・リデューサ・接続コードを書くボイラープレートが膨れ上がり、無関係な状態変更が広範囲の再描画を誘発しました。状態は使う場所の近くに置く(コロケーション)のが原則で、グローバル化は「本当に複数の離れた場所で共有・更新される」と確認できてからで遅くありません。

現在の潮流は、単一巨大ストアから「性質ごとの適材適所」への分散です。サーバ状態は専用ライブラリ、URLに置くべき状態(検索条件やページ番号)は URL そのもの、共有状態は小さなストアや signals、残りはローカルに。単方向データフローの規律という Flux の遺産は保ちつつ、箱を1つに限定しない — それが一巡りした先の現在地です。

試問

チャットアプリの未読数(いまは3)が、ヘッダーのバッジ・サイドバー・チャット窓の3か所に出ています。「新着を送る」でサーバから新着が届き、「チャット窓で既読」で未読が0になります。更新の経路は「双方向バインディング(隣どうしが値を書き写し合う)」と「単方向(アクションをストアへ送り、ストアが配る)」から選べます。

✦ 命題挑戦中

「新着」と「既読」がすれ違っても、3か所の未読数がそろい、何がどの順で起きたかを後から追える形にせよ

  • 「新着」と「既読」をすれ違わせる(1.5秒以内に続けて押す)
  • 処理が終わったとき、3か所の未読数がそろっている
  • どの変更がどの順で起きたかが記録に残っている
⚡ 体験: すれ違う更新で、3か所の未読数がそろうかを見る
更新の経路
新着書き写し合う書き写し合うサーバ押すと新着を送るビューヘッダーのバッジ未読 3ビューサイドバー未読 3ビュー(押すと既読)チャット窓未読 3書き写しの連鎖 0 回

アクションのログ(ストアが処理した順)

双方向バインディングには、どこから変更が来たかの記録がありません。部品同士が直接書き換え合うので、過去の時点にも戻れません。

サーバの「新着」を押し、それが画面に届ききる前にチャット窓を押して既読にしてください。3か所の未読数がどうなるかを見ます。