T-CREATOR

Storybook × React:現場で役立つベストプラクティス 10 選

Storybook × React:現場で役立つベストプラクティス 10 選

Storybook を導入してみたものの、「基本的な使い方はわかったけど、現場でもっと活用するにはどうすれば?」と感じているエンジニアの方も多いのではないでしょうか。

シンプルなストーリーは作れるようになったけれど、複雑な状態管理や API 連携、パフォーマンス最適化など、実際のプロダクト開発で直面する課題にどう対応すれば良いのか。そんな疑問をお持ちの方に向けて、現場で本当に役立つ実践的なテクニックをお伝えします。

今回ご紹介する 10 のベストプラクティスは、実際のプロダクト開発現場で培われた知見を基に厳選しました。これらを活用することで、Storybook が単なる「コンポーネントカタログ」から「開発体験を劇的に向上させる強力なツール」へと変貌することでしょう。

ベストプラクティス 1:Advanced Controls でリアルタイムデバッグ

基本的な Controls アドオンは多くの方が使っていると思いますが、その高度な機能を活用することで、デバッグ効率を大幅に向上させることができます。

オブジェクト型のコントロール活用

typescript// 複雑なオブジェクトを直接操作可能に
export interface UserProfileProps {
  user: {
    id: string;
    name: string;
    email: string;
    preferences: {
      theme: 'light' | 'dark';
      notifications: boolean;
      privacy: 'public' | 'friends' | 'private';
    };
  };
  onUpdate: (user: UserProfileProps['user']) => void;
}

export const UserProfile: React.FC<UserProfileProps> = ({
  user,
  onUpdate,
}) => {
  return (
    <div
      className={`profile profile--${user.preferences.theme}`}
    >
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <div className='preferences'>
        <label>
          <input
            type='checkbox'
            checked={user.preferences.notifications}
            onChange={(e) =>
              onUpdate({
                ...user,
                preferences: {
                  ...user.preferences,
                  notifications: e.target.checked,
                },
              })
            }
          />
          通知を受け取る
        </label>
      </div>
    </div>
  );
};

Advanced Controls の設定

typescript// UserProfile.stories.ts
import type { Meta, StoryObj } from '@storybook/react';
import { UserProfile } from './UserProfile';

const meta: Meta<typeof UserProfile> = {
  title: 'Advanced/UserProfile',
  component: UserProfile,
  argTypes: {
    user: {
      control: 'object',
      description: 'ユーザー情報の複雑なオブジェクト',
    },
    'user.preferences.theme': {
      control: 'select',
      options: ['light', 'dark'],
    },
    'user.preferences.privacy': {
      control: 'select',
      options: ['public', 'friends', 'private'],
    },
  },
};

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

export const Interactive: Story = {
  args: {
    user: {
      id: '1',
      name: '田中太郎',
      email: 'tanaka@example.com',
      preferences: {
        theme: 'light',
        notifications: true,
        privacy: 'friends',
      },
    },
  },
  parameters: {
    docs: {
      description: {
        story:
          'リアルタイムでユーザーオブジェクトを編集し、即座に反映される様子を確認できます。',
      },
    },
  },
};

カスタムコントロールの作成

更に高度な活用として、独自のコントロールを作成することも可能です。

typescript// 日付範囲ピッカーのカスタムコントロール
const dateRangeControl = {
  control: {
    type: 'object',
  },
  description: '開始日と終了日を指定',
  table: {
    type: {
      summary: 'DateRange',
      detail: '{ startDate: Date, endDate: Date }',
    },
  },
};

export const AnalyticsChart: Story = {
  argTypes: {
    dateRange: dateRangeControl,
    chartType: {
      control: 'select',
      options: ['line', 'bar', 'pie'],
      description: 'チャートの種類',
    },
  },
  args: {
    dateRange: {
      startDate: new Date('2024-01-01'),
      endDate: new Date('2024-12-31'),
    },
    chartType: 'line',
  },
};

実用性の高いポイント

機能従来の方法Advanced Controls
オブジェクト編集コード変更が必要GUI で直接編集可能
デバッグ速度変更 → 保存 → 確認のサイクルリアルタイム反映
非エンジニア確認困難直感的に操作可能
状態の再現複雑URL で状態共有可能

ベストプラクティス 2:Mock Service Worker で API モックを完全再現

実際のプロダクトでは、多くのコンポーネントが API との連携を前提としています。MSW(Mock Service Worker)を活用することで、本物の API レスポンスを完全に再現できます。

MSW の基本セットアップ

