ReactのコンポーネントをStorybookで一覧にして確かめる方法
React のコンポーネントの「通常」「削除用」「押せない」といった状態を、アプリの画面に組み込まずに1つずつ確かめたいときは、Storybook に「ストーリー」として書いておきます。ストーリーは状態ごとにサイドバーへ並び、props をその場で切り替えたり、押したときの動きをテストとして実行したりできます。
筆者は検証環境で、Vite の React + TypeScript のプロジェクトに Storybook 10 を入れ、自作の Button のストーリーを書いて、画面の表示とテストの結果を確かめました。
検証環境は Ubuntu 24.04、Node.js 22.22.0、npm 10.9.4、Chromium 141 です。使ったのは react / react-dom 19.3.1、storybook / @storybook/react-vite 10.6.3、vite 8.3.5、typescript 6.0.3、vitest 5.0.5、playwright 1.56.1 です。画面は、Storybook の開発サーバーを Chromium で開き、Playwright で操作して撮りました。
Vite の React プロジェクトに Storybook を入れる
まず npm create vite@latest app -- --template react-ts --no-interactive でプロジェクトを作り(--no-interactive は質問を省くために付けました)、その中で Storybook の初期設定のコマンドを実行しました。公式のインストールの手順では npm create storybook@latest を使います。筆者は、バージョンを 10.6.3 に固定し、CLI のオプションを付けて、次のように実行しました。
npx -y storybook@10.6.3 init --yes --no-dev --features docs --disable-telemetry --no-agent
付けたのは、質問に自動で答える --yes、入れる機能をドキュメントに絞る --features docs、終わったあとに開発サーバーを起動しない --no-dev、テレメトリーを送らない --disable-telemetry です。--no-agent も付けましたが、公式のオプションの一覧には載っておらず、役割は確かめられませんでした。
出力には、Storybook がプロジェクトを Vite と React の組み合わせ(react-vite)と判定したことと、追加したパッケージが表示されます(一部を抜き出しています)。
o Framework detected: react-vite
| Adding devDependencies:
| - storybook@^10.6.3
| - @storybook/react-vite@^10.6.3
| - @storybook/addon-docs@^10.6.3
o Storybook was successfully installed in your project!
途中で @storybook/addon-docs の設定について「Could not resolve the postinstall hook」という警告が出ましたが、すぐあとに「@storybook/addon-docs configured」と出て、このあとのドキュメントのページも表示できました。
コマンドが足したのは、設定のフォルダ .storybook(main.ts と preview.tsx)、package.json の2つのスクリプト(storybook と build-storybook)、見本のストーリーが入った src/stories です。筆者は見本を消し、.storybook/main.ts からは .mdx のファイルを探す行を消して、次の形にしました。
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
"stories": [
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
],
"addons": [
"@storybook/addon-docs"
],
"framework": "@storybook/react-vite"
};
export default config;
stories に書いたパターンに合うファイル(ここでは src の下の *.stories.tsx など)が、ストーリーとして読み込まれます。開発サーバーは npm run storybook(中身は storybook dev -p 6006)で起動します。検証環境では、同じコマンドに --ci --no-open を付けて起動しました。起動のログに http://127.0.0.1:6006/ と出て、http://localhost:6006/ で開けました。
確かめたいコンポーネントとストーリーを書く
確かめる対象は、見た目の種類(variant)と大きさ(size)を選べる Button です。<button> と同じ props も受け取れるようにしています。
import type { ComponentProps } from 'react';
import './button.css';
type ButtonProps = ComponentProps<'button'> & {
/** 見た目の種類 */
variant?: 'primary' | 'secondary' | 'danger';
/** 大きさ */
size?: 'small' | 'medium';
};
export function Button({ variant = 'primary', size = 'medium', type = 'button', className, ...rest }: ButtonProps) {
const classes = ['btn', `btn-${variant}`, `btn-${size}`, className].filter(Boolean).join(' ');
return <button type={type} className={classes} {...rest} />;
}
同じフォルダに、ストーリーのファイル Button.stories.tsx を置きます。次はその前半です。
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, fn } from 'storybook/test';
import { Button } from './Button';
const meta = {
title: 'Components/Button',
component: Button,
tags: ['autodocs'],
args: {
children: '保存する',
onClick: fn(),
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {};
export const Secondary: Story = {
args: { variant: 'secondary' },
};
export const Danger: Story = {
args: { variant: 'danger', children: '削除する' },
};
export const Small: Story = {
args: { size: 'small' },
};
export const Disabled: Story = {
args: { disabled: true },
};
公式のストーリーの書き方にあるとおり、export default した meta がコンポーネント全体の情報で、名前を付けて export したものが1つずつのストーリーになります。title の / は、サイドバーでのグループの区切りです。
meta の args に書いた値は、すべてのストーリーに共通の propsとして渡され、ストーリーの args で上書きできます。上の例では、どのストーリーも「保存する」というラベルで描かれ、Danger だけが「削除する」になります。onClick に渡した fn() は、呼ばれた回数などをあとで調べられる関数で、後半のテストで使います。
satisfies Meta<typeof Button> と StoryObj<typeof meta> は、meta やストーリーの args に Button の props の型を付けるための書き方で、公式の TypeScript での書き方の説明にある形です。検証環境で npx tsc -p tsconfig.app.json --noEmit を実行したところ、型エラーは出ませんでした。
開発サーバーを開くと、サイドバーに Components › Button のグループができ、その下にストーリーが並びました。

サイドバーの名前は、export した名前から作られます。ClickCallsOnClick は「Click Calls On Click」のように、単語の区切りに空白が入りました。
Controls で props を切り替えて見る
ストーリーを選ぶと、下の Controls のパネルに children、variant、size などの props が並びます。公式の Controls の説明によると、meta の component に渡したコンポーネントの型から、どの props にどんな入力欄を出すかを決めています。variant は、型に書いた3つの値がラジオボタンになりました。
検証環境で、Primary を開いたまま variant の「danger」を選んだところ、その場でボタンが削除用の色に変わりました。

全部の状態を1ページに並べる
meta に tags: ['autodocs'] を書くと、コンポーネントごとのドキュメントのページが作られ、サイドバーの「Docs」から開けます。このページの「Stories」の欄には、ストーリーが上から順に並びます。状態ごとの見た目を、1画面で見比べられます。

この画像には、後半で書くテスト用の2つのストーリーが入っていません。テスト用のストーリーには tags: ['!autodocs'] を付けて、ドキュメントのページから外しています。サイドバーには残ります。
play 関数で、押したときの動きを確かめる
ストーリーに play を書くと、ストーリーを描いたあとにその処理が動きます。公式のインタラクションテストの説明にあるとおり、canvas でストーリーの中の要素を探し、userEvent で操作し、expect で結果を確かめます。Button.stories.tsx の後半に、次の2つを足しました。
export const ClickCallsOnClick: Story = {
tags: ['!autodocs'],
play: async ({ args, canvas, userEvent }) => {
await userEvent.click(canvas.getByRole('button', { name: '保存する' }));
await expect(args.onClick).toHaveBeenCalledTimes(1);
},
};
export const DisabledDoesNotCallOnClick: Story = {
tags: ['!autodocs'],
args: { disabled: true },
play: async ({ args, canvas, userEvent }) => {
await userEvent.click(canvas.getByRole('button', { name: '保存する' }));
await expect(args.onClick).not.toHaveBeenCalled();
},
};
1つ目は、押すと onClick が1回呼ばれることを確かめます。2つ目は、disabled のときに押しても onClick が呼ばれないことを確かめます。
開発サーバーで ClickCallsOnClick を実行すると、Interactions のパネルに、クリックと expect の2つの手順が並び、「PASS」と出ました。

Vitest でストーリーをまとめてテストする
画面で1つずつ開かなくても、Vitest のアドオンを入れると、ストーリーをコマンドでまとめてテストできます。次のコマンドで入れました。
npx storybook add @storybook/addon-vitest --yes
このコマンドが、vitest や @vitest/browser-playwright などのパッケージを入れ、vite.config.ts にテストの設定を足しました。足された設定では、Playwright の Chromium でテストを動かします。
公式の説明では、セットアップの途中で、必要なら Playwright のブラウザを入れるかどうかを聞かれます。検証環境では、すでに入っている Chromium 141 を使うため、それに合う playwright 1.56.1 を入れ直してから実行しました。なお、検証環境では環境変数 CI=1 を付けてアドオンを入れており、この質問に答える流れは試していません。
テストは npx vitest --project=storybook --run --reporter=verbose で実行しました。--reporter=verbose は、ストーリーごとの結果を出すために付けています(結果の部分を抜き出しています)。
✓ |storybook (chromium)| src/components/Button.stories.tsx > Primary 372ms
✓ |storybook (chromium)| src/components/Button.stories.tsx > Secondary 13ms
✓ |storybook (chromium)| src/components/Button.stories.tsx > Danger 9ms
✓ |storybook (chromium)| src/components/Button.stories.tsx > Small 11ms
✓ |storybook (chromium)| src/components/Button.stories.tsx > Disabled 7ms
✓ |storybook (chromium)| src/components/Button.stories.tsx > Click Calls On Click 23ms
✓ |storybook (chromium)| src/components/Button.stories.tsx > Disabled Does Not Call On Click 12ms
Test Files 1 passed (1)
Tests 7 passed (7)
play のない5つのストーリーも、テストとして数えられています。公式の説明によると、どのストーリーも、エラーなく描けるかを確かめる「スモークテスト」として動き、play があるものは、その中の expect も確かめます。
props の渡し忘れを、テストで見つける
テストが本当に間違いを見つけるかを確かめるため、Button をわざと壊しました。残りの props(...rest)を <button> に渡さず、children だけを渡す形です。元のコードとの違いは次のとおりです(壊した版に足した説明のコメント行は省いています)。
-export function Button({ variant = 'primary', size = 'medium', type = 'button', className, ...rest }: ButtonProps) {
+export function Button({ variant = 'primary', size = 'medium', type = 'button', className, children }: ButtonProps) {
const classes = ['btn', `btn-${variant}`, `btn-${size}`, className].filter(Boolean).join(' ');
- return <button type={type} className={classes} {...rest} />;
+ return <button type={type} className={classes}>{children}</button>;
もう一度テストを実行すると、押したときに onClick が呼ばれないため、ClickCallsOnClick が失敗しました(途中の行を省いています)。
× |storybook (chromium)| src/components/Button.stories.tsx > Click Calls On Click 17ms
→
Click to debug the error directly in Storybook: http://localhost:6006/?path=/story/components-button--click-calls-on-click&addonPanel=storybook/interactions/panel
expected "onClick" to be called 1 times, but got 0 times
Test Files 1 failed (1)
Tests 1 failed | 6 passed (7)
失敗したのは、この1つだけでした。DisabledDoesNotCallOnClick は成功のままです。壊した Button では onClick がそもそも渡らないので、押しても呼ばれないからです。disabled も渡らなくなっていますが、それを確かめるストーリーはありません。play 関数で確かめられるのは、expect に書いたことだけです。disabled が効かずにボタンが半透明にならない、といった見た目の違いは、ドキュメントのページなどで目で見て確かめる必要があります。
Button を元に戻すと、7つとも成功に戻りました。
静的なサイトとして書き出す
npm run build-storybook を実行すると、storybook-static フォルダに index.html などのファイルが書き出されました。公式の公開の手順によると、このフォルダは普通の Web サーバーに置いて公開できます。筆者は書き出しまでを試し、書き出したサイトを開くことと公開することは試していません。
ストーリーは、状態ごとの props(args)を書いて名前を付けて export するだけで、サイドバーとドキュメントのページに並びます。押したときの動きは play 関数に書き、Vitest のアドオンを入れると、コマンドでまとめてテストできます。





