T-CREATOR

Storybook で Zustand をモックする:Controls 連動とシナリオ駆動 UI

Storybook で Zustand をモックする:Controls 連動とシナリオ駆動 UI

React で状態管理を行う際、Zustand は軽量でシンプルな API が魅力的な選択肢です。しかし、Storybook で Zustand を使ったコンポーネントを開発する際、ストアの状態をどうモックするか悩んだ経験はありませんでしょうか。

本記事では、Storybook の Controls 機能と連動させながら Zustand の状態を自在に操作し、さまざまなシナリオを視覚的に確認できる実践的なテクニックをご紹介いたします。UI カタログとしての Storybook がより強力な開発ツールに進化するでしょう。

背景

Zustand と Storybook の相性問題

Zustand はグローバルな状態管理ライブラリとして、シンプルな API で人気を集めています。一方、Storybook はコンポーネントを独立した環境で開発・テストするためのツールです。

この 2 つを組み合わせる際、以下のような課題が生じます。

Zustand のストアはグローバルに作成されるため、Storybook の各ストーリー間で状態が共有されてしまいます。また、ストーリーごとに異なる初期状態を設定したい場合、通常の方法では柔軟な対応が難しいのです。

以下の図は、Zustand と Storybook の基本的な関係性を示しています。

mermaidflowchart TB
  comp["React コンポーネント"]
  store["Zustand ストア<br/>(グローバル)"]
  sb["Storybook"]

  comp -->|useStore で購読| store
  sb -->|レンダリング| comp

  subgraph problem["課題"]
    share["ストーリー間で<br/>状態が共有される"]
    init["初期状態を<br/>柔軟に設定できない"]
  end

  store -.->|影響| problem

Storybook での UI 開発の重要性

現代のフロントエンド開発では、コンポーネント駆動開発が主流となっています。Storybook を活用することで、さまざまな状態のコンポーネントを一覧で確認でき、デザイナーとの認識合わせや QA テストの効率化にも貢献します。

特に状態管理ライブラリを使用したコンポーネントでは、「ローディング中」「エラー発生時」「データが空の場合」など、多様なシナリオを再現できることが重要です。Storybook の Controls 機能を使えば、リアルタイムでパラメータを変更しながら UI の挙動を確認できるため、開発体験が大きく向上するでしょう。

課題

Zustand ストアのモック化における問題点

Zustand を使ったコンポーネントを Storybook で扱う際、いくつかの技術的な課題に直面します。

まず、グローバルストアの問題があります。Zustand のストアはモジュールレベルで作成されるため、複数のストーリー間で同じインスタンスが共有されてしまいます。これにより、あるストーリーで変更した状態が別のストーリーに影響を与える可能性があるのです。

次に、初期状態の制御が困難です。各ストーリーで異なる初期状態を設定したい場合、ストアの作成時点で状態を固定する必要がありますが、通常の Zustand の使い方ではこれが簡単ではありません。

さらに、リアルタイム性の欠如も課題となります。Storybook の Controls パネルで値を変更しても、それが即座にストアに反映されず、コンポーネントの表示が更新されないことがあります。

以下の図は、これらの課題を可視化したものです。

mermaidflowchart LR
  story1["Story A"]
  story2["Story B"]
  store["共有 Zustand ストア"]

  story1 -->|状態変更| store
  store -.->|意図しない影響| story2

  subgraph issues["主な課題"]
    i1["状態の共有"]
    i2["初期化の困難"]
    i3["Controls 非連動"]
  end

  store -.-> issues

Controls との連動不足

Storybook の Controls 機能は、args を通じてコンポーネントの props を動的に変更できる便利な機能です。しかし、Zustand のストアは props として渡されるわけではないため、Controls で設定した値がストアに反映されません。

これにより、UI の状態を対話的に確認したいという Storybook の本来の目的が十分に達成できなくなってしまいます。開発者は毎回コードを書き換えて再ロードする必要があり、開発効率が低下してしまうでしょう。