bash# MSW のインストール
yarn add -D msw
yarn add -D @storybook/addon-msw-storybook
typescript// .storybook/main.ts
export default {
  addons: [
    '@storybook/addon-essentials',
    '@storybook/addon-msw-storybook', // MSW アドオンを追加
  ],
};
typescript// src/mocks/handlers.ts
import { rest } from 'msw';

export const handlers = [
  // ユーザー一覧 API のモック
  rest.get('/api/users', (req, res, ctx) => {
    const page = req.url.searchParams.get('page') || '1';
    const limit = req.url.searchParams.get('limit') || '10';

    return res(
      ctx.status(200),
      ctx.json({
        users: Array.from(
          { length: parseInt(limit) },
          (_, i) => ({
            id:
              (parseInt(page) - 1) * parseInt(limit) +
              i +
              1,
            name: `ユーザー ${
              (parseInt(page) - 1) * parseInt(limit) + i + 1
            }`,
            email: `user${
              (parseInt(page) - 1) * parseInt(limit) + i + 1
            }@example.com`,
            status: i % 3 === 0 ? 'active' : 'inactive',
          })
        ),
        total: 100,
        page: parseInt(page),
        totalPages: Math.ceil(100 / parseInt(limit)),
      })
    );
  }),

  // エラーレスポンスのモック
  rest.get('/api/users/error', (req, res, ctx) => {
    return res(
      ctx.status(500),
      ctx.json({
        error: 'Internal Server Error',
        message: 'データベース接続エラーが発生しました',
      })
    );
  }),
];

React Query との連携パターン

typescript// UserList コンポーネント(React Query 使用)
import { useQuery } from '@tanstack/react-query';

interface User {
  id: number;
  name: string;
  email: string;
  status: 'active' | 'inactive';
}

interface UserListProps {
  page?: number;
  limit?: number;
}

export const UserList: React.FC<UserListProps> = ({
  page = 1,
  limit = 10,
}) => {
  const { data, isLoading, error } = useQuery({
    queryKey: ['users', page, limit],
    queryFn: async () => {
      const response = await fetch(
        `/api/users?page=${page}&limit=${limit}`
      );
      if (!response.ok)
        throw new Error('Failed to fetch users');
      return response.json();
    },
  });

  if (isLoading)
    return <div className='loading'>読み込み中...</div>;
  if (error)
    return (
      <div className='error'>エラーが発生しました</div>
    );

  return (
    <div className='user-list'>
      <h2>ユーザー一覧</h2>
      <div className='users'>
        {data?.users.map((user: User) => (
          <div
            key={user.id}
            className={`user user--${user.status}`}
          >
            <h3>{user.name}</h3>
            <p>{user.email}</p>
            <span className='status'>{user.status}</span>
          </div>
        ))}
      </div>
      <div className='pagination'>
        {data?.page} / {data?.totalPages} ページ
      </div>
    </div>
  );
};

Storybook での MSW 活用

typescript// UserList.stories.ts
import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query';
import { UserList } from './UserList';
import { handlers } from '../mocks/handlers';

const meta: Meta<typeof UserList> = {
  title: 'API/UserList',
  component: UserList,
  decorators: [
    (Story) => {
      const queryClient = new QueryClient({
        defaultOptions: {
          queries: { retry: false },
        },
      });
      return (
        <QueryClientProvider client={queryClient}>
          <Story />
        </QueryClientProvider>
      );
    },
  ],
};

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

// 正常なレスポンスパターン
export const Default: Story = {
  parameters: {
    msw: {
      handlers: [handlers[0]], // 正常レスポンスのハンドラー
    },
  },
  args: {
    page: 1,
    limit: 5,
  },
};

// エラーレスポンスパターン
export const ErrorState: Story = {
  parameters: {
    msw: {
      handlers: [handlers[1]], // エラーレスポンスのハンドラー
    },
  },
  args: {
    page: 1,
    limit: 5,
  },
};

// 遅延レスポンスパターン
export const SlowResponse: Story = {
  parameters: {
    msw: {
      handlers: [
        rest.get('/api/users', (req, res, ctx) => {
          return res(
            ctx.delay(3000), // 3秒の遅延
            ctx.status(200),
            ctx.json({
              users: [],
              total: 0,
              page: 1,
              totalPages: 1,
            })
          );
        }),
      ],
    },
  },
};

MSW 活用の利点

  • 本物に近い動作確認: 実際の HTTP リクエストをインターセプト
  • エラーケースのテスト: 様々なエラーレスポンスを簡単に再現
  • ネットワーク遅延の再現: 遅い通信環境での動作確認
  • API 仕様の文書化: モックが API の仕様書としても機能

