管理パネル向け Vue 3 状態管理: Pinia とローカルの使い分け
Vue 3 管理パネルの状態管理: フィルター、ドラフト、タブなど実例を使って、Pinia、provide/inject、ローカル状態の使い分けを解説します。

管理パネルで状態が難しくなる理由
管理パネルは一画面に多くの要素を詰め込むため、状態が重たく感じられます。テーブルは単なるデータではありません。ソート、フィルター、ページネーション、選択された行、そしてユーザーが頼りにする「今何が起きた?」という文脈が含まれます。長いフォーム、ロールベースの権限、UIで許可される操作が変わるアクションを考えると、小さな状態の決定が重要になります。
問題は値を保存すること自体ではありません。複数のコンポーネントが同じ真実を必要とするときに振る舞いを予測可能に保つことが難しいのです。フィルターチップが「有効」と表示されているなら、テーブル、URL、エクスポート操作が一致しているべきです。ユーザーがレコードを編集して別の画面へ移動しても、アプリがこっそり作業を失ってはいけません。複数タブを開いたら、一方のタブがもう一方を上書きしてはいけません。
Vue 3 では、通常次の3つの置き場所から選ぶことになります。
- ローカルコンポーネント状態: ひとつのコンポーネントが所有し、アンマウント時にリセットして構わない。
- provide/inject: ページや機能領域にスコープされた共有状態。props を介さずに渡せる。
- Pinia: ナビゲーションを跨いで残す、ルート間で再利用する、デバッグが簡単であるべき共有状態。
有用な考え方は、各状態をどこに置けば正しく保たれ、ユーザーを驚かせず、スパゲッティ状態にならないかを決めることです。
以下の例は、フィルターとテーブル(何を残すべきかリセットすべきか)、ドラフトと未保存編集(信頼できるフォーム)、マルチタブ編集(状態衝突の回避)という管理画面でよくある3つの問題に沿っています。
道具を選ぶ前に状態を分類する簡単な方法
ツールの議論を始める前に、まず自分が持っている状態の種類を名前で呼ぶと楽になります。状態の種類ごとに振る舞いが違い、それらを混ぜると奇妙なバグが生まれます。
実用的な分類:
- UI 状態: トグル、開いたダイアログ、選択行、アクティブタブ、ソート順。
- サーバー状態: API 応答、読み込みフラグ、エラー、最終更新時刻。
- フォーム状態: フィールド値、検証エラー、dirty フラグ、未保存ドラフト。
- クロススクリーン状態: 複数のルートが読み書きする必要のあるもの(現在のワークスペース、共有権限など)。
次に スコープ を定義します。将来どこで使うかではなく、今日どこで使われているかを問います。それが1つのテーブルコンポーネント内だけで重要なら、ローカル状態で十分なことが多いです。2つの兄弟コンポーネントが同じページで必要とするなら、本質的にはページレベルでの共有が問題です。複数のルートが必要なら、共有アプリ状態の領域に入ります。
次に ライフタイム。ある状態はドロワーを閉じたらリセットすべきです。別の状態はナビゲーションを跨いで残るべきです(レコードを開いて戻ったときのフィルターなど)。さらに別はリロードしても残るべきです(後で戻ってくる長いドラフトなど)。この3つを同じ扱いにすると、フィルターが謎にリセットされたり、ドラフトが消えたりします。
最後に 並行性 をチェックします。管理パネルはすぐにエッジケースに当たります: 同じレコードを2つのタブで開く、バックグラウンドの更新がフォームを汚しているときに保存しようとする、あるいは2人の編集者が競合して保存するなど。
例: フィルター、テーブル、編集ドロワーを持つ「ユーザー」画面。フィルターはページライフタイムの UI 状態、行はサーバー状態、ドロワーフィールドはフォーム状態です。同じユーザーが2つのタブで編集されるなら、ブロック、マージ、警告のいずれかを明示的に決める必要があります。
状態を種類、スコープ、ライフタイム、並行性でラベル付けできれば、道具の選択(ローカル、provide/inject、Pinia)は自然に明確になります。
選ぶためのプロセス
良い状態の選択はひとつの習慣から始まります: ツールを選ぶ前に状態を平易な言葉で説明すること。管理パネルはテーブル、フィルター、大きなフォーム、レコード間のナビゲーションを混ぜるので、たとえ「小さな」状態でもバグの原因になり得ます。
5ステップの意思決定プロセス
-
誰がその状態を必要としているか?
- 1コンポーネントのみ: ローカルに保つ。
- 1ページ内の複数コンポーネント:
provide/injectを検討。 - 複数ルート: Pinia を検討。
フィルターは良い例です。フィルターがそれを所有するテーブルだけに影響するならローカルで十分です。ヘッダーコンポーネントにありテーブルを駆動するなら、ページスコープの共有(多くは
provide/inject)が適しています。 -
どれくらいの期間持たせる必要があるか?
- コンポーネントのアンマウントで消えて良いならローカルが理想。
- ルート変更を跨いで残す必要があるなら Pinia が適していることが多い。
- リロードを跨いで残す必要があるなら、どこに置くにせよ永続化(ストレージ)が必要。
これはドラフトで最も重要です。未保存の編集は信頼に関わるので、ユーザーは離れて戻ってきてもドラフトが残っていることを期待します。
-
ブラウザのタブ間で共有するべきか、それともタブごとに分離するべきか?
マルチタブ編集はバグの温床です。各タブに固有のドラフトが必要なら、単一のグローバルシングルトンは避けます。レコード ID でキーを付けるか、ページスコープにしてタブ間の上書きを防ぎます。
-
要件を満たす最もシンプルな選択をする。
まずはローカルで始めましょう。本当に痛みを感じたとき(prop の穴、ロジックの重複、再現しにくいリセット)だけ上位へ移動します。
-
デバッグの必要性を確認する。
変更を明確に検査したいなら Pinia の集中したアクションと状態検査が役立ちます。短命で明白な状態ならローカルの方が読みやすいことが多いです。
ローカルコンポーネント状態: これで十分なとき
ローカル状態は、そのデータが1つのページの1つのコンポーネントだけに関係するときのデフォルトです。これを飛ばしてすぐにストアを作ると、何ヶ月もメンテナンスする羽目になります。
明確に合うケースは、自分自身のフィルターを持つ単一のテーブルです。フィルターが1つのテーブルにしか影響しない(例えば Users リスト)なら、テーブルコンポーネント内の ref 値として保持しましょう。同様に「モーダルが開いているか」「どの行が編集中か」「今どのアイテムが選択されているか」といった小さな UI 状態もローカルで良いです。
計算できるものは保存しないようにしましょう。「アクティブなフィルター(3)」のバッジは現在のフィルター値から計算すべきです。ソートラベル、フォーマット済みサマリー、「保存可能」フラグも computed によって自動で同期させる方が安全です。
リセットルールはツール選びより重要なことがあります。ルート変更で何をクリアするか(通常はすべて)と、同じページ内でビューを切り替えたときに何を残すか(フィルターは残して一時的な選択はクリアするなど)を決めておきましょう。
ローカルコンポーネント状態が通常十分なのは次のような場合です:
- 状態が単一ウィジェットに影響する(1つのフォーム、1つのテーブル、1つのモーダル)。
- 他の画面がそれを読み書きしない。
- 1〜2コンポーネント内で保持でき、複数階層に props を渡す必要がない。
- リセット動作を一文で説明できる。
主な制限は深さです。ネストされた複数コンポーネントに同じ状態を渡し始めると、ローカル状態は props ドリリングに変わり、provide/inject やストアに移す合図になります。
provide/inject: ページや機能領域内の共有
provide/inject はローカル状態と完全なストアの中間に位置します。親が値を提供し、その下のすべてが prop の穴を掘らずに注入できます。管理パネルでは、状態が1つの画面や機能領域に属する場合に最適です。
一般的なパターンは、状態を所有するページシェルがあり、小さなコンポーネントがそれを消費する形です: フィルターバー、テーブル、一括操作ツールバー、詳細ドロワー、「未保存の変更」バナーなど。シェルは filters オブジェクト、draftStatus(dirty、saving、error) のような小さな反応的表面、権限に基づく読み取り専用フラグ(例: isReadOnly)を提供できます。
提供するもの(小さく保つ)
すべてを提供すると、構造の乏しいストアを再現してしまいます。複数の子が本当に必要とするものだけを提供してください。フィルターは古典的な例です: テーブル、チップ、エクスポート、ページネーションが同期する必要があるなら、1つの真実のソースを共有する方が props とイベントを使い分けるより楽です。
明快さと落とし穴
最大のリスクは隠れた依存関係です: 子が「ただ動く」のは上位が何かを提供しているからで、後でどこから更新が来ているか分かりにくくなります。
読みやすくテストしやすくするために、inject には明確な名前(定数や Symbol)を使い、単にミュータブルなオブジェクトだけでなくアクションを提供することを好みます。setFilter、markDirty、resetDraft のような小さな API は所有権と許可される変更を明示します。
Pinia: 画面を跨いだ共有と予測可能な更新
Pinia は同じ状態をルート間や複数コンポーネントで一貫させたいときに光ります。管理パネルでは、現在のユーザー、操作可能な権限、選択中の組織/ワークスペース、アプリ全体の設定といった本当に共有すべき懸念が該当します。各画面がそれを再実装すると大変です。
ストアがあると、共有状態を読み書きする場所が一箇所になります。複数の階層に props を渡す代わりに、必要な場所でストアをインポートします。リストから詳細ページに移動しても、UI の残りは同じ選択された組織や権限に反応し続けます。
なぜ Pinia が保守しやすく感じるか
Pinia はシンプルな構造を推します: 生の値を state に、導出値を getters に、更新を actions に置く。管理 UI ではその構造が「その場しのぎ」の修正が散らばるのを防ぎます。
例えば canEditUsers が現在のロールと機能フラグに依存するなら、そのルールは getter に置きます。組織を切り替えるとキャッシュ選択をクリアしナビゲーションを再読み込みする必要があるなら、その一連の処理は action に入れます。結果として不審なウォッチャーや「なぜこれが変わった?」の瞬間が減ります。
Pinia は Vue DevTools とも相性が良いです。バグが起きたときに、どのアクションが走ったのか、ストアの状態を調べれば原因追跡がしやすくなります。
捨て場ストアを避ける
最初はまとまって見えても、グローバルストアはやがて雑多な物置になります。Pinia に向くのは、本当に共有すべき懸念: ユーザー識別、権限、選択されたワークスペース、機能フラグ、複数画面で使う参照データなどです。
ページ専用の懸念(あるフォームの一時的な入力など)は、複数ルートが本当に必要としない限りローカルに置いてください。
例1: ストアに全部入れずにフィルターとテーブルを扱う
Orders ページを想像してください: テーブル、フィルター(ステータス、日付範囲、顧客)、ページネーション、選択した注文をプレビューするサイドパネル。すべてのフィルターやテーブル設定をグローバルストアに入れたくなりますが、これではすぐに混乱します。
選び方のシンプルな方法は、何を記憶すべきか、どこに置くべきかを決めることです:
- メモリだけ(ローカルまたは provide/inject): ページを離れるとリセット。使い捨ての状態に最適。
- クエリパラメータ: 共有可能でリロードに耐える。フィルターやページネーションに向く。
- Pinia: ナビゲーションを跨いで残る。「リストを出たときのまま戻る」体験に向く。
実装は通常次の流れになります:
誰も設定がナビゲーションを跨いで残ることを期待していないなら、filters、sort、page、pageSize は Orders ページコンポーネント内に置き、そのページがフェッチをトリガーします。ツールバー、テーブル、プレビューパネルが同じモデルを必要とし、prop の受け渡しが煩雑ならリストモデルをページシェルに移し provide/inject で共有します。ルート間でフィルターや選択を残したいなら Pinia が良い選択です。
実用的なルール: まずローカルで始め、複数の子コンポーネントが同じモデルを必要とするなら provide/inject に移し、真に跨ルートの永続性が必要になったら Pinia に手を伸ばします。
例2: ドラフトと未保存編集(信頼できるフォーム)
サポート担当が顧客レコード(連絡先、請求情報、内部メモ)を編集している場面を想像してください。中断されて別の画面に移り、戻ったときにフォームが作業を忘れていたり、途中のデータを部分的に保存してしまったら信頼は失われます。
ドラフトでは「最後に保存されたレコード」「ユーザーの用意した編集(ステージング)」「検証エラーのような UI 専用状態」を分けて考えます。
ローカル状態: 明確な dirty ルールを持つステージド編集
編集画面が自己完結しているなら、ローカルコンポーネント状態が最も安全なことが多いです。レコードをロードして draft をクローンし、isDirty(あるいはフィールド単位の dirty マップ)を追跡し、エラーはフォームコントロールのそばに保持します。
単純なフロー: レコードを読み込み、ドラフトへコピーし、ドラフトを編集し、ユーザーが Save をクリックしたときにのみ保存リクエストを送る。Cancel はドラフトを破棄して再読み込みします。
provide/inject: ネストされたセクションで共有される1つのドラフト
管理フォームはタブやパネルに分かれていることが多いです(Profile、Addresses、Permissions)。provide/inject を使えば1つのドラフトモデルを保ち、updateField()、resetDraft()、validateSection() のような小さな API を公開できます。各セクションは5階層も props を経由せずに同じドラフトを読んで書けます。
ドラフトで Pinia が役立つ時
ドラフトをナビゲーションに耐えさせたり、編集画面外から見えるようにしたい場合に Pinia が有効です。一般的なパターンは draftsById[customerId] のようにして、各レコードに専用のドラフトを持たせることです。これにより複数の編集画面を同時に開くことも扱いやすくなります。
ドラフトのバグは予測可能なミスから生じます: レコード読み込み前にドラフトを作ってしまう、リフェッチで汚れたドラフトを上書きしてしまう、Cancel 時にエラーを消し忘れる、あるいは単一キーでドラフトを管理して異なるレコードが上書きされるなど。いつドラフトを作るのか、上書きするか破棄するか永続化するか、保存後に置き換えるかといったルールを明確にすれば、多くは解消します。
AppMaster で管理画面を作る場合でも「保存済レコード」と「クライアント側のドラフト」の分離は同じです。保存成功後にバックエンドを真のソースと見なすようにしてください。
例3: マルチタブ編集で状態衝突を防ぐ
マルチタブ編集は管理パネルが壊れやすい領域です。ユーザーが Customer A を開き、次に Customer B を開き、前のタブに戻ったときにそれぞれのタブが自分の未保存変更を覚えていることを期待します。
解決策は各タブを共有ドラフトではなく独自の状態バンドルとしてモデル化することです。各タブは少なくともユニークなキー(通常はレコード ID)、ドラフトデータ、ステータス(clean、dirty、saving)、フィールドエラーを持つ必要があります。
タブが1つの画面内にあるならローカルアプローチでうまくいきます。タブ一覧とドラフトはそのページコンポーネントが管理し、各エディタパネルは自分のバンドルだけを読んで書きます。タブを閉じたらそのバンドルを削除するだけです。これにより状態は分離され、理解しやすくなります。
状態の形はどこに置いてもほぼ同じです:
customerId、draft、status、errorsを持つタブオブジェクトのリストactiveTabKeyopenTab(id)、updateDraft(key, patch)、saveTab(key)、closeTab(key)のようなアクション
タブをナビゲーションで残したり(Orders に飛んで戻る)、複数の画面からタブを開いてフォーカスする必要があるなら、Pinia の小さな「タブマネージャ」ストアが行動を一貫させます。
避けるべき衝突は currentDraft のような単一のグローバル変数です。それは2つ目のタブが開くまでは機能しますが、そこで編集が互いを上書きし、検証エラーが誤った場所に表示され、保存が誤ったレコードを更新してしまいます。開いている各タブが自分のバンドルを持てば、衝突の多くは設計上防げます。
バグと散らかったコードを生む一般的な間違い
ほとんどの管理パネルのバグは「Vue のバグ」ではありません。状態のバグです: データが間違った場所にある、画面の二箇所が合意していない、古い状態がこっそり残る。
よく見られるパターン:
-
最初からすべてを Pinia に入れて所有権があいまいになる。グローバルストアは最初は整理されて見えますが、すぐにすべてのページが同じオブジェクトを読み書きし、クリーンアップが困難になります。
-
明確な契約なしに
provide/injectを使うと隠れた依存が生まれる。子がfiltersを inject していると、どこが提供者か、どのアクションが変更してよいかが不明瞭になります。 -
サーバー状態と UI 状態を同じストアに混ぜると、偶発的な上書きが起きる。フェッチしたレコードが「ドロワーが開いているか」といった UI を踏み倒すことがあるため、分けておく方が安全です。
-
ライフサイクルのクリーンアップを怠ると状態が漏れる。あるビューのフィルターが別のビューに影響し、次に別のレコードを開いたときに古い選択が残っているとアプリが壊れているように見えます。
-
ドラフトのキー付けが不適切だと信頼が失われる。
draft:editUserのような単一キーで管理すると、User A を編集してから User B を編集すると同じドラフトが上書きされます。
単純なルールで多くは防げます: 状態は使われる場所にできるだけ近く置き、2つの独立した部分が本当に共有する必要があるときだけ持ち上げる。共有するなら所有権(誰が変更できるか)と識別(どうキー付けするか)を定義してください。
ローカル、provide/inject、Pinia を選ぶ前のチェックリスト
最も有用な問いは: 誰がこの状態を所有しているか言えるか?と言うことです。それが一文で言えないなら、状態は多過ぎる仕事をしている可能性が高く、分割すべきです。
簡易チェック:
- 所有者を特定できるか(コンポーネント、ページ、またはアプリ全体)?
- ルート変更やリロードを跨いで残る必要があるか?あるなら永続化を計画する。
- 2つのレコードを同時に編集することがあるか?あるならレコード ID でキーを付ける。
- 状態は1つのページシェル以下のコンポーネントだけで使われるか?あるなら
provide/injectが合う。 - 変更を検査して誰が何を変えたかを理解する必要があるか?あるなら Pinia が向く。
道具のマッチング(平易に):
状態が1コンポーネント内で生まれて死ぬなら(ドロップダウンの開閉フラグなど)、ローカルに置く。1つの画面内の複数コンポーネントが共有コンテキストを必要とするなら(フィルターバー+テーブル+サマリー)、provide/inject がグローバルにせずに共有を保てる。ルートを跨いで共有する必要がある、ナビゲーションを跨いで残す必要がある、または予測可能でデバッグしやすい更新が必要なら Pinia を使い、ドラフトが絡む場合はレコード ID でキー付けする。
AppMaster のようなツールで生成した Vue 3 管理 UI を作っているなら、このチェックリストは「早すぎるストア依存」を避けるのに役立ちます。
次の一手: 状態を混乱させず進化させる方法
管理パネルの状態管理を改善する最も安全な方法は、小さく地味なステップで成長させることです。ページ内に留まるものはまずローカルで。実際に再利用が現れたら(ロジックのコピー、3つ目のコンポーネントが同じ状態を必要とする、再現しにくいリセットが起きる)一段上に持ち上げます。その上で初めて共有ストアを検討します。
多くのチームに有効な道筋:
- まずはページ専用の状態をローカルに保つ(フィルター、ソート、ページング、開閉パネル)。
- 同じページ内の複数コンポーネントが共有コンテキストを必要とする場合は
provide/injectを使う。 - ルート間の共有が必要な部分については1つずつ Pinia ストアを追加する(ドラフトマネージャ、タブマネージャ、現在のワークスペース)。
- リセットルールを書き、それに従う(ナビゲーション、ログアウト、フィルタークリア、変更破棄時に何がリセットされるか)。
リセットルールは小さく見えて大多数の「なぜ変わった?」を防ぎます。例えば誰かが別のレコードを開いて戻ったときにドラフトをどう扱うか: 復元するか、警告するか、リセットするかを決めて一貫させてください。
ストアを導入するなら、機能ごとに形作る(feature-shaped)ことを心がけてください。ドラフトストアはドラフトの作成、復元、クリアを扱うべきであり、テーブルフィルターや UI レイアウトフラグまで握るべきではありません。
素早く管理パネルをプロトタイプしたいなら、AppMaster (appmaster.io) は Vue3 の Web アプリとバックエンド、ビジネスロジックを生成できます。生成されたコードは必要に応じて洗練できます。実践的な次の一歩は、1つの画面をエンドツーエンドで作ってみて(例えばドラフト復元付きの編集フォーム)、何が本当に Pinia を必要とするのか、何がローカルで十分かを見極めることです。
よくある質問
ローカル状態は、そのデータが1つのコンポーネントだけに関係し、コンポーネントがアンマウントされたときにリセットして問題ない場合に使います。典型的な例はダイアログの開閉、ひとつのテーブル内の選択行、他で再利用されないフォームセクションです。
provide/inject は同じページ内の複数のコンポーネントが一つの真実のソースを必要とし、props の受け渡しが煩雑になっているときに有効です。提供するものは最小限にして、ページの可読性を保ってください。
状態がルートをまたいで共有される必要があり、ナビゲーションをまたいで残るか、ひと目で検査・デバッグできる必要がある場合に Pinia を選びます。よくある例は現在のワークスペース、権限、機能フラグ、ドラフトやタブのようなクロススクリーンの“マネージャ”です。
まず型を特定します(UI、サーバー、フォーム、クロススクリーン)。次にスコープ(1コンポーネント、1ページ、複数ルート)、ライフタイム(アンマウントでリセットするか、ナビゲーションを跨いで残すか、リロードで残すか)、並行性(同時編集の可能性)を決めます。これら4つのラベルから適切なツールが導かれます。
ユーザーがビューを共有したりリロード後も復元したりすることを期待するなら、フィルターやページネーションをクエリパラメータに入れると良いです。ルート間で“残したまま戻る”体験を期待するなら、リストモデルを Pinia に入れる判断になります。そうでなければページスコープに置いておくのが良いでしょう。
最後に保存されたレコードとユーザーの編集中データ(ドラフト)と、検証エラーなどの UI 専用状態を分けます。ドラフトはクライアント側に保持し、保存ボタンで初めてバックエンドに反映する、といったルールを明確にすると信頼性が高まります。
各エディタにレコード ID をキーにした独立した状態バンドルを与えてください。単一のグローバル currentDraft を使うと、2つ目のタブを開いた瞬間に上書きが発生します。タブごとに draft、status、errors を持たせることで競合を避けられます。
編集フローが1つのルート内に閉じるならページ所有の provide/inject が有効です。ドラフトをルート間で残したり編集画面外からアクセスする必要があるなら、draftsById[recordId] のような Pinia の方がシンプルで予測可能です。
バッジやサマリー、「保存できるかどうか」のフラグなど、他の状態から導けるものは計算で出してください。computed を使えば同期のズレが起きません。
よくあるミスは、最初から何でも Pinia に入れてしまうこと、サーバーの応答と UI トグルを同じ場所に混ぜること、ナビゲーション時のクリーンアップを怠ることです。また、共通のドラフトキー(例えば draft:editUser)を使ってしまい、異なるレコードの編集が上書きされる問題も頻出します。


