状態管理
今回の記事では、状態管理(State Management)について考えてみたい。ライブラリの比較記事ではない。どのツールが優れているかを決めるよりも、状態というものをどう捉え、どこに境界を引くべきかという感覚を整理するための記事だ。
今ではAIツール(Claude、ChatGPT、Cursor、Gemini、Copilot)が、私たちの仕事に深く入り込んでいる。開発速度は飛躍的に上がった一方で、率直に言えば、サービスの完成度はそれほど向上していないように感じる。機能が増えた分だけバグも増え、「なぜこうなったのか分かりません」という言葉を耳にする機会も多くなった。
開発が速くなるほど、コードを一行ずつ細かく確認することは減っていく。だからこそ筆者は、AIを正しい方向へ導ける基礎力がいっそう必要になると考えている。AIが書いたコードの問題に気づき、望む方向へ改めて導けなければ、成果物の品質は保てない。その基礎力には、ドメイン視点での開発、抽象化、TDD(Test-Driven Development、テスト駆動開発)、ライブラリの活用、パフォーマンス上の優位性の確保など、さまざまなものがあるだろう。
ところが、フロントエンドの同僚や他のIT職種の同僚に「フロントエンド開発で最も難しい作業は何ですか?」と尋ねるたび、最も多く返ってきた答えはいつも同じだった。「状態の流れを管理することです」
この記事では、なぜ状態の流れを管理することがそれほど難しいのか、そして適切に扱うためにどのような設計眼と感覚を養うべきかを整理していく。
状態(State)とは何か
本題に入る前に、最も基本的な問いから確認しよう。私たちが言う「状態」とは、正確には何だろうか。
フロントエンド開発を学ぶ中で、hoseung.me氏の記事をよく読んだ。そこでは状態を、**「UIに影響を与え得るすべてのデータ」**と説明している。いいね数、カートの中身、モーダルが開いているかどうか、入力値、ログイン中のユーザー情報、現在選択されているタブ、検索結果、ローディング中かどうか。これらはすべて状態である。
React公式ドキュメントは、もう少し形式的に定義している。ページのタイトルはそのまま「State: A Component's Memory」であり、かみ砕けば、「コンポーネントがレンダー間でデータを保持(retain)し、そのデータが更新されたときにReactへ再レンダーを促す仕組み」といったところだ。つまり、時間が経っても消えず、何らかのイベントによって更新され、更新時にUIを描き直すデータである。もう一つ押さえておくと、状態はコンポーネントのインスタンスごとに分離される。 同じコンポーネントがページ上に10個あっても、それぞれが独立した状態を持つ。この事実は、後で扱う「状態をどこに置くか」という議論に直結する。
どちらの定義も、同じものを指している。**「レンダーに影響し、時間とともに変化する値」**が状態である。変化しない定数(constant)は状態ではない。ビルド時に固定されるプリミティブなデザイントークンは状態ではないが、ユーザーが切り替えるダークモードは状態だ。(厳密には、値そのものがダーク/ライトというテーマの状態に応じてresolveされるため、「テーマの選択」が状態であり、トークンはその状態を映す鏡だと捉えるのが正確である)
ここで一つ確認しておきたい。すべての状態がコンポーネント内に存在するわけではない。 Cookieに存在する状態もあれば、localStorage・sessionStorage・IndexedDBに存在する状態も、URLに存在する状態もある。サーバーにあるデータをクライアントへ持ってきてキャッシュすれば、それも一種の状態になる。ブラウザ自身が保持するスクロール位置や履歴スタックも、アプリの振る舞いを決めるという点で、状態として扱うべき場合がある。
なぜそれほど難しいのか
状態を扱うことがなぜ難しいのか、まずは単純に考えてみよう。必要な状態を作り、必要な場所まで渡し、更新と初期化さえ適切に処理すればよいのではないだろうか。
この問いを頭に置いたまま、いま携わっているサービスのページを一つ開いてみよう。
そのページには、いくつのコンポーネントがあるだろうか。単純なページでも少なければ数十、多ければ数百のコンポーネントがツリーを構成しているはずだ。各コンポーネントは独自の状態を持つこともあれば、兄弟コンポーネントと状態を共有することも、親から状態を受け取ることもある。ページ間で状態が遷移することもあり、再読み込み後も残るべき状態も、タブを閉じたら消えるべき状態もある。
状態の管理が難しい本当の理由は、ここにある。無数の状態がどこで宣言され、どのように更新され、いつ消滅するのかを、私たちは一目では把握できない。 似た役割を持つコンポーネントが増えるほど、状態の命名も、状態を変更するコードの追跡も難しくなる。
こうして、目に見えない蜘蛛の巣ができあがる。AコンポーネントでのクリックがBのデータを無効化し、Bの無効化によってCのUIが閉じ、Cが閉じるとフォームの入力が失われる。この連鎖がコードのどこにも明示されていなければ、バグをデバッグするたびに、私たちは頭の中で蜘蛛の巣を描き直さなければならない。
では、この蜘蛛の巣をどう整理すればよいのだろうか。筆者が最初の一歩だと考えるのは、**「状態には種類がある」**と認識することである。
すべての状態が同じではない
Kent C. Doddsは、状態をServer Cache(サーバーに存在する情報を、クライアントが素早くアクセスするために保持するもの)とUI State(インターフェースの動作を制御するため、UIにのみ存在するもの)に分けている。私たちは、この二つを一緒に扱うときにしばしば間違いを犯す。
TanStack Query公式ドキュメントでは、TanStack Queryをサーバーとクライアント間の非同期処理を管理するServer Stateライブラリ、Redux、MobX、ZustandなどをClient Stateライブラリと位置づけている。(非同期データを保存することはできるが、それは非効率である)
要点は明確だ。Server StateとClient Stateは異なる問題である。 Server Stateは非同期で、他のユーザーによって変更される可能性があり、時間が経つとstaleになる。Client Stateは同期的で、私たちが制御でき、再読み込みすれば消える。(正確には、ページがunloadされるとJavaScriptランタイムが再起動し、heapメモリ上にあったコンポーネントツリーとその内部の状態がまとめて回収される。 そのため、再びマウントされるとuseStateの初期値からやり直す)この二つを同じツールで扱おうとすると、キャッシュの無効化、バックグラウンド更新、楽観的更新といったパターンをすべて自分で実装しなければならない。
筆者はさらに一歩進めて、フロントエンドの状態を7つの分類に分けて捉えている。あらかじめ断っておくと、この7つは一つの軸できれいに分割できるものではない。保存場所・出所・ライフサイクル・役割が混在しているため、一つの状態が複数の分類に同時に属することもある。完全な分類表ではなく、状態をどう管理するか決める際に投げかける問いだと考えてほしい。
- ローカル状態(Local State) — 一つのコンポーネント、または狭いツリー内だけで使う状態
- グローバル状態(Global State) — アプリ全体で共有する必要がある状態
- サーバー状態(Server State) — サーバーがSingle Source of Truthで、クライアント側はキャッシュである状態
- フォーム状態(Form State) — ユーザーの入力中だけ一時的に存在する状態
- URL状態(URL State) — アドレスバーにあり、共有でき、再読み込み後も残る状態
- 外部状態(External State) — Cookie、localStorage、sessionStorage、IndexedDBなど、Reactの外部にある状態
- 状態ガード(State Guard) — 状態そのものではなく、状態の組み合わせに応じてアクセスや実行を制御・検証するロジック
この分類以外にも、状態マシンで精緻化すべきワークフロー状態や、WebSocket・CRDTを基盤とするリアルタイム共同編集状態がある。
それぞれに異なるツールが必要な理由と、どのような視点で向き合うべきかを、一つずつ見ていこう。
ローカル状態(Local State)
最も単純な状態である。一つのコンポーネント内でのみ使われ、外部が知る必要も、知る権利もない状態だ。モーダルの開閉、トグルボタンのon/off、ホバー状態、入力途中の検索語などが該当する。
function SearchBox() {
const [query, setQuery] = useState("");
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}この程度なら馴染みがあるだろう。しかし、ローカル状態で本当に難しいのは、**「この状態をどこに置くべきか」**という配置の判断である。
Kent C. DoddsのState Colocationの記事では、**人々は状態を「引き上げる(lift up)」ことには慣れているが、コードが変わったときに状態を再び「近くに置く(colocate)」ことはあまりしない。**と述べている。
状態を引き上げるのは、兄弟コンポーネントが同じ状態を共有する必要があるときに、私たちが自然に取る行動だ。二つの兄弟が同じデータを見る必要があるため、共通の親へ状態を移し、propsで渡す。
問題は、その状態が兄弟に必要なくなったときである。私たちは状態を再び子の側へ引き下げることを、あまりしない。その結果、親コンポーネントには本来関係のない状態が大量に蓄積し、親が再レンダーされるたびに、子のツリー全体まで再レンダーされることになる。
したがって、ローカル状態に関する第一の設計眼は、高速かつシンプルにするため、状態はそれを使うコードのできるだけ近くに置くことである。あるコンポーネントの子の一つでしか使わない状態なら、親が持つ理由はない。その子の内部へ移そう。その分、親は軽くなる。
グローバル状態(Global State)
グローバル状態とは、アプリのどこからでもアクセスできる必要がある状態である。ログイン情報、テーマ、言語、通知(トースト)などが候補になる。
ローカル状態とグローバル状態の違いは、単に「どこに存在するか」ではない。参照に関する契約が異なる。ローカル状態は**「このコンポーネントの内部でのみ意味を持つ」という契約を、グローバル状態は「アプリのどこからでも、この名前でこの値を参照できる」**という契約をコード全体に公開する。その契約のコストが高いことこそ、グローバル状態の本質である。
グローバル状態を一つ作ることは、実のところ、アプリ全体にまたがる暗黙的な依存関係を一つ追加することだ。
サーバー状態(Server State)
APIから受け取ったデータをClient Stateに入れ、ローディングとエラーをbooleanで直接管理し続けた末に、**「なぜ毎回、同じボイラープレートを書いているのだろう?」**という疑問へ行き着く。
TanStackのメインメンテナーであるTanner Linsleyは、「Client Stateは同期的で予測可能だ。Server Stateは非同期で、複数のコンポーネントにまたがって共有され、キャッシュ・バックグラウンド更新・エラー状態を慎重に扱う必要がある」と述べている。つまり、Server StateはClient Stateとは本質的に別種のものである。同じツールで扱うべきではない。
Server Stateが難しいのは、ツールの問題ではなく、データの本質によるものだ。
クライアントが見ているデータは、サーバーのものだ。クライアントが持つデータは、ある時点のスナップショットにすぎない。このデータは時間の経過とともにstaleになる。また、非同期で失敗する可能性があり、pending、error、successなどの状態を持つ。
最も重要な本質は、レスポンスがリクエストを送った順番どおりに返る保証はないという点だ。検索欄へ「react」と素早く入力するとしよう。r → re → rea → reac → reactの順にリクエストは送られるが、「react」のレスポンスが先に到着し、その後で「rea」のレスポンスが届けば、画面には「rea」の結果が表示されてしまう。この問題を防ぐには、AbortControllerやリクエストIDの追跡を毎回手作業で実装する必要があり、**並行処理に伴う危険(race conditions)**を考慮しなければならない。
フォーム状態(Form State)
フォームは少し特殊な状態である。ユーザーが入力している間は激しく変化するが、送信が終われば通常は消える。他の場所と共有されることも、保存先があることも(ほとんどの場合)ない。
問題は、この「激しい変化」のコストが高いことだ。キー入力のたびにReactの再レンダーが起きれば、大きなフォームでは入力遅延が目立つようになる。そしてフォームは、単に「値を保持する」だけのものではない。バリデーション、dirty check、送信状態、エラーメッセージ、複数ステップのフローまで、一つのフォーム内で複数種類の状態が同時に動き続ける。
3段階の決済フローのような複数ステップのフォームには、「途中で再読み込みしても進行状態が残ること」が期待される。このときフォームの値をuseStateだけで保持していれば、再読み込みですべて失われる。sessionStorage(タブ単位の一時保存)やURL(共有可能なステップ)に保存するのが自然だ。つまりフォーム状態は、ライフサイクル上の要件に応じて、External StateやURL Stateと組み合わせることになる。
URL状態(URL State)
検索ページで、カテゴリー・並び順・ページ番号を使って絞り込んでいるとしよう。これらの状態をuseStateで保持すると、三つの問題が同時に生じる。
- 再読み込みすると、すべてのフィルターが初期化される
- URLを友人に共有しても、相手にはフィルターが適用されていないページが表示される
- 戻るボタンを押しても、以前のフィルターへ戻らない
こうした問題を解決するには、状態をURLに入れるのが自然だ。 URLはそれ自体が、再読み込み・共有・履歴に対応した、無料で使える永続ストレージである。
/products?category=shoes&sort=price-desc&page=2
この一行のURLには、**「靴カテゴリーを価格の降順で並べた2ページ目」**という完全な状態が含まれている。useStateで別途保持する必要はない。
では、どのような状態をURLで扱うのが適切だろうか。URLは公開インターフェースである。 パスワード、認証トークン、ユーザーが他人に見せたくない一時的なメモなどをURLへ入れてはいけない。また、頻繁に変わる値(入力のたびに更新される検索語)をそのままURLへ反映すると、履歴スタックがゴミで埋まってしまう。この場合はdebounceしてから反映するか、pushではなくreplaceを使い、履歴を汚さないようにする必要がある。
URLの値は常に文字列である。数値、boolean、配列、オブジェクトには、シリアライズとデシリアライズの工程が必要だ。さらにURLは、パーセントエンコーディング(percent-encoding)の規則に従う必要があるため、&、=、韓国語、スペースなどは特別に処理される。これを毎回手作業で実装すれば、やがてバグの温床になる。
const params = new URLSearchParams(location.search);
const page = Number(params.get("page") ?? "1");
params.set("page", String(page + 1));
navigate(`?${params.toString()}`);
const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1));nuqsのようなライブラリは、*パーサー(parser)*という概念で、この二つの問題を解決する。parseAsInteger、parseAsBoolean、parseAsJsonといったパーサーが、シリアライズ・デシリアライズ・型をまとめて担う。Next.js(App/Pages Routerの両方)、React Router v6/v7、TanStack Router、Remixなど、ほとんどの環境に対応している。
では、URLへ状態をいくらでも詰め込んでよいのだろうか。シリアライズや型の問題とは別に、最後に考慮すべき制約が一つ残っている。RFC 7230は正確な上限を定めてはいないものの、「サーバーは最低でも8,000オクテット(ネットワークやデータ通信において、8 Bitからなる1 Byteを明確に指す際に使う単位)をサポートすべき」と推奨している。ブラウザごとの上限もさまざまで、モダンブラウザではおおむね8KBから数万文字まで許容されるが、検索エンジンやソーシャルメディアのOG/共有処理、一部のゲートウェイでは2KB前後で切り捨てられることもある。 したがって、URLへ無制限に詰め込むのは避けよう。共有可能な主要フィルターだけを置き、残りはsessionStorageやサーバー側の保存に任せるのが安全だ。
外部状態(External State)
Reactが認識できるのは、自身の内部にある状態だけだ。しかし、私たちのアプリはReactの外の世界とも絶えずやり取りしている。そこに存在する状態はReactのライフサイクルと無関係に残り、変化する。ここでいうExternal Stateには、Cookie、localStorage、sessionStorage、IndexedDBがある。
ストレージはどのように選ぶのが適切だろうか。筆者は通常、寿命・容量・同期性・セキュリティという四つの観点から考える。
認証トークンに関するOWASPの推奨では、HttpOnly + Secure Cookieが第一候補となる。localStorageはJavaScriptからアクセスできるため、XSSにさらされた瞬間、トークンをそのまま盗まれる。 セキュリティガイドの中には、アクセストークンはメモリに、リフレッシュトークンはHttpOnly Cookieに置くハイブリッドパターンを推奨するものもある。永続的で、機密性がなく、頻繁に変わらないデータにはlocalStorageを、タブとともに消えるデータにはsessionStorageを活用する。オフラインキャッシュ・大容量データ・ファイルを扱う際には、一般にIndexedDBを利用する。
CookieとWeb Storage(local/session)は、文字列しか保存できない。 そのため、オブジェクトを入れるにはJSON.stringify/JSON.parseを経由する必要がある。しかし、JSONには限界がある。
JSON.stringify({ when: new Date() });
// → { "when": "2026-05-19T..." } — Date becomes a string
JSON.stringify({ map: new Map([["a", 1]]) });
// → { "map": {} } — Map is lost entirely
JSON.stringify({ value: undefined });
// → "{}" — the undefined field is omittedDateはJSONとの往復で文字列になり、Map、Set、undefinedではデータが失われることがある。BigIntはデフォルトではJSON.stringifyがTypeErrorをスローし、シリアライズ自体が失敗する。外部ストレージへオブジェクトを入れるときは、どの型が失われたり変化したり、シリアライズを失敗させたりするかを常に意識し、必要に応じてシリアライズ用のアダプターを用意しなければならない。
External Stateの本当の難しさは、Reactがその変化を自動では検知できない点にある。localStorageへ値を書き込んでも、Reactコンポーネントは再レンダーされない。これを解決するパターンは、主に三つある。
- カスタムフック(useLocalStorage)で一段ラップし、External StateをReact stateと同期する。 軽量だが、自分で実装するなら、複数タブ・SSR・tearingといったエッジケースをすべて扱わなければならない。
- React 18で導入された
useSyncExternalStoreフックを使い、「React外部の状態と同期できる」。 これにより、並行レンダリングでtearingが発生しないことを保証できる。 localStorage・ブラウザAPI・外部ストアをつなぐ標準的なツールである。 - Zustandの
persistミドルウェアやJotaiのatomWithStorageのように、状態管理ライブラリは外部ストレージとの連携を第一級の機能として提供しているため、既存のライブラリを活用できる。
ここでもう一つ、設計上の視点を加えよう。External StateをReactへ取り込んだ瞬間、同期の責任は私たちに移る。 別のタブで更新されたら? サーバーがCookieを変更したら? ユーザーがブラウザの開発者ツールからlocalStorageを直接編集したら? こうした事象が、最も大きなバグの温床になることも多い。
状態ガード(State Guard)
最後の分類は、少し性質が異なる。状態そのものではなく、状態の組み合わせによって、何らかのフローを止めたり許可したりするロジックである。
最も一般的な例は、**認証ガード(Auth Guard)**だ。
function ProtectedRoute({ children }) {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) return <Spinner />;
if (!isAuthenticated) return <Navigate to="/login" replace />;
return children;
}ここでは、isAuthenticatedという状態がルーティングのフローを制御している。これがガードロジックである。認証ガード(認証済みか)、権限ガード(特定のロールや権限を持つか)、フローガード(進入先の分岐)、検証ガード(ステップを有効化できるか)など、さまざまな種類のガードがある。
ガードロジックは、一か所に集中しやすい。一つのコンポーネントに、**「未ログインならログインページ、権限がなければ403、カートが空なら商品ページ、ユーザーが停止中なら停止案内」**がすべて入ってしまうことも珍しくない。ガードが肥大化するほど、どの条件によって、どこで止められたのかをデバッグしにくくなる。
優れたガードは、一つのことだけを検査する。 組み合わせにはCompositionを使う。
<AuthGuard>
<RoleGuard role="admin">
<FlowGuard require={["cartHasItems"]}>
<CheckoutPage />
</FlowGuard>
</RoleGuard>
</AuthGuard>各ガードは一つの判断だけを行い、合成はツリー構造が担う。新しいガードを追加するときに、既存のガードへ手を加える必要はない。
ガードを扱う際、止めること以上に考えるべきなのは、どこへ遷移させるか/その後どう扱うかを決めることだ。止めるだけでフォールバックのないガードは、真っ白な画面や無限スピナーに行き着く。
最もよくあるバグは、**「ガードの非同期チェックが終わる前に、保護対象のコンテンツが一瞬表示される」**ことだ。認証トークンの検証や権限の取得は、ほとんどが非同期であり、その間、isAuthenticatedが一時的にundefinedやfalseになる瞬間がある。ローディング状態を明示的に扱わなければ、その隙に保護対象の画面が露出したり、誤ってログインページへリダイレクトされたりする。
// Ignores loading and handles only missing data => incorrect
if (!user) return <Navigate to="/login" />;
// Treat loading as a first-class state (early return) => correct
if (isLoading) return <Spinner />;
if (!user) return <Navigate to="/login" replace />;
return children;権限ガードを実装するときは、二つのモデルがよく使われる。
- RBAC(Role-Based Access Control):ロール単位で権限を付与する。「adminはすべてのユーザー情報を閲覧できる」といった形だ。シンプルで高速だが、ロールを細分化するほど、その数が爆発的に増える
- ABAC(Attribute-Based Access Control):属性の組み合わせで権限を決める。「ユーザーがその投稿の作成者である、同じチームに所属している、またはadminである場合」といった形だ。表現力は高いが、実装とデバッグが難しい
TanStack RouterのRBACガイドのように、ルーターレベルでbeforeLoadにガードを組み込むパターンが推奨される。重要なのは、権限チェックをコードの各所にばらまかず、データ(ロール/権限の一覧)として表現できるようにすることだ。そうすれば、権限ポリシーの変更はデータの変更だけで済む。
まとめ
整理しよう。状態管理が難しいのは、ライブラリが難しいからではない。状態には種類があるという事実を忘れがちだからであり、種類ごとに異なるツールと考え方が必要だという点を見落としやすいからだ。
ローカル状態はできるだけ近くへ置く。グローバル状態は本当にグローバルであるべきか、もう一度疑う。Server Stateはキャッシュとして扱う。フォームはドメインから分離する。URLはより積極的に活用する。外部ストレージは責任を意識する。ガードは薄く分けて合成する。これが7つの分類を扱うための基本である。
そして、それらすべての土台となる設計眼は、最終的に四つの問いへ集約される。
- このデータのSingle Source of Truthはどこにあるか?
- これは計算で求められる値か、それとも本当に保存すべき値か?
- この状態の組み合わせに、不可能な組み合わせはないか?
- この状態は、本当にこの場所にあるべきか?
新しい画面を実装するとき、PRをレビューするとき、AIが生成したコードを受け取るとき。そのたびに、これらの問いを一度ずつ投げかける。それこそが、設計眼と感覚を養う最も確実な道だと筆者は信じている。
冒頭で述べたとおり、AIはこれからも長く私たちのそばにいるだろう。コードを一行ずつ確認する時間は、ますます減っていく。しかし、だからこそ、**「この状態はどこにあるべきか?」**という小さな問いに答えられる感覚の価値は高まる。AIに「ここへuseStateをもう一つ追加して」と頼むのは簡単だ。だが、その一行がアプリの蜘蛛の巣にどのような糸を加えるのかを理解できるかどうかは、そのコードを読む人の設計眼にかかっている。
正解はない。それでも、**「状態が何かを理解せずに状態を作ること」と、「状態の種類と配置を意識して作ること」**の間には、明確な違いがある。この記事を読んだ方にも、次にuseStateを一行書く前に一度立ち止まり、「これはどの分類の状態だろう?」と問いかけてみてほしい。
参考資料
📚関連記事
Toss Frontend Fundamentals 模擬試験 第2回のリファクタリングを終えて
2026/3/28 · 24 min read
今回の記事では、Toss Frontend Fundamentals 模擬試験の第2回に参加し、取り組んだリファクタリングについて振り返ってみたい。 以前からコードレビューやリファクタリングに関心があった筆者は、Tossが公開した「Frontend Fundamentals 模擬試験」という興味深い形式の課題に取り組むことになった。課題は、与えられた会議室予約アプリをリファクタリングするというもの...
queryKey
2026/1/4 · 43 min read
今回は、TanStack Query の queryKeyについて掘り下げてみたい。 筆者は実務で TanStack Query を使う中で、queryKey の管理方法を何度も作り直してきた。最初はコンポーネント内に ['user', userId] のような配列を直接書いていたが、無効化のたびに同じキーを複数箇所へ記述するうちにタイプミスが増え、QUERYKEYS のような定数オブジェクトへ移...
エラーハンドリング
2025/11/17 · 44 min read
今回は、フロントエンドでエラーをどう捉えるかについて考えてみたい。 筆者は実務でエラーハンドリングを書くたび、どこか釈然としない感覚を抱くことが多かった。あるエラーは try/catch で捉え、別のエラーは ErrorBoundary が捉え、さらに別のエラーは TanStack Query の onError が捉える。それぞれの守備範囲は微妙に重なったり、ずれたりする。その結果、エラーが漏れ...