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 の変更をストアに自動反映させられます
- 複数ストアもプロバイダーのネストで統合的に管理できます
関連リンク
articleStorybook で Zustand をモックする:Controls 連動とシナリオ駆動 UI
article伝搬方式を比較:Zustand の selector/derived-middleware/外部 reselect の使い分け
articleZustand subscribeWithSelector で発生する古い参照問題:メモ化と equalityFn の落とし穴
articleZustand × useTransition 概説:並列レンダリング時代に安全な更新を設計する
articleフィーチャーフラグ運用:Zustand で段階的リリースとリモート設定を実装
articleオフラインファースト設計:Zustand で楽観的 UI とロールバックを実現
articleStorybook で Zustand をモックする:Controls 連動とシナリオ駆動 UI
articleStorybook 代替ツール比較:Ladle/Histoire/Pattern Lab と何が違う?
articleStorybook の HMR が遅い問題を撃退:大型プロジェクト最適化の実践手順
articleStorybook で“仕様が生きる”開発:ドキュメント駆動 UI の実践ロードマップ
articleStorybook リリース運用:Changesets とバージョン別ドキュメントの整備術
articleStorybook 情報設計の教科書:フォルダ/タイトル/ストーリー命名のベストプラクティス
articleReact でデータ取得を最適化:TanStack Query 基礎からキャッシュ戦略まで実装
articleAnsible Jinja2 テンプレート速攻リファレンス:filters/tests/macros
articlePython Dev Containers 完全レシピ:再現可能な開発箱を VS Code で作る
articleStorybook で Zustand をモックする:Controls 連動とシナリオ駆動 UI
articlePrisma を Monorepo で使い倒す:パス解決・generate の共有・依存戦略
articleプラグイン競合の特定術:WordPress で原因切り分けを高速化する手順
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来