1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Zustand 8ストア構成で学んだ状態管理設計 — 投資ポートフォリオアプリの実例

1
Posted at

はじめに

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行を超えて可読性が落ちた。次に作るなら marketDataStoresyncStore を分離すると思う。

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 でのマルチストア設計を検討している方の参考になれば幸いだ。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?