ベストプラクティス 3:Composition でコンポーネントの組み合わせテスト

実際のアプリケーションでは、複数のコンポーネントが組み合わさって動作します。Composition パターンを活用することで、コンポーネント間の連携を効果的にテストできます。

複合コンポーネントの設計

typescript// ショッピングカートの例
interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
  image: string;
}

interface CartContextType {
  items: CartItem[];
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  total: number;
}

const CartContext =
  React.createContext<CartContextType | null>(null);

// カートプロバイダー
export const CartProvider: React.FC<{
  children: React.ReactNode;
}> = ({ children }) => {
  const [items, setItems] = useState<CartItem[]>([]);

  const addItem = (item: Omit<CartItem, 'quantity'>) => {
    setItems((prev) => {
      const existing = prev.find((i) => i.id === item.id);
      if (existing) {
        return prev.map((i) =>
          i.id === item.id
            ? { ...i, quantity: i.quantity + 1 }
            : i
        );
      }
      return [...prev, { ...item, quantity: 1 }];
    });
  };

  const removeItem = (id: string) => {
    setItems((prev) =>
      prev.filter((item) => item.id !== id)
    );
  };

  const updateQuantity = (id: string, quantity: number) => {
    if (quantity === 0) {
      removeItem(id);
      return;
    }
    setItems((prev) =>
      prev.map((item) =>
        item.id === id ? { ...item, quantity } : item
      )
    );
  };

  const total = items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );

  return (
    <CartContext.Provider
      value={{
        items,
        addItem,
        removeItem,
        updateQuantity,
        total,
      }}
    >
      {children}
    </CartContext.Provider>
  );
};

// 商品カード
export const ProductCard: React.FC<{
  product: Omit<CartItem, 'quantity'>;
}> = ({ product }) => {
  const cart = useContext(CartContext);

  return (
    <div className='product-card'>
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p className='price'>
        ¥{product.price.toLocaleString()}
      </p>
      <button onClick={() => cart?.addItem(product)}>
        カートに追加
      </button>
    </div>
  );
};

// カート表示
export const CartSummary: React.FC = () => {
  const cart = useContext(CartContext);

  if (!cart?.items.length) {
    return <div className='cart-empty'>カートは空です</div>;
  }

  return (
    <div className='cart-summary'>
      <h3>カート内容</h3>
      {cart.items.map((item) => (
        <div key={item.id} className='cart-item'>
          <span>{item.name}</span>
          <div className='quantity-controls'>
            <button
              onClick={() =>
                cart.updateQuantity(
                  item.id,
                  item.quantity - 1
                )
              }
            >
              -
            </button>
            <span>{item.quantity}</span>
            <button
              onClick={() =>
                cart.updateQuantity(
                  item.id,
                  item.quantity + 1
                )
              }
            >
              +
            </button>
          </div>
          <span>
            ¥{(item.price * item.quantity).toLocaleString()}
          </span>
        </div>
      ))}
      <div className='total'>
        合計: ¥{cart.total.toLocaleString()}
      </div>
    </div>
  );
};

Composition ストーリーの作成

typescript// ShoppingExperience.stories.ts
import {
  CartProvider,
  ProductCard,
  CartSummary,
} from './ShoppingComponents';

const sampleProducts = [
  {
    id: '1',
    name: 'ワイヤレスヘッドフォン',
    price: 15000,
    image: '/images/headphones.jpg',
  },
  {
    id: '2',
    name: 'スマートウォッチ',
    price: 35000,
    image: '/images/smartwatch.jpg',
  },
  {
    id: '3',
    name: 'Bluetooth スピーカー',
    price: 8000,
    image: '/images/speaker.jpg',
  },
];

const meta: Meta = {
  title: 'Composition/ShoppingExperience',
  decorators: [
    (Story) => (
      <CartProvider>
        <div
          style={{
            display: 'grid',
            gridTemplateColumns: '2fr 1fr',
            gap: '24px',
          }}
        >
          <Story />
        </div>
      </CartProvider>
    ),
  ],
};

export default meta;

// 完全なショッピング体験
export const CompleteExperience: StoryObj = {
  render: () => (
    <>
      <div>
        <h2>商品一覧</h2>
        <div
          style={{
            display: 'grid',
            gridTemplateColumns:
              'repeat(auto-fit, minmax(250px, 1fr))',
            gap: '16px',
          }}
        >
          {sampleProducts.map((product) => (
            <ProductCard
              key={product.id}
              product={product}
            />
          ))}
        </div>
      </div>
      <div>
        <CartSummary />
      </div>
    </>
  ),
  parameters: {
    docs: {
      description: {
        story:
          '商品の追加からカート確認まで、完全なユーザー体験を再現',
      },
    },
  },
};