シナリオベースのテストの困難さ

UI コンポーネントをテストする際、さまざまなシナリオを想定する必要があります。例えば、ユーザー認証の状態、データの読み込み状態、エラー状態などです。

Zustand のストアを適切にモック化できないと、これらのシナリオを簡単に再現できず、ストーリーの作成に多大な労力がかかります。また、チームメンバー間で一貫性のあるモックパターンを共有することも難しくなるのです。

解決策

Zustand のストアをモック可能にする設計

Zustand ストアをモック可能にするには、ストアの作成方法を工夫する必要があります。最も効果的なアプローチは、ストアをファクトリー関数として定義し、初期状態を引数として受け取れるようにすることです。

以下は、基本的なストア定義の例です。

typescriptimport { create } from 'zustand';

// ストアの状態の型定義
interface UserState {
  name: string;
  isLoggedIn: boolean;
  email: string;
  setUser: (name: string, email: string) => void;
  login: () => void;
  logout: () => void;
}

次に、初期状態を受け取れるファクトリー関数を作成します。

typescript// 初期状態の型定義
type InitialState = Partial<
  Pick<UserState, 'name' | 'isLoggedIn' | 'email'>
>;

// ストアファクトリー関数
export const createUserStore = (
  initialState?: InitialState
) => {
  return create<UserState>((set) => ({
    // デフォルト値と初期状態をマージ
    name: initialState?.name ?? 'Guest',
    isLoggedIn: initialState?.isLoggedIn ?? false,
    email: initialState?.email ?? '',

    // アクション定義
    setUser: (name, email) => set({ name, email }),
    login: () => set({ isLoggedIn: true }),
    logout: () =>
      set({ isLoggedIn: false, name: 'Guest', email: '' }),
  }));
};

この設計により、ストーリーごとに異なる初期状態を持つストアインスタンスを作成できるようになります。

Context API を活用したストアの注入

作成したストアをコンポーネントに渡すには、React の Context API を活用します。これにより、グローバルなストアに依存せず、ストーリーごとに独立したストアを使用できるのです。

まず、ストアを提供する Context とプロバイダーを作成します。

typescriptimport {
  createContext,
  useContext,
  ReactNode,
} from 'react';
import { StoreApi, useStore } from 'zustand';

// Context の作成
const UserStoreContext =
  createContext<StoreApi<UserState> | null>(null);

// プロバイダーコンポーネントの Props 型
interface UserStoreProviderProps {
  store: StoreApi<UserState>;
  children: ReactNode;
}

プロバイダーコンポーネントを実装します。

typescript// プロバイダーコンポーネント
export const UserStoreProvider = ({
  store,
  children,
}: UserStoreProviderProps) => {
  return (
    <UserStoreContext.Provider value={store}>
      {children}
    </UserStoreContext.Provider>
  );
};

カスタムフックを作成して、コンポーネントからストアにアクセスできるようにします。

typescript// カスタムフック
export const useUserStore = <T>(
  selector: (state: UserState) => T
): T => {
  const store = useContext(UserStoreContext);

  if (!store) {
    throw new Error(
      'useUserStore must be used within UserStoreProvider'
    );
  }

  return useStore(store, selector);
};

このパターンにより、コンポーネントは Context を通じてストアにアクセスするようになり、テスト時に簡単にモックストアを注入できます。

以下の図は、Context を使ったストア注入の仕組みを示しています。

mermaidflowchart TB
  factory["createUserStore<br/>(ファクトリー)"]
  store1["ストアインスタンス A"]
  store2["ストアインスタンス B"]

  provider1["UserStoreProvider"]
  provider2["UserStoreProvider"]

  comp1["コンポーネント A"]
  comp2["コンポーネント B"]

  factory -->|初期状態 A| store1
  factory -->|初期状態 B| store2

  store1 -->|注入| provider1
  store2 -->|注入| provider2

  provider1 --> comp1
  provider2 --> comp2

  comp1 -->|useUserStore| store1
  comp2 -->|useUserStore| store2

