Reactの状態管理:生成アプリでは地味に保つ
Reactの状態管理をシンプルに:サーバー状態とクライアント状態を分離し、いくつかのルールに従い、複雑化の初期兆候を見抜く。

本番のReactアプリで状態はどこで失敗するのか
状態とは、アプリ実行中に変わりうるデータのことです。表示しているもの(モーダルが開いている)、編集中のもの(フォームの下書き)、取得したデータ(プロジェクト一覧)などが含まれます。問題はこれらがすべて「状態」と呼ばれ、挙動が大きく異なるにも関わらず同じように扱われがちな点です。
ほとんどのひどく混乱したアプリは同じパターンで壊れます:多種類の状態が同じ場所で混ざり合うことです。コンポーネントがサーバーデータ、UIフラグ、フォームの下書き、導出値を一括で持ち、useEffect 等でそれらを同期しようとします。やがて「この値はどこから来ているのか?」「何がそれを更新するのか?」といった簡単な質問に、複数のファイルを探さないと答えられなくなります。
生成されたReactアプリはこの状況に陥りやすいです。最初に動くバージョンをそのまま受け入れがちだからです。新しい画面を追加してパターンをコピーし、バグを別の useEffect でパッチしていたら、いつの間にか真実が二つになっています。ジェネレータやチームが途中で方針を変えると(ここはローカル状態、あそこはグローバルストア)、コードベースはパターンのコレクションになり、一貫性がなくなります。
目標は「地味に保つ」こと:状態の種類を減らし、探す場所を減らすことです。サーバーデータの明確な置き場とUI専用状態の明確な置き場があれば、バグは小さくなり、変更が怖くなくなります。
「地味に保つ」とは、いくつかのルールを守るということです:
- 明確な理由がない限りサーバーデータをクライアント状態にコピーしない。
- 将来役立ちそうだからといってローカルのUI状態をグローバルストアに昇格させない。
- 計算で得られる値は、本当に計算が高コストでない限り別の状態にしない。
具体例:ユーザー一覧がバックエンド由来なら、それはサーバー状態として扱い、使用箇所で取得してください。selectedUserId が詳細パネルを動かすだけなら、そのパネル近辺に小さなUI状態として置きます。これらを混ぜると複雑化の始まりです。
サーバー状態とクライアント状態をやさしく説明すると
ほとんどのReactの状態トラブルは一つの混同から始まります:サーバーデータをUI状態のように扱うこと。早い段階で分けておけば、アプリが大きくなっても状態管理は落ち着きます。
サーバー状態はバックエンドに属するものです:ユーザー、受注、タスク、権限、価格、フィーチャーフラグなど。別のタブで更新されたり、管理者が編集したり、ジョブで更新されたり、データが期限切れになったりと、アプリ側の操作なしに変わり得ます。共有され変更されるデータなので、フェッチ、キャッシュ、再取得、エラーハンドリングが必要です。
クライアント状態は今そのUIだけが気にするものです:どのモーダルが開いているか、どのタブが選択されているか、フィルタのトグル、ソート順、サイドバーの折り畳み、下書きの検索クエリなど。タブを閉じたら失っても問題ないものです。
簡単なテストは:「ページをリロードしてサーバーから再構築できるか?」
- はいなら、おそらくサーバー状態。
- いいえなら、おそらくクライアント状態。
さらに導出状態があります。これは余計な状態を作らないために利用します。他の値から計算できる値であれば保存せずに計算します。フィルタ済みリスト、合計、isFormValid、"空状態を表示するか" などは通常ここに入ります。
例:プロジェクト一覧を取得する(サーバー状態)。選択フィルタと「新規プロジェクト」ダイアログの開閉はクライアント状態。フィルタ後に表示されるリストは導出状態です。表示リストを別に保存すると同期がずれて「なぜ古いまま?」というバグを追いかけることになります。
この分離は Koder.ai のようなツールで画面を素早く生成する場合に助けになります:バックエンドデータは一つのフェッチ層に、UIの選択はコンポーネント近くに、計算した値は保存しない、という原則を守ってください。
ほとんどの問題を防ぐ地味なルールセット
状態が痛みになるのは、あるデータに二人の所有者が出現したときです。単純に保つ最速の方法は、誰が何を持つか決めてそれに従うことです。
- APIデータはフェッチしてキャッシュするものとして扱い、コンポーネント状態で手動で維持しない。
- 「念のため」に取得データをローカル状態にコピーしない。コピーは同期ずれを生みます。
- クライアント状態は使う場所の近くに置く。複数箇所で本当に必要になるまで上に持ち上げない。
- オブジェクト全体ではなくIDや小さなフラグを保存し、レンダリング時にキャッシュからオブジェクトを再派生する。
例:ユーザー一覧を取得して、選択したユーザーの詳細を表示する場合、selectedUser としてオブジェクト全体を保存するのはよくある誤りです。代わりに selectedUserId を保存し、一覧はサーバーキャッシュに置いておき、詳細ビューはIDでユーザーを参照します。こうすれば再取得でUIが勝手に更新され、余計な同期コードが不要になります。
生成されたReactアプリでは、サーバーデータを重複する「便利な」状態が生成コードに含まれやすい点にも注意してください。fetch -> setState -> edit -> refetch のような流れを見つけたら、一旦止まってください。しばしばブラウザ内にもう一つのデータベースを作っている合図です。
サーバー状態を悩まず扱う方法
サーバー状態とはバックエンドにあるもの:一覧、詳細ページ、検索結果、権限、カウントなどです。地味なアプローチは、それ用に一つのツールを選んでそれに従うことです。多くのReactアプリでは TanStack Query で十分なことが多いです。
ゴールは単純です:コンポーネントはデータを要求し、読み込みとエラーを表示し、内部で何回フェッチされようと気にしない。これは生成されたアプリで重要で、小さな不整合が新しい画面の追加で急速に増幅するからです。
クエリキーは命名システムだと考えてください。安定した配列キーを使い、結果を変える入力(フィルタ、ページ、ソート)だけを含め、たくさんのワンオフより予測可能な形をいくつか使う方が良いです。多くのチームはキー生成を小さなヘルパーにまとめ、各画面が同じルールを使うようにしています。
書き込みは明示的な成功処理を持つミューテーションで扱います。ミューテーションは「何が変わったか」と「UIは次に何をするか」の2つの質問に答えるべきです。
例:新しいタスクを作成したとします。成功時はタスクリストのクエリを無効化して再読み込みさせるか、キャッシュをターゲット更新(新しいタスクをキャッシュ済リストに追加)するか、どちらかを選びます。機能ごとに一つの方法を決めて一貫して使ってください。
複数箇所で「念のため」に再取得を追加したくなったら、代わりにひとつの地味な動きを選んでください:
- 古くなった正確なクエリキーを無効化する。
- 変わった正確なクエリに対してキャッシュを更新する。
- 正しいクエリを既に取得する画面へ遷移する。
小さいまま保てるクライアント状態のパターン
クライアント状態はブラウザが所有するものです:サイドバーの開閉フラグ、選択された行、フィルタ文字列、保存前のドラフトなど。使用箇所の近くに置くとたいてい管理しやすくなります。
まずは小さく始めてください:最寄りのコンポーネントで useState を使う。Koder.ai などで画面を生成すると、つい「念のために全部グローバルストアへ」と押し込んでしまいがちですが、それが誰も理解しないストアを生みます。
単純な昇格ルール
状態は共有ニーズを名前で説明できる場合にのみ上へ上げる。
- デフォルトはUI専用状態をローカルにする。
- 兄弟間で必要なら最も近い共通親にリフトする。
- 複数のルートや離れたコンポーネントが同時に必要なら小さな共有ストアを使う。
例:テーブルと詳細パネルがあるなら selectedRowId はテーブルコンポーネント内に置く。ページの別のツールバーもそれを必要とするならページコンポーネントへリフトする。別ルート(バルク編集)でも必要なら小さなストアを検討する。
読みやすいストアの形
ストア(Zustand 等)を使う場合は、ひとつの役割に集中させてください。結果そのもの(ソート済みリスト)ではなく「何を」保存するか(選択ID、フィルタ)を保存する。
ストアが大きくなり始めたら問いかけてください:これはまだ一つの機能か?正直に「たぶん」と答えるなら今のうちに分割しましょう。次の機能で触るのを怖くなる前に分けておくのが得策です。
フォーム、ドラフト、一時的なUI状態
フォームのバグはしばしば「ユーザーが入力しているもの」「サーバーに保存されたもの」「UIが表示しているもの」の三つを混ぜることから生じます。
地味な状態管理のために、フォームは送信するまではクライアント状態のドラフトと扱ってください。サーバーデータは最後に保存されたバージョンです。サーバーオブジェクトをその場で編集しないでください。値をドラフト状態へコピーし、ユーザーが自由に変更できるようにし、保存時に送信して成功したら再取得またはキャッシュ更新を行います。
ユーザーが離脱したときに何を保持するかを早めに決めておくと予期せぬバグを防げます。例えばインライン編集モードや開いているドロップダウンは通常リセットでよく、長いウィザードのドラフトや送信していないメッセージは持続させるべき場合があります。リロードを越えて保持するのはユーザーが明確に期待する場合に限定してください(チェックアウトフォームなど)。
バリデーションルールは一箇所にまとめてください。入力ごと、送信ハンドラ、ヘルパーにルールを分散させると不一致なエラーになります。ひとつのスキーマ(またはひとつの validate() 関数)を好み、UI側でエラー表示のタイミング(change/blur/submit)を決めてください。
例:Koder.ai で Edit Profile 画面を生成する場合。保存されたプロファイルをサーバー状態として読み込み、フォームフィールド用にドラフト状態を作る。ドラフトと保存済みを比較して「未保存の変更」を表示する。ユーザーがキャンセルしたらドラフトを破棄してサーバー版を表示し、保存したらドラフトを送信して保存済みをサーバーレスポンスで置き換える。
混乱した状態設定を段階的に簡素化する
生成されたReactアプリが成長すると、同じデータが3箇所(コンポーネント状態、グローバルストア、キャッシュ)に重複することがよくあります。解決は通常「新しいライブラリ」ではなく、各データの居場所を一つに決めることです。
多くのアプリで有効なクリーンアップ手順:
- 状態の棚卸し。持っているもの(一覧、選択項目、フィルタ、モーダル、ドラフト)を列挙して、それぞれをサーバーかクライアントかラベル付けする。
- 重複を削除する。
filteredUsersのようにusers + filterから計算できるものは削除する。複製されたselectedUserオブジェクトよりselectedUserIdを優先する。 - フェッチ処理は一つの層にまとめる。キャッシュや再取得、無効化のルールを一箇所にする。
- 小さなクライアントストアは、それが本当に価値を生む箇所にだけ追加する(ページを跨ぐUIニーズなど)。
- 命名ルールを固定して、同じ問題が再発しないようにする。
例:Koder.ai で生成されたCRUDアプリは useEffect フェッチとグローバルストアのコピーから始まることが多いです。サーバー状態を集中させると、リストは一つのクエリから来て、"更新" は手動同期ではなく無効化で済むようになります。
命名は一貫して地味に保ってください:
- クエリ:
users.list,users.detail(id) - クライアント状態:
ui.isCreateModalOpen,filters.userSearch - アクション:
openCreateModal(),setUserSearch(value) - サーバー書き込み:
users.create,users.update,users.delete
目標は、各項目につき一つの真実の所在を持ち、サーバー状態とクライアント状態の境界を明確にすることです。
状態の複雑化が始まりそうな早期兆候
状態の問題は小さく始まり、ある日フィールドを変更するとUIの三箇所がその"本当の"値で合意できない、という状況になります。
明確な警告サインはデータの重複です:同じユーザーやカートがコンポーネント、グローバルストア、リクエストキャッシュに存在する。各コピーが別々のタイミングで更新され、等しくするためにますますコードを書かなくてはならなくなります。
もう一つの兆候は同期コードです:状態を行き来させるエフェクト。"クエリデータが変わったらストアを更新"、"ストアが変わったら再取得" のようなパターンは、あるところまでは機能しますが、エッジケースで古い値やループを引き起こします。
いくつかの赤旗:
- 「この値はどこから来るのか」「誰が所有しているか」を一文で説明できない。
- ストアやリデューサーがAPIクライアントやHTTPステータスコードをインポートしている。
- 新しい画面を追加するたびに
needsRefresh,didInit,isSavingのような共有フラグが増える。 - 一つの状態を他の層へ鏡像するためだけにエフェクトを書いている。
- バグを直すために同じフィールドを複数の層で更新する必要がある。
例:Koder.ai でダッシュボードを生成し、Edit Profile モーダルを追加したとき。プロフィールデータがクエリキャッシュにあり、それがグローバルストアにコピーされ、ローカルフォーム状態にも重複していると、三つの真実ができてしまいます。バックグラウンド再取得や楽観的更新を追加すると、食い違いが表面化します。
これらの兆候が見えたら、地味な動きで各データの所有者を一つに決め、鏡像を削除してください。
よくある罠とその回避法
「念のため保存する」は状態を痛めつける最速の方法の一つで、特に生成されたアプリでは顕著です。
APIレスポンスをグローバルストアにコピーするのはよくある罠です。データがサーバー由来なら(一覧、詳細、ユーザープロフィールなど)、デフォルトでクライアントストアにコピーしないでください。サーバーデータの置き場を一つに決め(通常はクエリキャッシュ)、クライアントストアはサーバーが知らないUI専用の値だけに使ってください。
導出値を保存するのも罠です。カウント、フィルタ済みリスト、合計、canSubmit、isEmpty は通常入力から計算してください。パフォーマンスが実測で問題になるならメモ化や別手段を検討し、最初から結果を保存しないでください。
すべてを詰め込んだ一つの巨大ストア(認証、モーダル、トースト、フィルタ、ドラフト、オンボーディングフラグ)はゴミ箱になりがちです。機能ごとに分割してください。画面ごとにしか使わない状態はローカルに置きましょう。
Context は安定した値(テーマ、現在のユーザーID、ロケール)には優秀ですが、頻繁に変わる値には広範な再レンダリングを招くことがあります。接続やワイヤリングには Context を使い、頻繁に変わるUI値はコンポーネント状態や小さなストアで扱ってください。
最後に、命名の一貫性を保ってください。近似したクエリキーやストアフィールドは微妙な重複を生みます。シンプルな基準を決めて従ってください。
これ以上状態を増やす前のクイックチェックリスト
「もう一つだけ状態を追加しよう」と思ったら所有権チェックをしてください。
まず、サーバー取得とキャッシュが行われる場所が一箇所にあるか?同じデータが複数のコンポーネントで取得され、かつストアにコピーされているなら利子(負債)を払っています。
次に、この値は一つの画面内だけで必要か?(例:「フィルタパネルが開いているか」)。そうならグローバルにすべきではありません。
三つ目、オブジェクトを複製する代わりにIDを保存できるか?selectedUserId を保存し、キャッシュや一覧からユーザーを読むようにしましょう。
四つ目、導出値か?既存の状態から計算できるなら保存しないでください。
最後にワンミニッツトレーステスト。チームメンバーが「この値はどこから来るのか?」(prop、ローカル状態、サーバーキャッシュ、URL、ストア)を1分以内に答えられないなら、もっと所有権を整備してから状態を増やしてください。
例:成長しても地味に保てるCRUDアプリ
生成された管理アプリ(例:Koder.ai のプロンプトで作られたもの)を想像してください。画面は顧客一覧、顧客詳細、編集フォームの3つです。
状態が落ち着いているのは、それぞれの居場所が明確だからです:
- サーバー状態:顧客一覧、顧客レコード、保存/削除リクエスト。
- クライアント状態:リストフィルタ、選択行、編集ドラフト。
一覧と詳細ページはクエリキャッシュからサーバー状態を読みます。保存時に顧客をグローバルストアに再保存したりはしません。ミューテーションを送ってキャッシュを再取得または更新します。
編集画面ではフォームのドラフトをローカルに保ちます。取得した顧客から初期化しますが、ユーザーが入力を始めたら別物として扱います。こうすれば、詳細ビューが再取得されても途中の編集が上書きされることがありません。
地味な楽観更新
楽観UIはチームがすべてを複製しがちな場所です。ほとんどの場合そこまでしなくてよいです。
保存時に行うことは、該当するキャッシュ内の顧客レコードと該当する一覧アイテムだけを更新し、リクエストが失敗したらロールバックすることです。フォームのドラフトは保存が成功するまで保持し、失敗したらエラーを表示してユーザーが再試行できるようにします。
別機能が同じ状態を欲しがったら
例えばバルク編集を追加して選択行が必要になったとします。新しいストアを作る前に考えてください:この状態はナビゲーションやリロードを跨いで保持すべきか?
- はいなら URL に(選択ID、フィルタを)入れる。
- いいえならページコンポーネント内に置く。
- 複数の離れたコンポーネントが同時に本当に必要なら、そのクライアント状態専用の小さな共有ストアを導入する。
次の一手:生成アプリでルールを一貫させる
生成された画面は急速に増えます。それ自体は良いことですが、新しい画面ごとに状態の扱いがバラバラだと問題が膨らみます。
リポジトリ内に短いチームノートを書いてください:何がサーバー状態で何がクライアント状態か、どのツールがそれぞれを所有するか。短くして実際に守られるようにしましょう。
小さなPR習慣を追加するのも有効です:新しい状態を追加するときにサーバーかクライアントかラベルを付ける。サーバー状態なら「どこで読み込み、どうキャッシュし、何が無効化するか?」を確認する。クライアント状態なら「誰が所有し、いつリセットするか?」を問う。
Koder.ai(koder.ai)を使っているなら、Planning Mode で生成前に状態の境界を合意し、スナップショットとロールバックで状態変更がまずければ戻せるようにしておくと安全です。
一つの機能(例えばプロフィール編集)を選んでルールを端から端まで適用し、それを全員が真似する模範にしてください。
よくある質問
What’s the simplest way to untangle messy React state?
まずは全ての状態を サーバー、クライアント(UI)、導出(derived) のどれかにラベリングしてください。
- サーバー: users、tasks、permissions のような取得されたデータ。
- クライアント: 「モーダルが開いている」や選択されたタブ、フィルタ文字列のようなUIの選択肢。
- 導出: 計算で得られる値(フィルタ済みリスト、合計、
isValidなど)。
ラベル付けしたら、それぞれに 一つの明確な所有先(クエリキャッシュ、ローカルコンポーネント状態、URL、または小さなストア)を割り当ててください。
How do I tell if something is server state or client state?
この簡単なテストを使ってください:「ページをリロードしてこれをサーバーから再構築できるか?」
- はい なら、それは サーバー状態(fetch/cache/refetch)として扱います。
- いいえ なら、それは クライアント状態(ローカルUI状態、場合によってはストア)です。
例:プロジェクト一覧はサーバー状態、選択中の行IDはクライアント状態です。
Why is copying API data into local or global state such a big problem?
なぜ問題になるかというと、真実が二つできるからです。
users を取得してから useState やグローバルストアにコピーすると、次のような同期の必要が生まれます:
- バックグラウンドでの再取得
- 他のタブや他ユーザーからの更新
- ミューテーション後の部分的な更新
デフォルトのルール:サーバーデータは一箇所(通常はクエリキャッシュ)から読み、ローカル状態はUI専用やドラフトにのみ使う。
When is it okay to store derived values instead of computing them?
計算コストが本当に安くない場合に限って、導出値を保存してください。
通常は既存の入力から計算します:
visibleUsers = users.filter(...)total = items.reduce(...)canSubmit = isValid && !isSaving
パフォーマンスが問題化したら useMemo やデータ構造の改善を検討し、まずは結果を保存するのは避けてください。
What’s a “boring” way to handle server state in React?
デフォルトはサーバー状態用のツール(多くの場合 TanStack Query)を使い、コンポーネントはただ「データを要求して」読み込み・エラー状態を扱うだけにします。
実践的な基本:
- 安定した、一貫したクエリキーを使う(結果を変える入力のみを含める)。
- 書き込みはミューテーションで扱い、機能ごとに一つの戦略を選ぶ:
- 対象のクエリを無効化して再取得させる、または
- キャッシュをターゲット更新する。
あちこちに refetch() を散らばらせて「念のため」にするのは避けてください。
When should I move UI state into a global store?
実際の共有ニーズを名前で説明できるときだけ上位へ上げてください。
昇格ルール:
- デフォルトはローカルコンポーネント状態。
- 兄弟コンポーネント間で共有が必要なら、最も近い共通の親へリフトする。
- 複数のルートや離れたコンポーネントが同時に必要なら、小さな共有ストアを使う。
こうするとグローバルストアが気軽なゴミ箱になるのを防げます。
Should I store the selected object or just its ID?
小さなIDとフラグを保存し、サーバーオブジェクト全体を保存しないでください。
例:
- ✅
selectedUserId - ❌
selectedUser(コピーしたオブジェクト)
詳細表示はキャッシュや一覧からIDでユーザーを参照してレンダリングします。これによりバックグラウンドの再取得や更新が余計な同期コードなしに正しく動きます。
What’s the cleanest way to manage forms and drafts?
フォームは送信するまでは ドラフト(クライアント状態) として扱ってください。
実用的なパターン:
- 保存済みのレコードをサーバー状態として取得する。
- そこからローカルのドラフトを初期化する。
- ユーザーが自由に編集できるようにする。
- 保存時にドラフトを送信し、成功したらキャッシュを更新/再取得する。
- キャンセル時はドラフトを破棄してサーバー版を表示する。
こうすることでサーバーのデータを「直接編集して」再取得と戦うことを避けられます。
What are early warning signs that state complexity is about to explode?
よくある警告サイン:
- 同じデータがコンポーネント、グローバルストア、クエリキャッシュに存在する。
- 状態を行き来させるエフェクト("クエリが変わったらストアを更新"、"ストアが変わったら再取得")。
needsRefresh、didInit、isSavingのような共有フラグが増え続ける。- 「この値はどこから来ている?」を一文で説明できない。
多くの場合の解決はライブラリではなく、鏡像を削除して各値に一つの所有者を決めることです。
How do I keep state “boring” when using a generator like Koder.ai?
生成された画面は混在パターンに陥りやすいです。シンプルな防護策は所有権の標準化です:
- サーバー状態: 常に同じ取得/キャッシュ層経由で扱う。
- クライアントUI状態: デフォルトでローカル。必要なときだけストアへ。
- 導出値: 計算して保存しない。
もし Koder.ai を使っているなら、Planning Mode で生成前に所有権を決め、スナップショットやロールバックで実験を安全に行ってください。