// 異なる状態での表示確認
export const PreFilledCart: StoryObj = {
  render: () => (
    <>
      <div>
        <h2>商品一覧</h2>
        <div
          style={{
            display: 'grid',
            gridTemplateColumns:
              'repeat(auto-fit, minmax(250px, 1fr))',
            gap: '16px',
          }}
        >
          {sampleProducts.map((product) => (
            <ProductCard
              key={product.id}
              product={product}
            />
          ))}
        </div>
      </div>
      <div>
        <CartSummary />
      </div>
    </>
  ),
  decorators: [
    (Story) => {
      // 初期状態でカートにアイテムを追加
      const CartWithInitialItems: React.FC<{
        children: React.ReactNode;
      }> = ({ children }) => {
        const [items, setItems] = useState<CartItem[]>([
          { ...sampleProducts[0], quantity: 2 },
          { ...sampleProducts[1], quantity: 1 },
        ]);

        // 実際のCartProviderロジックを実装...

        return <CartProvider>{children}</CartProvider>;
      };

      return (
        <CartWithInitialItems>
          <div
            style={{
              display: 'grid',
              gridTemplateColumns: '2fr 1fr',
              gap: '24px',
            }}
          >
            <Story />
          </div>
        </CartWithInitialItems>
      );
    },
  ],
};

Composition の利点

側面単体テストComposition テスト
現実性人工的な環境実際の使用状況に近い
インタラクション限定的複数コンポーネント間の連携
バグ発見コンポーネント内部統合時の問題
ユーザー体験部分的エンドツーエンド

ベストプラクティス 4:Visual Testing の自動化で回帰バグを撲滅

UI の変更は意図しない視覚的な回帰バグを引き起こしがちです。Visual Testing を自動化することで、これらの問題を効率的に検出できます。

Chromatic との連携

bash# Chromatic のセットアップ
yarn add -D chromatic
typescript// package.json にスクリプト追加
{
  "scripts": {
    "chromatic": "chromatic --project-token=YOUR_PROJECT_TOKEN",
    "chromatic:ci": "chromatic --project-token=YOUR_PROJECT_TOKEN --exit-zero-on-changes"
  }
}
typescript// .storybook/main.ts
export default {
  stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
  addons: [
    '@storybook/addon-essentials',
    {
      name: '@storybook/addon-visual-tests', // Visual Testing アドオン
      options: {
        service: 'chromatic',
      },
    },
  ],
};

レグレッションテスト対応ストーリー

typescript// Button.stories.ts - Visual Testing に最適化
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

const meta: Meta<typeof Button> = {
  title: 'Visual/Button',
  component: Button,
  parameters: {
    // 一貫したスクリーンショットのための設定
    layout: 'centered',
    backgrounds: {
      default: 'light',
      values: [
        { name: 'light', value: '#ffffff' },
        { name: 'dark', value: '#333333' },
      ],
    },
  },
};

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

// すべての状態を一度に確認
export const AllStates: Story = {
  render: () => (
    <div
      style={{
        display: 'grid',
        gridTemplateColumns: 'repeat(4, 200px)',
        gap: '16px',
        padding: '16px',
      }}
    >
      {/* 通常状態 */}
      <div style={{ textAlign: 'center' }}>
        <h4>Primary</h4>
        <Button variant='primary'>Button</Button>
      </div>

      {/* ホバー状態 */}
      <div style={{ textAlign: 'center' }}>
        <h4>Primary Hover</h4>
        <Button variant='primary' className='hover'>
          Button
        </Button>
      </div>

      {/* フォーカス状態 */}
      <div style={{ textAlign: 'center' }}>
        <h4>Primary Focus</h4>
        <Button variant='primary' className='focus'>
          Button
        </Button>
      </div>

      {/* 無効状態 */}
      <div style={{ textAlign: 'center' }}>
        <h4>Disabled</h4>
        <Button variant='primary' disabled>
          Button
        </Button>
      </div>

      {/* Secondary バリエーション */}
      <div style={{ textAlign: 'center' }}>
        <h4>Secondary</h4>
        <Button variant='secondary'>Button</Button>
      </div>

      {/* ローディング状態 */}
      <div style={{ textAlign: 'center' }}>
        <h4>Loading</h4>
        <Button variant='primary' loading>
          Button
        </Button>
      </div>

      {/* サイズバリエーション */}
      <div style={{ textAlign: 'center' }}>
        <h4>Small</h4>
        <Button variant='primary' size='small'>
          Button
        </Button>
      </div>

      <div style={{ textAlign: 'center' }}>
        <h4>Large</h4>
        <Button variant='primary' size='large'>
          Button
        </Button>
      </div>
    </div>
  ),
  parameters: {
    // Visual Testing 専用の設定
    chromatic: {
      disableSnapshot: false,
      pauseAnimationAtEnd: true,
    },
  },
};