Controls と連動させるデコレーター作成

Storybook の Controls と Zustand ストアを連動させるには、専用のデコレーターを作成します。このデコレーターは args の変更を監視し、ストアの状態を更新する役割を担います。

まず、デコレーターの基本構造を作成します。

typescriptimport { useEffect, useMemo } from 'react';
import { Decorator } from '@storybook/react';

// デコレーターの型定義
type StoreDecoratorArgs = Partial<Pick<UserState, 'name' | 'isLoggedIn' | 'email'>>;

export const withUserStore: Decorator = (Story, context) => {
  // args から状態を取得
  const args = context.args as StoreDecoratorArgs;

  // ストアインスタンスを作成(メモ化)
  const store = useMemo(() => {
    return createUserStore({
      name: args.name,
      isLoggedIn: args.isLoggedIn,
      email: args.email,
    });
  }, []); // 初回のみ作成

次に、args の変更を監視してストアを更新する処理を追加します。

typescript// args が変更されたらストアを更新
useEffect(() => {
  store.setState({
    name: args.name ?? 'Guest',
    isLoggedIn: args.isLoggedIn ?? false,
    email: args.email ?? '',
  });
}, [args.name, args.isLoggedIn, args.email, store]);

最後に、プロバイダーでラップしてストーリーをレンダリングします。

typescript  return (
    <UserStoreProvider store={store}>
      <Story />
    </UserStoreProvider>
  );
};

このデコレーターを使用することで、Controls パネルで値を変更すると即座にストアが更新され、コンポーネントの表示に反映されるようになります。

シナリオ駆動のストーリー作成パターン

実際のストーリーファイルでは、さまざまなシナリオを定義できます。まず、基本的なストーリー設定を行います。

typescriptimport type { Meta, StoryObj } from '@storybook/react';
import { UserProfile } from './UserProfile';

const meta: Meta<typeof UserProfile> = {
  title: 'Components/UserProfile',
  component: UserProfile,
  decorators: [withUserStore],
  // Controls で編集可能な args を定義
  argTypes: {
    name: { control: 'text' },
    email: { control: 'text' },
    isLoggedIn: { control: 'boolean' },
  },
};

export default meta;
type Story = StoryObj<typeof UserProfile>;

ログイン状態のシナリオを定義します。

typescript// シナリオ 1: ログイン済みユーザー
export const LoggedIn: Story = {
  args: {
    name: '山田太郎',
    email: 'yamada@example.com',
    isLoggedIn: true,
  },
};

未ログイン状態のシナリオを定義します。

typescript// シナリオ 2: 未ログインユーザー
export const LoggedOut: Story = {
  args: {
    name: 'Guest',
    email: '',
    isLoggedIn: false,
  },
};

エッジケースのシナリオも簡単に追加できます。

typescript// シナリオ 3: 長い名前のユーザー(UI 境界テスト)
export const LongName: Story = {
  args: {
    name: 'とても長い名前のユーザーですがUIは正しく表示されるでしょうか',
    email: 'very-long-email-address@example.co.jp',
    isLoggedIn: true,
  },
};

このようなパターンで、さまざまな状態を簡単に再現し、視覚的に確認できるようになります。

具体例

実践的なコンポーネントの実装

ここでは、ユーザープロフィールを表示するコンポーネントを例に、実際の実装を見ていきましょう。

まず、コンポーネントの基本構造を作成します。

typescriptimport React from 'react';
import { useUserStore } from './store/UserStoreContext';

// コンポーネントの Props は不要(ストアから取得)
export const UserProfile: React.FC = () => {
  // ストアから必要な状態を取得
  const name = useUserStore((state) => state.name);
  const email = useUserStore((state) => state.email);
  const isLoggedIn = useUserStore((state) => state.isLoggedIn);

  // アクションも取得
  const login = useUserStore((state) => state.login);
  const logout = useUserStore((state) => state.logout);

レンダリング部分を実装します。

typescript  return (
    <div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2>ユーザープロフィール</h2>

      {isLoggedIn ? (
        <div>
          <p><strong>名前:</strong> {name}</p>
          <p><strong>メール:</strong> {email}</p>
          <button onClick={logout}>ログアウト</button>
        </div>
      ) : (
        <div>
          <p>ログインしていません</p>
          <button onClick={login}>ログイン</button>
        </div>
      )}
    </div>
  );
};

