ReactとTailwind CSSで始めるStorybook 8入門:独立したUIコンポーネントの構築とテスト

Development tutorial - IT technology blog
Development tutorial - IT technology blog

1. UIコンポーネント構築における3つの主要なアプローチ

ReactとTailwind CSSを使ったUI構築は、一見すると非常にシンプルです。ページファイルを開き、JSXタグを書き、ユーティリティクラスを付与するだけで完了します。しかし、プロジェクトの規模が拡大するにつれて、このやり方はすぐに限界を迎えます。現在、現場では主に次の3つのアプローチが採られています。

  • PageやFeature内に直接実装する:機能ディレクトリ内でコンポーネントを実装し、メイン画面(ダッシュボードや決済画面など)に直接インポートする方法です。UIがAPI呼び出し、URLルーター、グローバル状態と強く結合してしまいます。
  • 内部Playground用ルーティングを作成する:/dev-componentsのような検証用パスを一時的に設け、各種ボタン、モーダル、入力フォームをまとめてレンダリングする方法です。手軽に一覧確認できるものの、高度なインタラクション検証ツールが不足しています。
  • Storybook 8によるコンポーネント駆動開発(Component-Driven):ビジネスロジックからUIを完全に切り離します。各コンポーネントは独立したサンドボックス内で動作し、ホバー、ローディング、エラー、非活性(disabled)などの全状態を網羅して確認できます。

2. 各手法のメリットとデメリット

プロダクトの規模やチームの開発リソースに応じて、各手法にはそれぞれ適した使いどころがあります。

Page/Feature内への直接実装

  • メリット:初期段階での実装スピードが最も速い。外部ライブラリをセットアップする必要がなく、思い立ったらすぐに書き始められます。
  • デメリット:エッジケースの再現が非常に困難です。例えば、決済フローのステップ4の奥深くにあるエラーモーダルを調整したい場合、UIの微調整やサーバーの500エラーをテストするたびに、毎回ステップ1から操作をやり直す必要があります。また、バックエンドのAPI実装が完了していなければ、フロントエンドは待機を余儀なくされます。

内部Playgroundページの構築

  • メリット:コンポーネントを1つの画面に集約でき、アプリのメインフローを乱すことがありません。
  • デメリット:不要コードの保守コストがかさみます。コンポーネント数が30を超えると画面が散らかりやすくなります。また、ルート遮断の設定を忘れてしまうと、検証用ページが本番環境へ流出するリスクもあります。

Storybook 8の導入

  • メリット:Viteネイティブアーキテクチャのおかげで、極めて高速に起動します(通常2秒未満)。Controlsパネルにより、コードを編集することなくブラウザ上で直接Propsを変更できます。Storybook 8にはユーザー操作のエラーを検出するインタラクションテストが統合されており、デザイナーやQAとの認識合わせに使える生きた仕様書としても機能します。
  • デメリット:初期設定に一定のコストがかかります。また、UI実装と並行して.stories.tsxファイルを作成・保守するというチーム内の開発規律が求められます。

3. どのようなプロジェクトにStorybookを導入すべきか?

1〜2週間で完結するプロモーション用のランディングページや小規模な個人プロジェクトであれば、ページに直接コードを書くのが最も現実的な選択です。まだ必要としていない段階でシステムを過剰に複雑にするべきではありません。

一方で、3〜5名以上のフロントエンドエンジニアが関わる商用Webアプリケーションでは、Storybookは非常に高い費用対効果(ROI)をもたらします。最近のEコマースプロジェクトで筆者のチームがStorybookを導入した際、UI結合にかかる工数を約35%削減できました。1人のエンジニアがFigmaのデザインに沿ってコンポーネント全体の構築を担当し、もう1人がカートの状態管理やビジネスロジックに集中しました。バックエンドのAPI提供が予定より1週間遅れた際も、本番データに依存することなくUIの実装を予定通り完了させることができました。

4. ReactとTailwind CSSへのStorybook 8導入手順

以下のステップは、ViteとTypeScriptを採用したReactプロジェクトを前提としています。

ステップ1:Reactプロジェクトの作成とTailwind CSSの設定

既存のプロジェクトがある場合は、ステップ2へお進みください。新規作成する場合は、Viteを使ってセットアップします。

# ViteでReact + TypeScriptプロジェクトを作成
npm create vite@latest my-design-system -- --template react-ts
cd my-design-system

# Tailwind CSS v3のインストール
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Tailwindが.storybookディレクトリ内のStoryファイルもスキャンするように、tailwind.config.jsを設定します。

