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

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