// ダークテーマでの表示確認
export const DarkTheme: Story = {
  ...AllStates,
  parameters: {
    backgrounds: { default: 'dark' },
    chromatic: {
      disableSnapshot: false,
    },
  },
};

// レスポンシブ対応の確認
export const ResponsiveLayout: Story = {
  render: () => (
    <div>
      {/* デスクトップ */}
      <div
        style={{ width: '1200px', marginBottom: '32px' }}
      >
        <h3>Desktop (1200px)</h3>
        <div style={{ display: 'flex', gap: '12px' }}>
          <Button variant='primary'>Primary</Button>
          <Button variant='secondary'>Secondary</Button>
          <Button variant='outline'>Outline</Button>
        </div>
      </div>

      {/* タブレット */}
      <div style={{ width: '768px', marginBottom: '32px' }}>
        <h3>Tablet (768px)</h3>
        <div style={{ display: 'flex', gap: '12px' }}>
          <Button variant='primary'>Primary</Button>
          <Button variant='secondary'>Secondary</Button>
          <Button variant='outline'>Outline</Button>
        </div>
      </div>

      {/* モバイル */}
      <div style={{ width: '375px' }}>
        <h3>Mobile (375px)</h3>
        <div
          style={{
            display: 'flex',
            flexDirection: 'column',
            gap: '12px',
          }}
        >
          <Button variant='primary' fullWidth>
            Primary
          </Button>
          <Button variant='secondary' fullWidth>
            Secondary
          </Button>
          <Button variant='outline' fullWidth>
            Outline
          </Button>
        </div>
      </div>
    </div>
  ),
  parameters: {
    chromatic: {
      viewports: [1200, 768, 375],
    },
  },
};

GitHub Actions との統合

yaml# .github/workflows/chromatic.yml
name: Visual Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  chromatic:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
        with:
          fetch-depth: 0 # Chromatic に必要

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'yarn'

      - name: Install dependencies
        run: yarn install --frozen-lockfile

      - name: Run Chromatic
        uses: chromaui/action@v1
        with:
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
          token: ${{ secrets.GITHUB_TOKEN }}
          onlyChanged: true # 変更されたストーリーのみテスト
          skipUpdateCheck: true

Visual Testing の効果

従来の手動確認自動 Visual Testing
確認漏れが発生しやすい全パターンを自動チェック
時間がかかる数分で完了
主観的な判断客観的なピクセル比較
ブラウザ依存複数環境で一貫した結果

ベストプラクティス 5:CSF3.0 を活用した型安全なストーリー設計

Component Story Format 3.0(CSF3.0)の新機能を活用することで、より型安全で保守しやすいストーリーを作成できます。

オブジェクト記法から関数記法への移行

typescript// 従来のCSF2.0記法
export const OldStyle = {
  args: {
    variant: 'primary',
    children: 'Old Style Button',
  },
};

// CSF3.0の関数記法
export const NewStyle: Story = {
  args: {
    variant: 'primary',
    children: 'New Style Button',
  },
  // render関数で完全な制御
  render: (args) => {
    return (
      <div style={{ padding: '20px' }}>
        <Button {...args} />
      </div>
    );
  },
};

型安全な引数の定義

typescript// 型安全なストーリー設計
import type { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, within } from '@storybook/test';

interface FormComponentProps {
  onSubmit: (data: FormData) => void;
  initialValues?: Partial<FormData>;
  validationRules?: ValidationRules;
  isLoading?: boolean;
}

interface FormData {
  email: string;
  password: string;
  rememberMe: boolean;
}

const meta: Meta<FormComponentProps> = {
  title: 'Forms/LoginForm',
  component: LoginForm,
  parameters: {
    layout: 'centered',
  },
  // 引数の型を厳密に定義
  argTypes: {
    onSubmit: { action: 'submitted' },
    initialValues: {
      control: 'object',
      description: 'フォームの初期値',
    },
    isLoading: {
      control: 'boolean',
      description: 'ローディング状態',
    },
  } satisfies Meta<FormComponentProps>['argTypes'],
};

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

// 基本的なフォーム
export const Default: Story = {
  args: {
    onSubmit: (data) => {
      console.log('Form submitted:', data);
    },
  },
};

// バリデーションエラーの状態
export const WithValidationErrors: Story = {
  args: {
    initialValues: {
      email: 'invalid-email',
      password: '123',
    },
  },
  render: (args) => {
    const [errors, setErrors] = React.useState<
      Record<string, string>
    >({});

    const handleSubmit = (data: FormData) => {
      const newErrors: Record<string, string> = {};

      if (!data.email.includes('@')) {
        newErrors.email =
          '有効なメールアドレスを入力してください';
      }

      if (data.password.length < 6) {
        newErrors.password =
          'パスワードは6文字以上で入力してください';
      }

      setErrors(newErrors);

      if (Object.keys(newErrors).length === 0) {
        args.onSubmit?.(data);
      }
    };

    return (
      <LoginForm
        {...args}
        onSubmit={handleSubmit}
        errors={errors}
      />
    );
  },
  parameters: {
    docs: {
      description: {
        story:
          'バリデーションエラーが発生した場合の表示を確認できます。',
      },
    },
  },
};

// インタラクションテスト付きストーリー
export const InteractiveTest: Story = {
  args: {
    onSubmit: (data) => {
      console.log('Submitted:', data);
    },
  },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);

    // メールアドレス入力
    const emailInput =
      canvas.getByLabelText(/メールアドレス/i);
    await userEvent.type(emailInput, 'test@example.com');

    // パスワード入力
    const passwordInput =
      canvas.getByLabelText(/パスワード/i);
    await userEvent.type(passwordInput, 'password123');

    // チェックボックスをクリック
    const checkbox =
      canvas.getByLabelText(/ログイン状態を保持/i);
    await userEvent.click(checkbox);

    // フォーム送信
    const submitButton = canvas.getByRole('button', {
      name: /ログイン/i,
    });
    await userEvent.click(submitButton);

    // 送信されたことを確認
    await expect(args.onSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123',
      rememberMe: true,
    });
  },
};

条件付きレンダリングの型安全な実装

typescript// 複雑な条件分岐を型安全に
export const ConditionalRendering: Story = {
  args: {
    userRole: 'admin',
    permissions: ['read', 'write', 'delete'],
  },
  render: (args) => {
    const { userRole, permissions } = args;

    // 型ガードを活用
    const hasPermission = (permission: string): boolean => {
      return permissions?.includes(permission) ?? false;
    };

    if (userRole === 'admin') {
      return (
        <AdminDashboard
          permissions={permissions}
          onAction={(action) =>
            console.log('Admin action:', action)
          }
        />
      );
    }

    if (userRole === 'user' && hasPermission('read')) {
      return (
        <UserDashboard
          readOnly={!hasPermission('write')}
          onAction={(action) =>
            console.log('User action:', action)
          }
        />
      );
    }

    return <AccessDenied />;
  },
  argTypes: {
    userRole: {
      control: 'select',
      options: ['admin', 'user', 'guest'] as const,
    },
    permissions: {
      control: 'check',
      options: [
        'read',
        'write',
        'delete',
        'admin',
      ] as const,
    },
  },
};

CSF3.0 の利点

従来(CSF2.0)CSF3.0
型チェックが不完全完全な型安全性
render 関数の制約柔軟なレンダリング制御
テストとの統合が困難play 関数で完全なテスト
再利用性が低いコンポーザブルな設計

ベストプラクティス 6:Performance 測定でコンポーネント最適化

パフォーマンス問題は往々にして見落とされがちですが、Storybook でパフォーマンス測定を組み込むことで、問題を早期に発見できます。

React DevTools Profiler との連携

typescript// Performance測定用のWrapper
import { Profiler, ProfilerOnRenderCallback } from 'react';

const PerformanceWrapper: React.FC<{
  children: React.ReactNode;
  id: string;
  onRender?: ProfilerOnRenderCallback;
}> = ({ children, id, onRender }) => {
  const defaultOnRender: ProfilerOnRenderCallback = (
    id,
    phase,
    actualDuration,
    baseDuration,
    startTime,
    commitTime
  ) => {
    console.group(`🔍 Performance Report: ${id}`);
    console.log('Phase:', phase);
    console.log(
      'Actual Duration:',
      `${actualDuration.toFixed(2)}ms`
    );
    console.log(
      'Base Duration:',
      `${baseDuration.toFixed(2)}ms`
    );
    console.log('Start Time:', `${startTime.toFixed(2)}ms`);
    console.log(
      'Commit Time:',
      `${commitTime.toFixed(2)}ms`
    );
    console.groupEnd();
  };

  return (
    <Profiler
      id={id}
      onRender={onRender || defaultOnRender}
    >
      {children}
    </Profiler>
  );
};

パフォーマンステスト用ストーリー

typescript// PerformanceTest.stories.ts
export const LargeDatasetComparison: Story = {
  render: () => {
    const [dataSize, setDataSize] = useState(100);
    const data = generateLargeDataset(dataSize);

    return (
      <div>
        <div style={{ marginBottom: '16px' }}>
          <label>
            データサイズ: {dataSize}件
            <input
              type='range'
              min='100'
              max='10000'
              step='100'
              value={dataSize}
              onChange={(e) =>
                setDataSize(parseInt(e.target.value))
              }
            />
          </label>
        </div>

        <PerformanceWrapper id='DataTable'>
          <DataTable data={data} />
        </PerformanceWrapper>
      </div>
    );
  },
  parameters: {
    docs: {
      description: {
        story:
          'データサイズを変更してパフォーマンスの変化を測定できます。',
      },
    },
  },
};

ベストプラクティス 7:Custom Addon 開発でチーム独自の機能追加

チーム固有のニーズに対応するため、カスタムアドオンを開発することで Storybook の機能を拡張できます。

デザイントークン表示アドオン

typescript// addon/Panel.tsx
import React from 'react';
import {
  useGlobals,
  useStorybookApi,
} from '@storybook/api';
import { AddonPanel, Button } from '@storybook/components';

export const Panel: React.FC = () => {
  const api = useStorybookApi();
  const [globals] = useGlobals();

  const designTokens = [
    {
      name: 'primary-blue',
      value: '#3b82f6',
      type: 'color',
    },
    { name: 'spacing-md', value: '16px', type: 'spacing' },
    {
      name: 'font-body',
      value: '16px/1.5 sans-serif',
      type: 'typography',
    },
  ];

  const copyToClipboard = (value: string) => {
    navigator.clipboard.writeText(value);
    api.addNotification({
      id: 'token-copied',
      type: 'success',
      content: `コピーしました: ${value}`,
    });
  };

  return (
    <AddonPanel>
      <div style={{ padding: '16px' }}>
        <h3>デザイントークン</h3>
        {designTokens.map((token) => (
          <div
            key={token.name}
            style={{
              display: 'flex',
              alignItems: 'center',
              gap: '12px',
              padding: '8px',
              borderBottom: '1px solid #e5e7eb',
            }}
          >
            <div
              style={{
                width: '40px',
                height: '40px',
                backgroundColor:
                  token.type === 'color'
                    ? token.value
                    : '#e5e7eb',
                borderRadius: '4px',
              }}
            />
            <div style={{ flex: 1 }}>
              <div style={{ fontWeight: '500' }}>
                {token.name}
              </div>
              <div
                style={{
                  fontSize: '12px',
                  fontFamily: 'monospace',
                }}
              >
                {token.value}
              </div>
            </div>
            <Button
              size='small'
              onClick={() => copyToClipboard(token.value)}
            >
              コピー
            </Button>
          </div>
        ))}
      </div>
    </AddonPanel>
  );
};

ベストプラクティス 8:Figma 連携でデザイナーとの完璧な同期

デザインツールとの連携により、デザイナーとエンジニアの間の認識齟齬を最小限に抑えることができます。

Figma Embed アドオンの活用

bashyarn add -D storybook-addon-designs
typescript// Button.stories.ts - Figmaデザインとの連携
export const WithFigmaDesign: Story = {
  args: {
    variant: 'primary',
    children: 'Figma Design Button',
  },
  parameters: {
    design: {
      type: 'figma',
      url: 'https://www.figma.com/file/ABC123/Design-System?node-id=123%3A456',
    },
    docs: {
      description: {
        story: 'Figmaデザインと実装の比較ができます。',
      },
    },
  },
};

デザインシステム同期の自動化

typescript// scripts/sync-figma-tokens.ts
async function syncDesignTokens() {
  try {
    // Figma APIからデザイントークンを取得
    const figmaTokens = await figmaApi.getDesignTokens();

    // CSS変数として出力
    const cssVars = figmaTokens
      .map((token) => `  --${token.name}: ${token.value};`)
      .join('\n');

    const cssContent = `:root {\n${cssVars}\n}`;
    await fs.writeFile(
      './src/tokens/figma-tokens.css',
      cssContent
    );

    console.log('✅ Figmaトークンの同期が完了しました');
  } catch (error) {
    console.error('❌ 同期エラー:', error);
  }
}

ベストプラクティス 9:CI/CD パイプラインでの自動デプロイ最適化

継続的な開発において、Storybook の自動デプロイとテストを効率化することが重要です。

GitHub Actions でのデプロイパイプライン

yaml# .github/workflows/storybook.yml
name: Build and Deploy Storybook

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'yarn'

      - name: Install dependencies
        run: yarn install --frozen-lockfile

      - name: Build Storybook
        run: yarn build-storybook --quiet

      - name: Test Storybook
        run: |
          npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
            "npx http-server storybook-static --port 6006 --silent" \
            "npx wait-on http://localhost:6006 && yarn test-storybook"

      - name: Run Visual Tests
        if: github.event_name == 'push'
        run: yarn chromatic --project-token=${{ secrets.CHROMATIC_PROJECT_TOKEN }}

      - name: Deploy to GitHub Pages
        if: github.ref == 'refs/heads/main'
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./storybook-static

ベストプラクティス 10:大規模プロジェクトでのモノレポ運用術

複数のプロジェクトを抱える組織では、モノレポでの効率的な Storybook 運用が重要になります。

統合 Storybook の構築

typescript// apps/storybook-hub/.storybook/main.ts
export default {
  stories: [
    '../../../libs/design-system/src/**/*.stories.@(js|jsx|ts|tsx)',
    '../../../libs/app-components/src/**/*.stories.@(js|jsx|ts|tsx)',
    '../../../apps/web-app/src/**/*.stories.@(js|jsx|ts|tsx)',
  ],
  addons: ['@storybook/addon-essentials'],
};

共通設定の管理

typescript// libs/storybook-config/src/preview.ts
export const commonDecorators = [
  (Story) => (
    <div style={{ margin: '3em' }}>
      <Story />
    </div>
  ),
];

export const commonParameters = {
  backgrounds: {
    default: 'light',
    values: [
      { name: 'light', value: '#ffffff' },
      { name: 'dark', value: '#333333' },
    ],
  },
};

モノレポ運用の利点

従来の分離型モノレポ
依存関係の同期が困難一元的な依存関係管理
重複したコンポーネント共通コンポーネントの再利用
バージョン管理が複雑統一されたバージョン管理
CI/CD パイプラインが分散効率的な一括ビルド

まとめ

今回ご紹介した 10 のベストプラクティスは、いずれも実際の開発現場で培われた実践的なテクニックです。これらを活用することで、Storybook が単なる「コンポーネントカタログ」を超えた、開発チーム全体の生産性を向上させる強力なツールへと進化します。

実践のポイント

段階的な導入がカギ すべてのベストプラクティスを一度に導入する必要はありません。チームの状況と課題に応じて、優先度の高いものから段階的に取り入れていくことが成功の秘訣です。

導入優先度ベストプラクティス効果
Advanced Controls、MSW、Visual Testing即座に開発効率向上
CSF3.0、Performance 測定、Figma 連携品質と連携の改善
Custom Addon、CI/CD 最適化、モノレポ大規模運用での威力発揮

チーム全体での合意形成 技術的な導入だけでなく、デザイナー、プロジェクトマネージャーを含めたチーム全体での理解と合意が重要です。特に Figma 連携や Visual Testing は、非エンジニアメンバーにとっても大きなメリットがあります。

継続的な改善 これらのプラクティスは「導入して終わり」ではありません。チームの成長とプロジェクトの変化に合わせて、継続的に改善し続けることが重要です。

効果的な活用のための Tips

  1. 小さく始める: まずは最も頻繁に使うコンポーネントから高度な機能を適用
  2. 測定する: パフォーマンスや効率性の変化を数値で把握
  3. 共有する: チーム内でベストプラクティスの知見を積極的に共有
  4. 文書化する: 独自のルールやカスタマイズ内容を必ず文書化

最後に

現代の Web 開発において、Storybook は単なるツールを超えた「開発文化」の一部となっています。これらのベストプラクティスを通じて、あなたのチームも効率的で楽しい開発体験を実現できることでしょう。

特に印象的なのは、これらの高度な機能により、エンジニアだけでなく、デザイナーやプロジェクトマネージャーまでもが開発プロセスに積極的に参加できるようになることです。これは、チーム全体の創造性と生産性を大幅に向上させる革新的な変化と言えるでしょう。

ぜひ今日から、一つずつでも実践してみてください。きっと、開発の質とスピードの両方で大きな改善を実感していただけるはずです。

関連リンク