/** @type {import('tailwindcss').Config} */
export default {
  content: [
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}",
    "./.storybook/**/*.{js,ts,jsx,tsx}"
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

src/index.cssの先頭にTailwindディレクティブを追記します。

@tailwind base;
@tailwind components;
@tailwind utilities;

ステップ2:Storybook 8のインストール

プロジェクトのルートディレクトリで以下の初期化コマンドを実行します。

npx storybook@latest init

CLIが自動的にViteを検出し、.storybook/ディレクトリを生成します。Storybook上のコンポーネントにTailwindのクラスを適切に反映させるため、.storybook/preview.tsでメインCSSをインポートします。

import type { Preview } from "@storybook/react";
import "../src/index.css"; // Tailwind CSS設定ファイルの読み込み

const preview: Preview = {
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
};

export default preview;

ステップ3:デザインシステム標準のButtonコンポーネントの構築

バリアント(variant)、サイズ(size)、ローディング状態を持つButtonコンポーネントを作成します。src/components/Button.tsxを作成してください。

import React from "react";

export interface ButtonProps {
  variant?: "primary" | "secondary" | "danger";
  size?: "sm" | "md" | "lg";
  label: string;
  disabled?: boolean;
  isLoading?: boolean;
  onClick?: () => void;
}

export const Button: React.FC<ButtonProps> = ({
  variant = "primary",
  size = "md",
  label,
  disabled = false,
  isLoading = false,
  onClick,
}) => {
  const baseStyles = "inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2";

  const sizeStyles = {
    sm: "px-3 py-1.5 text-xs",
    md: "px-4 py-2 text-sm",
    lg: "px-6 py-3 text-base",
  };

  const variantStyles = {
    primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 disabled:bg-blue-300",
    secondary: "bg-gray-100 text-gray-800 hover:bg-gray-200 focus:ring-gray-400 disabled:bg-gray-50",
    danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 disabled:bg-red-300",
  };

  return (
    <button
      type="button"
      disabled={disabled || isLoading}
      onClick={onClick}
      className={`${baseStyles} ${sizeStyles[size]} ${variantStyles[variant]} ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
    >
      {isLoading && (
        <svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-current" fill="none" viewBox="0 0 24 24">
          <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
          <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
        </svg>
      )}
      {label}
    </button>
  );
};

ステップ4:CSF3標準に沿ったStoryの作成

Storybook 8ではComponent Story Format 3(CSF3)がデフォルトで採用されており、記述量を大幅に削減できます。src/components/Button.stories.tsxを作成します。

import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./Button";

const meta: Meta<typeof Button> = {
  title: "Components/Button",
  component: Button,
  parameters: {
    layout: "centered",
  },
  tags: ["autodocs"],
  argTypes: {
    variant: {
      control: { type: "select" },
      options: ["primary", "secondary", "danger"],
    },
    size: {
      control: { type: "radio" },
      options: ["sm", "md", "lg"],
    },
  },
};

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

export const Primary: Story = {
  args: {
    variant: "primary",
    label: "変更を保存",
  },
};

export const Danger: Story = {
  args: {
    variant: "danger",
    label: "アカウントを削除",
  },
};

export const LoadingState: Story = {
  args: {
    variant: "primary",
    label: "処理中...",
    isLoading: true,
  },
};

Storybookを起動して表示を確認します。

npm run storybook

ブラウザでhttp://localhost:6006にアクセスします。Controlsタブからサイズや背景色を切り替えたり、ラベルテキストを直接編集したりして、コンポーネントの柔軟な伸縮や表示変化をプレビューできます。

ステップ5:play()関数を用いたインタラクションテストの実装

Storybook 8の大きな強みの1つが@storybook/testパッケージです。このパッケージはTesting LibraryとVitestを統合しており、ターミナル上の単体テストにとどまらず、実際のUI上でクリックやキーボード入力といったユーザー操作をテストできます。

src/components/Button.stories.tsxの末尾にクリックテストのシナリオを追加します。

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

export const InteractiveClick: Story = {
  args: {
    label: "テストクリック",
    onClick: fn(), // 呼び出しを追跡するモック関数
  },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole("button", { name: /テストクリック/i });

    // ユーザーのマウスクリック操作をシミュレート
    await userEvent.click(button);

    // onClick関数がトリガーされたかを検証
    await expect(args.onClick).toHaveBeenCalledOnce();
  },
};

ブラウザでこのStoryを開くと、下部ツールバーのInteractionsタブにミリ秒単位でPass/Failの実行結果が表示されます。タイムトラベルデバッグ機能を使えば操作ステップを1つずつ巻き戻して確認できるため、フォーカス制御やフォーム送信のデバッグ作業が劇的にスムーズになります。

5. チーム開発における実践的なベストプラクティス

  • サイドバーの階層(ネームスペース)を整理する:Elements/Button、Forms/InputField、Overlays/ModalのようにStoryをカテゴリ分類しておくことで、コンポーネントが数百個に膨らんだ際もディレクトリツリーの視認性を高く保てます。
  • autodocsを活用してドキュメントを自動化する:メタ情報にautodocsタグを残しておくだけで、StorybookがTypeScriptのInterfaceを自動解析し、詳細なProps一覧表を生成してくれます。手動でMarkdownドキュメントを保守する手間が省けます。
  • レビューフロー(CI/CD)へ組み込む:コードのマージ前にnpm run build-storybookを実行して静的ファイルをビルドし、Vercel PreviewやCloudflare Pagesへ自動デプロイしてPull RequestにURLを添付します。デザイナーやQA担当者はチャットツール越しのスクリーンショットに頼ることなく、デプロイ先リンクから直接実機動作をレビューできます。
Share: