概要
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などで値を差し込む playfunction の中でgetState()を直接 assert できる。DOM 以外の検証もカバーできる- Monitor のような表示専用コンポーネントは decorator で初期値だけ変えた複数ストーリー にすると比較しやすい
- RHF を使う UI は
FormProviderを被せる decorator を追加し、本番と同じ前提を再現する
関連記事
セットで読むと文脈がつながる関連記事は次のとおりです。
- Store Monitor 編 — ストーリーで複数の状態を見せたい本体
- TabBar a11y 編 —
playで操作する対象 - エラー UI / aria 編 — エラー状態を見せるストーリーの作り方
- Zustand Test (Build) — 完成例と外部デモ