© TOOLS BOX — Next.js / React / TypeScript コードサンプル集

サンプルガイド
←サンプル一覧
nextjsui-component

Zustand Test の Form / Monitor / TabBar を Storybook で分離する

Zustand に依存する UI を Storybook で取り扱うために、store のリセット / decorator / play function を組み合わせて状態をストーリー単位で固定する手順を解説する。

難易度: 中級·更新: 2026-05-07·
zustandstorybookreact-hook-form

対応バージョン

nextjs 15react 19zustand 5storybook 8

前提環境

Storybook の CSF3 形式(meta + Story)と decorator の基本を理解していること

概要

Zustand Test の UI(フォーム / Monitor / TabBar)は Zustand store にぶら下がっている ため、何もしないと Storybook 上で「前のストーリーの状態が残っている」「ストーリーごとに違う初期値を見せたいのに固定できない」といった問題が起きます。

これを解くカギは 2 つ。

  • decorator で各ストーリー実行前に store をリセット / 初期化する
  • play function で初期表示後の操作(タブ切替・入力)を再現する

インストール

すでに Storybook が入っている前提です。@storybook/test の userEvent / expect を使います。

npm install -D @storybook/test

ストーリーごとに store をリセットする

各 store の reset action を decorator で呼びます。decorator は Story の 直前に走る ため、初期値で開始する保証になります。

// src/components/SettingsForm.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { SettingsForm } from "./SettingsForm";
import { useSettingsStore } from "@/store/settingsStore";
import { useProfileStore }  from "@/store/profileStore";
import { useDetailStore }   from "@/store/detailStore";
import { useUiStore }       from "@/store/uiStore";

const meta: Meta<typeof SettingsForm> = {
  title: "playground/SettingsForm",
  component: SettingsForm,
  decorators: [
    (Story) => {
      // 各ストーリー実行前に全 store をリセット
      useSettingsStore.getState().reset();
      useProfileStore.getState().reset();
      useDetailStore.getState().reset();
      useUiStore.getState().reset();
      return <Story />;
    },
  ],
};
export default meta;

type Story = StoryObj<typeof SettingsForm>;

export const Empty: Story = {};

decorator 内で getState().reset() を呼ぶだけで「毎ストーリー、初期値から始まる」が成立します。

ストーリーごとに初期値を差し込む

「name に "alice" が入った状態」「rate が満タンの状態」など、特定の初期状態 を示したいストーリーは、reset の直後に値を入れます。

export const Filled: Story = {
  decorators: [
    (Story) => {
      useSettingsStore.getState().reset();
      useSettingsStore.getState().setFormData({
        name:  "alice",
        email: "alice@example.com",
        rate:  72,
      });
      return <Story />;
    },
  ],
};

decorator は meta レベルの decorator のあとに実行される ので、リセット → 値の差し込み、の順序が守られます。

play function で操作を再現する

「タブを切り替えてから保存ボタンを押す」のような 画面操作の流れ をストーリーに焼き付けるなら、play を使います。Storybook の interaction tab で再生でき、CI でも回せます。

import { userEvent, within, expect } from "@storybook/test";

export const SubmitFromDetailTab: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // 詳細タブに切り替える
    await userEvent.click(canvas.getByRole("tab", { name: "詳細" }));

    // 入力する
    await userEvent.type(canvas.getByLabelText("名前"), "alice");
    await userEvent.type(canvas.getByLabelText("メール"), "alice@example.com");

    // 保存
    await userEvent.click(canvas.getByRole("button", { name: "保存" }));

    // store の値を assert する
    const settings = useSettingsStore.getState();
    expect(settings.name).toBe("alice");
    expect(settings.email).toBe("alice@example.com");
  },
};

play の中から getState() で store を直接 assert できるのが Zustand の利点です。React の DOM だけでなく state まで検証範囲に入ります。

Monitor だけのストーリーを分ける

StoreMonitor は表示だけで store には書き込まないため、初期値を differents なケースで見せる のに向いています。