このコンポーネントは、ストアの状態に応じて表示内容を切り替え、ボタンクリックでアクションを実行します。

複数ストアの統合パターン

実際のアプリケーションでは、複数のストアを組み合わせることもあります。例えば、ユーザー情報とテーマ設定を別々のストアで管理する場合を見てみましょう。

テーマストアの定義を作成します。

typescript// テーマストアの型定義
interface ThemeState {
  mode: 'light' | 'dark';
  primaryColor: string;
  toggleMode: () => void;
  setPrimaryColor: (color: string) => void;
}

// テーマストアファクトリー
export const createThemeStore = (
  initialState?: Partial<ThemeState>
) => {
  return create<ThemeState>((set) => ({
    mode: initialState?.mode ?? 'light',
    primaryColor: initialState?.primaryColor ?? '#007bff',
    toggleMode: () =>
      set((state) => ({
        mode: state.mode === 'light' ? 'dark' : 'light',
      })),
    setPrimaryColor: (color) =>
      set({ primaryColor: color }),
  }));
};

複数ストアを組み合わせたデコレーターを作成します。

typescript// 複数ストア対応デコレーター
export const withMultipleStores: Decorator = (Story, context) => {
  const { name, email, isLoggedIn, mode, primaryColor } = context.args;

  // 各ストアを作成
  const userStore = useMemo(() => createUserStore({ name, email, isLoggedIn }), []);
  const themeStore = useMemo(() => createThemeStore({ mode, primaryColor }), []);

  // それぞれの状態を更新
  useEffect(() => {
    userStore.setState({ name, email, isLoggedIn });
  }, [name, email, isLoggedIn, userStore]);

  useEffect(() => {
    themeStore.setState({ mode, primaryColor });
  }, [mode, primaryColor, themeStore]);

プロバイダーをネストしてストーリーをレンダリングします。

typescript  return (
    <UserStoreProvider store={userStore}>
      <ThemeStoreProvider store={themeStore}>
        <Story />
      </ThemeStoreProvider>
    </UserStoreProvider>
  );
};

このパターンにより、複数のストアを持つ複雑なコンポーネントも Storybook で簡単にテストできるようになります。

以下の図は、複数ストアの統合構造を示しています。

mermaidflowchart TB
  decorator["withMultipleStores<br/>デコレーター"]

  userFactory["createUserStore"]
  themeFactory["createThemeStore"]

  userStore["UserStore インスタンス"]
  themeStore["ThemeStore インスタンス"]

  userProvider["UserStoreProvider"]
  themeProvider["ThemeStoreProvider"]

  component["コンポーネント"]

  decorator --> userFactory
  decorator --> themeFactory

  userFactory --> userStore
  themeFactory --> themeStore

  userStore --> userProvider
  themeStore --> themeProvider

  userProvider --> themeProvider
  themeProvider --> component

  component -.->|useUserStore| userStore
  component -.->|useThemeStore| themeStore

非同期処理を含むストアのモック

実際のアプリケーションでは、API 呼び出しなどの非同期処理を含むストアも一般的です。これらもモック化できます。

非同期処理を含むストアの定義を作成します。

typescript// データ取得状態を含むストア
interface DataState {
  data: string[];
  isLoading: boolean;
  error: string | null;
  fetchData: () => Promise<void>;
  reset: () => void;
}

// 非同期処理のモック関数型
type FetchDataMock = () => Promise<string[]>;

モック可能な非同期ストアファクトリーを実装します。

