〇背景
SPAでは、画面遷移のたびに BFF(Backend for Frontend)へ保存・取得を繰り返すが、UXやネットワーク効率の観点から**フロント側での一時保持(Store)**が重要になる。
この記事では、**Store が担うべき“アプリ内状態の一時・共通管理”**と、**BFF が担う“正本データの登録・取得・整形”**の境界を、実装と運用の観点で整理する。
〇今回のコード例(Pinia + BFF想定)
// stores/useUserStore.ts
import { defineStore } from 'pinia'
import type { User } from '@/types'
import { fetchMe, updateProfile } from '@/api/bff' // BFF呼び出し
export const useUserStore = defineStore('user', {
state: () => ({
me: null as User | null,
loading: false,
error: null as string | null,
// UIだけの状態(正本ではない)
ui: { theme: 'light' as 'light' | 'dark' },
}),
actions: {
async loadMe(force = false) {
if (this.me && !force) return
this.loading = true
try {
this.me = await fetchMe()
} catch (e:any) {
this.error = e?.message ?? 'failed to fetch'
} finally {
this.loading = false
}
},
// 楽観的更新(Store→BFF)
async saveProfile(patch: Partial<User>) {
const prev = this.me
this.me = { ...this.me!, ...patch } // 先に反映して体感速度UP
try {
this.me = await updateProfile(this.me!)
} catch (e) {
// 失敗時は巻き戻し
this.me = prev
throw e
}
},
},
getters: {
isLoggedIn: (s) => !!s.me,
},
})
// 画面側(例)
import { onMounted } from 'vue'
import { useUserStore } from '@/stores/useUserStore'
const user = useUserStore()
onMounted(() => user.loadMe()) // 取得済みならキャッシュをそのまま利用
〇調べる前の自分の認識
- Store は未保存の入力や共通情報を保持する場所。
- BFF へは保存・取得のトリガー時だけアクセスして、基本は Store を読み書きする。
- ただし正のデータ源はサーバなので、Store は“キャッシュないし UI 状態”。
→ 概ね正しい。ただし「何を Store に置くか」「いつ破棄・再同期するか」の設計がカギ。
〇調べた結果
Store の立ち位置(役割の要約)
- UI/アプリ状態の一時・共通管理(メモリ上)
- 取得結果の短期キャッシュ(画面間で即時再利用)
- 未保存ドラフトの保持(ウィザード/フォーム途中離脱の復帰)
- ネットワーク遮断時の暫定表示(オフライン耐性の下地)
BFF の立ち位置(やるべきこと)
- 正本データの登録・更新・取得(権限・バリデーション・集約)
- クライアント向け整形(複数APIの集約、過不足のないスキーマ)
- サーバサイドの永続化ポリシー(監査・トランザクション・整合性)
まとめると
- Store=“速さ・一貫性・未保存の保持”
- BFF=“正確さ・権限・永続性”
3. Store を使うとどう変わるか
Storeなし
画面A → BFF保存 → 画面遷移 → BFF取得 → 表示
Storeあり
画面A → Store更新(即時反映)+必要に応じBFF保存
画面遷移 → Storeから即時表示(必要時だけBFF再取得)
4. Storeを使うと便利なシーン
- 検索条件・ページネーション・タブ状態を複数画面で共有
- 一覧→詳細→戻る時にスクロール位置・フィルタを保持
- ウィザード/マルチステップフォームのドラフト保持
- ログインユーザ情報や権限フラグを全体で使い回す
- 楽観的UI(保存を待たずに先に描画)
5. 注意点(設計原則)
-
Single Source of Truthの明確化
- 正本はサーバ。Store は短期キャッシュ/一時状態。
- 「Storeが真」である期間・条件(フェッチ直後~TTL内など)を決める。
-
無尽蔵に詰め込まない
- *UI専用(選択中ID・モーダル開閉)とデータキャッシュ(User, Items)**を分ける。
- 大量リストはキー付きキャッシュ+ページごとTTLで管理。
-
同期ポリシーを持つ
- いつ再取得するか(ルート遷移時 / 明示リフレッシュ / TTL / フォーカス復帰時)。
- 部分無効化(当該IDだけ再取得)を用意。
-
永続化は選択的に
-
pinia-plugin-persistedstate
などで最低限を localStorage に。 - センシティブ情報は永続化しない(トークンはHTTP-only Cookie推奨)。
-
-
エラーとロールバック
- 楽観的更新は巻き戻しを常に用意。
- BFF検証エラーはフォームエラーへマップする責務をフロント側に。
〇動作解説(図解)
┌─────────┐ fetch/update ┌──────────────┐
UI → │ Store │ ───────────────────────▶ │ BFF │
└────┬────┘ └──────┬───────┘
│ (immediate read/write) │
└───────────(response/refresh)◀──────────┘
- 基本は UI ↔ Store を同期、必要時のみ Store ↔ BFF。
- StoreはUIを滞留させて速く、BFFは正本として厳密に。
〇実務での注意点(チェックリスト)
設計
-
データ分類:
UI状態 / 一時ドラフト / 短期キャッシュ / 永続設定
を棚卸し -
同期戦略:
onRouteEnter
でload(force?)
/TTL/手動更新 -
無効化設計:
invalidate(id?)
を用意(更新後の再取得は最小限) - エラー伝播:BFFエラー → Store → 画面(メッセージ規約)
-
PRG:保存後の画面遷移は
router.replace
で二重送信回避
実装
- Storeの責務を薄く(I/Oは action、整形は getter、重ロジックは service 層へ)
-
型の一元化(
types
共有。BFFレスポンスの型変換を関数化) -
並行要求の抑止(
loading
/inflight
フラグ or リクエストキーでデバウンス) - 権限・セッション失効(401/403時の一括ハンドリング)
テスト
- キャッシュ命中でBFF未呼び出しになるか
- TTL切れで自動再取得されるか
- 楽観的更新失敗時に確実にロールバックするか
- 直リンク/再読込で必要データを再構築できるか
- 複数タブでの整合(永続化を使う場合のスタンプ比較)
〇まとめ・所感
- Store=速さと一貫性、BFF=正確さと永続性。
- UXを上げるカギは「Storeを一次キャッシュとして使い、必要な時だけBFF」に行くこと。
- 一方で、“何をStoreに置くか/いつ無効化するか”が曖昧だとバグと同期ズレの温床になる。
- 設計初期にデータ分類表と同期ポリシーを決め、PRG・エラーマップ・無効化APIを整えると運用が安定する。
次回案(Day5):
- キャッシュ無効化パターン徹底解説(ID単位/クエリ単位/TTL/イベント駆動)
- pinia-plugin-persistedstate の安全な使い方(ホワイトリスト・暗号化・マイグレーション)
- 楽観的UIパターン(Toasts、差分ハイライト、失敗時の再試行)
とりあえずStoreを利用することで部分的な送信受信が可能になり、通信・ソースコードにおける無駄を省けることに旨味があるということだ。そのアプリかサービス特有のキャッシュと考えると早い。