// src/components/monitor/StoreMonitor.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { StoreMonitor } from "./StoreMonitor";
import { useSettingsStore } from "@/store/settingsStore";

const meta: Meta<typeof StoreMonitor> = {
  title: "playground/StoreMonitor",
  component: StoreMonitor,
};
export default meta;

type Story = StoryObj<typeof StoreMonitor>;

export const Initial:   Story = {
  decorators: [(Story) => { useSettingsStore.getState().reset(); return <Story />; }],
};

export const WithRate75: Story = {
  decorators: [(Story) => {
    useSettingsStore.getState().reset();
    useSettingsStore.getState().setFormData({ name: "alice", email: "a@b.c", rate: 75 });
    return <Story />;
  }],
};

Monitor は副作用がないので、ストーリー間でも安定して挙動を比較できます。

RHF を使うストーリーは FormProvider を被せる

SettingsForm 内で useFormContext を使う構造にしているなら、Storybook 側でも FormProvider を被せる decorator を追加します。

import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { formSchema, type FormData } from "@/schemas/formSchema";

const FormHarness = ({ children }: { children: React.ReactNode }) => {
  const form = useForm<FormData>({
    resolver: zodResolver(formSchema),
    defaultValues: { name: "", email: "", rate: 0 },
  });
  return <FormProvider {...form}>{children}</FormProvider>;
};

const meta: Meta<typeof SettingsForm> = {
  title: "playground/SettingsForm",
  component: SettingsForm,
  decorators: [
    (Story) => <FormHarness><Story /></FormHarness>,
  ],
};

これで Storybook 上の SettingsForm も、本番と同じ前提(FormProvider ラップ)で動きます。

ポイント

  • decorator で 毎ストーリーの直前に reset() を呼ぶ。state が前のストーリーから漏れない
  • 特定の初期状態を見せたいストーリーは、reset 後に setFormData などで値を差し込む
  • play function の中で getState() を直接 assert できる。DOM 以外の検証もカバーできる
  • Monitor のような表示専用コンポーネントは decorator で初期値だけ変えた複数ストーリー にすると比較しやすい
  • RHF を使う UI は FormProvider を被せる decorator を追加し、本番と同じ前提を再現する

関連記事

セットで読むと文脈がつながる関連記事は次のとおりです。

  • Store Monitor 編 — ストーリーで複数の状態を見せたい本体
  • TabBar a11y 編 — play で操作する対象
  • エラー UI / aria 編 — エラー状態を見せるストーリーの作り方
  • Zustand Test (Build) — 完成例と外部デモ

注意点

Playground 追加記事。store がモジュールスコープで共有される性質に合わせて、ストーリーごとに状態を切り替える decorator パターンをまとめる。

リポジトリを見る

関連サンプル

同じテーマや技術スタックを使った実装例

  • Zustand Test のアクションを selector → props で安全に受け渡す

    Zustand のアクションは store 生成時から参照が安定している性質を活かし、selector で取り出して props で子に渡す書き方を解説する。

  • Zustand Test の Store Monitor サイドパネルを selector だけで作る

    全 store の値をリアルタイムに監視するサイドパネルを selector だけで構成し、再レンダリング範囲を局所化するパターンを解説する。

  • Zustand Test の uiStore でタブ切り替えだけを切り出す

    アクティブなタブの状態だけを抱える uiStore を独立させ、settings / profile / detail と疎結合に保つ最小設計を解説する。

  • Next.js 15 + Zustand でモーダル状態管理を作る

    Zustand を使ってモーダルの開閉状態をグローバルに管理する実装例。複数モーダルへの対応と型安全な設計を示す。

  • Zustand Test の保存処理を非同期 action と loading / error 状態で扱う

    saveSettings のような非同期処理を store のアクションに閉じ込め、status: 'idle'|'loading'|'error'|'success' を 1 つのフィールドで表現する書き方を解説する。

関連仕様

このサンプルを理解するのに役立つ仕様や概念

  • FrameworkNext.jsReact ベースのフルスタックフレームワーク。SSR・SSG・App Router・API Routes を提供する。
←サンプル一覧に戻る