はじめに
React の状態管理ライブラリとして Zustand を採用し、8つのストアに分割した設計で投資ポートフォリオ管理アプリ PFWise を運用して約1年が経った。
当初は Redux Toolkit で始めたが、ボイラープレートの多さと Action/Reducer の分離による認知負荷に限界を感じ、Zustand 5.x に移行した。Context API も検討したが、再レンダリングの制御が困難で金融データのリアルタイム表示には不向きだった。
この記事では、Zustand で8ストア構成を設計・運用する中で得た知見を、実際のコード例とともに共有する。
なぜ8ストアなのか
単一ストアの限界
小規模なアプリなら useStore 1つで十分だが、以下のような要件が重なると単一ストアは破綻する。
- ポートフォリオデータ: 保有銘柄、為替レート、市場データ、Google Drive同期
- 認証: Google OAuth、JWT管理、セッション永続化
- エンゲージメント: 連続アクセス記録、スコア履歴、マイルストーン
- UI状態: テーマ、通知、ローディング、プライバシーモード
これらを1つのストアに詰め込むと、型定義だけで数百行になり、どのアクションがどの状態を変更するのか追跡不能になる。
責務分割の基準
分割の基準は「永続化の方式が同じか」と「変更頻度が近いか」の2軸で判断した。
| ストア | 責務 | 永続化 | 変更頻度 |
|---|---|---|---|
portfolioStore |
保有銘柄・為替・市場データ・Drive同期 | localStorage + サーバー同期 | 高(市場データ更新) |
authStore |
OAuth・JWT・セッション | メモリ(JWTはlocalStorage禁止) | 低(ログイン時のみ) |
uiStore |
テーマ・通知・ローディング | localStorage | 中(ユーザー操作都度) |
engagementStore |
ストリーク・スコア履歴・バッジ | localStorage | 低(日次) |
goalStore |
投資目標CRUD | localStorage | 低 |
notificationStore |
アラートルール・通知管理 | localStorage | 低 |
referralStore |
リファラルコード・統計 | sessionStorage | 極低 |
socialStore |
ポートフォリオ共有・ピア比較 | localStorage | 低 |
ポイントは authStore だけ永続化方式が異なる点。JWTはセキュリティ上メモリのみに保持し、localStorage には保存しない。この制約だけでも分離する理由として十分だった。
ストア設計の実装パターン
基本構造: create + persist middleware
Zustand のストアは create + middleware の組み合わせで宣言する。永続化が必要なストアには persist middleware を適用する。
// goalStore.ts — 投資目標管理
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface GoalState {
goals: InvestmentGoal[];
addGoal: (input: GoalInput) => AddGoalResult;
removeGoal: (id: string) => void;
getMaxGoals: () => number;
}
export const useGoalStore = create<GoalState>()(
persist(
(set, get) => ({
goals: [],
addGoal: (input) => {
const maxGoals = get().getMaxGoals();
if (get().goals.length >= maxGoals) {
return { success: false, limitReached: true };
}
// バリデーション + 追加ロジック
const validated = validateGoalInput(input);
if (validated.errors) return { success: false, errors: validated.errors };
set((state) => ({
goals: [...state.goals, createGoal(validated)],
}));
return { success: true };
},
removeGoal: (id) => {
set((state) => ({
goals: state.goals.filter((g) => g.id !== id),
}));
},
getMaxGoals: () => getIsPremiumFromCache() ? 5 : 1,
}),
{ name: 'pfwise-goals' }
)
);
この構造により、ストアの定義・型・永続化設定が1ファイルに凝縮される。Redux の createSlice + configureStore + persistReducer に比べて圧倒的にコード量が少ない。
Cross-Store Communication: getState() パターン
8つのストアが完全に独立しているわけではない。たとえば、ポートフォリオにデータが初めて登録されたとき、engagementStore のセレブレーション機能を発火させたい。
Redux だとどうなるか: dispatch を中継する middleware を書くか、saga/thunk でストアを横断する。
Zustand では getState() で直接呼ぶ:
// portfolioStore.ts 内
import { useEngagementStore } from './engagementStore';
import { useUIStore } from './uiStore';
// CSV取込完了後の処理
const afterImport = () => {
// engagementStore のセレブレーション発火
useEngagementStore.getState().celebrateFirstData();
// uiStore の通知表示
useUIStore.getState().addNotification('データを取り込みました', 'success');
};
getState() はストアの最新状態を同期的に取得する。React のレンダリングサイクル外(非同期処理やイベントハンドラ内)でも安全に呼べるのが利点だ。
getState() パターンの依存グラフ
実際のストア間依存は以下のようになっている。
authStore ──→ portfolioStore(ログイン後のデータ同期)
portfolioStore ──→ uiStore(通知・ローディング制御)
portfolioStore ──→ engagementStore(初回データ登録セレブレーション、トライアル判定)
goalStore ──→ uiStore(目標追加時の通知)
socialStore ──→ uiStore(共有操作の通知)
notificationStore ──→ uiStore(アラート発火時の通知表示)
重要なのは循環依存を避けること。portfolioStore → engagementStore は許容するが、engagementStore → portfolioStore を追加すると循環が発生する。この方向性の制約はコメントやドキュメントで明示している。
subscribeWithSelector: 変更検知と自動同期
portfolioStore はサーバーとの自動同期機能を持つ。Zustand の subscribeWithSelector middleware で特定のフィールドの変更を検知し、5秒デバウンスでサーバーに同期する。
import { subscribeWithSelector } from 'zustand/middleware';
export const usePortfolioStore = create<PortfolioState>()(
persist(
subscribeWithSelector(
(set, get) => ({
// ... ストア定義
})
),
{ name: 'pfwise-portfolio' }
)
);
// ストア外でサブスクリプション設定
const syncFields = (state: PortfolioState) => ({
currentAssets: state.currentAssets,
targetPortfolio: state.targetPortfolio,
baseCurrency: state.baseCurrency,
});
usePortfolioStore.subscribe(
syncFields,
debounce((fields) => {
const store = usePortfolioStore.getState();
if (getAuthToken()) {
store.syncToServer();
}
}, 5000),
{ equalityFn: shallow }
);
Redux で同等のことをやろうとすると、store.subscribe() + selector + deep equal 比較を自前で組む必要がある。Zustand なら middleware の組み合わせで宣言的に書ける。
プラン制限の横断的な適用
SaaS として Free / Standard プランの制限を各ストアに適用する必要がある。Zustand では「各ストアが自身のプラン制限を知っている」設計にした。
// portfolioStore.ts
const MAX_HOLDINGS_FREE = 5;
const MAX_HOLDINGS_STANDARD = Infinity;
const getMaxHoldings = (): number => {
if (getIsPremiumFromCache()) return MAX_HOLDINGS_STANDARD;
// トライアル期間中は10銘柄まで
const { isInTrialPeriod } = useEngagementStore.getState();
if (isInTrialPeriod()) return TRIAL_MAX_HOLDINGS;
return MAX_HOLDINGS_FREE;
};
// goalStore.ts
const MAX_GOALS_FREE = 1;
const MAX_GOALS_STANDARD = 5;
getMaxGoals: () => getIsPremiumFromCache() ? MAX_GOALS_STANDARD : MAX_GOALS_FREE,
プラン判定ロジック自体は TanStack Query のキャッシュ (getIsPremiumFromCache()) に委ね、各ストアは「上限値の取得」だけを担う。これにより Stripe のサブスクリプション状態が変わっても、各ストアのロジックを変更する必要がない。
テストでの Zustand モック
Vitest + React Testing Library でのテストでは、Context Provider のラップではなく vi.mock() でストアごとモックする。
vi.mock('../../stores/portfolioStore', () => ({
usePortfolioStore: vi.fn(() => ({
currentAssets: mockAssets,
baseCurrency: 'JPY',
exchangeRate: { rate: 150 },
refreshMarketData: vi.fn(),
})),
}));
ストアが分離されているため、テスト対象のコンポーネントが依存するストアだけをモックすれば良い。8ストア全てをモックする必要はない。
Redux / Context API との比較
| 観点 | Redux Toolkit | Context API | Zustand |
|---|---|---|---|
| ボイラープレート | 多い(slice + store + provider) | 少ない | 最小限 |
| 再レンダリング制御 | selector で可能 | 困難(Provider 値変更で全子要素再描画) | selector で可能 |
| DevTools | Redux DevTools | なし | Redux DevTools 互換 |
| 永続化 | redux-persist | 自前実装 | persist middleware |
| ストア分割 | combineReducers | 複数 Context | 複数 create |
| 非同期処理 | thunk / saga | useEffect | ストア内で直接 async |
| Bundle Size | ~11KB | 0KB(React標準) | ~1.5KB |
Zustand の最大の利点は「React の外からもストアにアクセスできる」点だ。getState() でサーバー同期処理やイベントハンドラから直接状態を読み書きできるため、コンポーネントのライフサイクルに依存しないロジックが書きやすい。
1年運用して気づいた注意点
1. ストアの粒度は「大きすぎるより小さすぎる方がマシ」
当初 portfolioStore に市場データフェッチ、為替レート、Google Drive同期、サーバー同期を全て入れたが、700行を超えて可読性が落ちた。次に作るなら marketDataStore と syncStore を分離すると思う。
2. getState() の乱用は依存グラフを見えなくする
TypeScript の import で依存関係は追えるが、実行時にどのタイミングで getState() が呼ばれるかはコードを読まないとわからない。依存方向をドキュメントやコメントで明示する運用が必要。
3. persist middleware のマイグレーション
localStorage の永続化データのスキーマが変わると、既存ユーザーのデータとの互換性を保つマイグレーションが必要になる。persist middleware の version + migrate オプションを最初から設定しておくべきだった。
まとめ
Zustand の8ストア構成は、中〜大規模 React アプリの状態管理として非常に実用的だ。
- 責務分割: 永続化方式 + 変更頻度で分割基準を決める
-
Cross-Store通信:
getState()で同期的にストア間連携。循環依存に注意 -
自動同期:
subscribeWithSelector+ デバウンスでサーバー同期を宣言的に実装 -
テスト:
vi.mock()で依存ストアだけモック。Provider不要
実際に PFWise で運用してみて、Redux からの移行コストは2日程度、コード量は約40%削減、テストの記述量も大幅に減った。金融データのリアルタイム表示という再レンダリング性能が重要なユースケースでも、selector による最適化で問題なく動作している。
Zustand でのマルチストア設計を検討している方の参考になれば幸いだ。