typescriptexport const createDataStore = (
  initialState?: Partial<DataState>,
  fetchDataMock?: FetchDataMock
) => {
  return create<DataState>((set) => ({
    data: initialState?.data ?? [],
    isLoading: initialState?.isLoading ?? false,
    error: initialState?.error ?? null,

    // fetchData アクション
    fetchData: async () => {
      set({ isLoading: true, error: null });

      try {
        // モック関数が提供されていればそれを使用
        const result = fetchDataMock
          ? await fetchDataMock()
          : await mockApiCall();

        set({ data: result, isLoading: false });
      } catch (error) {
        set({
          error: (error as Error).message,
          isLoading: false,
        });
      }
    },

    reset: () =>
      set({ data: [], isLoading: false, error: null }),
  }));
};

// デフォルトのモック API
const mockApiCall = async (): Promise<string[]> => {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  return ['Item 1', 'Item 2', 'Item 3'];
};

ストーリーで異なる非同期動作をシミュレートできます。

typescript// ストーリー: 成功ケース
export const Success: Story = {
  args: {
    data: ['データ A', 'データ B', 'データ C'],
    isLoading: false,
    error: null,
  },
};

// ストーリー: ローディング中
export const Loading: Story = {
  args: {
    data: [],
    isLoading: true,
    error: null,
  },
};

// ストーリー: エラー発生
export const Error: Story = {
  args: {
    data: [],
    isLoading: false,
    error: 'データの取得に失敗しました',
  },
};

このように、非同期処理のさまざまな状態を簡単に再現し、UI の挙動を確認できるようになります。

Play 関数を使ったインタラクションテスト

Storybook 7 以降では、Play 関数を使ってユーザーインタラクションを自動化できます。これを Zustand モックと組み合わせることで、より高度なテストが可能になります。

typescriptimport { within, userEvent } from '@storybook/testing-library';
import { expect } from '@storybook/jest';

// インタラクションテストを含むストーリー
export const InteractiveLogin: Story = {
  args: {
    name: 'Guest',
    email: '',
    isLoggedIn: false,
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // 初期状態の確認
    const loggedOutText = canvas.getByText('ログインしていません');
    expect(loggedOutText).toBeInTheDocument();

ボタンクリックのシミュレーションとアサーションを実装します。

typescript    // ログインボタンをクリック
    const loginButton = canvas.getByRole('button', { name: 'ログイン' });
    await userEvent.click(loginButton);

    // ログイン後の状態確認(ストアのアクションが実行される)
    // 注: この例では login() がモックされている前提
    const logoutButton = await canvas.findByRole('button', { name: 'ログアウト' });
    expect(logoutButton).toBeInTheDocument();
  },
};

Play 関数により、Controls で状態を設定するだけでなく、実際のユーザー操作をシミュレートした自動テストも実現できるのです。

まとめ

本記事では、Storybook で Zustand を効果的にモックする方法をご紹介いたしました。

ストアをファクトリー関数として設計し、Context API で注入することで、ストーリーごとに独立した状態管理が可能になります。さらに、専用デコレーターを作成することで、Storybook の Controls 機能と Zustand ストアを連動させ、リアルタイムで UI の変化を確認できるようになりました。

この手法により、さまざまなシナリオを簡単に再現でき、コンポーネントの品質向上と開発効率の改善が期待できます。複数ストアの統合や非同期処理のモック、Play 関数を使ったインタラクションテストなど、実践的なパターンも併せて活用していただければと思います。

Storybook はただの UI カタログではなく、状態管理を含めた包括的な開発ツールとして活用できるでしょう。ぜひ、あなたのプロジェクトでこれらのテクニックを試してみてください。

図で理解できる要点

  • Zustand ストアをファクトリー関数化することで、ストーリーごとに独立したインスタンスを作成できます
  • Context API を使ってストアを注入することで、コンポーネントとストアの結合度を下げられます
  • デコレーターパターンにより、Controls の変更をストアに自動反映させられます
  • 複数ストアもプロバイダーのネストで統合的に管理できます

関連リンク