UI開発を効率化!Storybookでコンポーネントの「見える化」&管理をラクに!

こんにちは。
株式会社アドグローブ ソリューション第一事業部の清水です!

突然ですがフロントエンド開発で、こんなお悩みありませんか?

「コンポーネントが増えて、どんなコンポーネントがあるか把握できない...😥」

そんな悩みを解決してくれるのが Storybook です!
特に、フロントエンド開発でコンポーネント管理に課題を感じている方におすすめです。

この記事では、Storybookの導入方法やメリットを紹介します!

Storybookとは?

Storybookは、ReactやVueなどのフレームワークで使える UIコンポーネント管理ツール です!

Storybookを使えば、UIコンポーネントを単体で表示・動作確認できます。
また、コンポーネントごとにテストやドキュメントを管理でき、開発チーム内での共有がスムーズになります。

Storybookを導入するメリット

Storybookを導入すると、以下のようなメリットがあります。

  • コンポーネントの動作確認が簡単になり、デバッグ効率が向上する
  • デザイナーやPMもUIを直接確認でき、フィードバックがスムーズになる
  • コンポーネントのテスト・仕様を一元管理でき、チーム開発がスムーズになる

それでは、導入方法を見ていきましょう。

導入方法

公式ドキュメントに従ってインストールしていきます。

npx storybook@latest init

※対応フレームワークやバージョン要件については、公式ドキュメントをご確認ください。

起動

npm run storybookでStorybookを起動できます。

http://localhost:6006/にアクセスすると、Storybookにデフォルトで用意されているサンプルのコンポーネント が表示されます。

Storybook起動直後

コンポーネントに渡す引数に応じてボタンのサイズや色を指定できる、Buttonのコンポーネント

//stories\Button.tsx
export interface ButtonProps {
  /** Is this the principal call to action on the page? */
  primary?: boolean;
  /** What background color to use */
  backgroundColor?: string;
  /** How large should the button be? */
  size?: 'small' | 'medium' | 'large';
  /** Button contents */
  label: string;
  /** Optional click handler */
  onClick?: () => void;
}

export const Button = ({
  primary = false,
  size = 'medium',
  backgroundColor,
  label,
  ...props
}: ButtonProps) => {
  return (コンポーネントの中身);
};

ButtonコンポーネントのStoryファイル
コンポーネントに渡す引数によって、変わる見た目の変化を各Storyとして定義しています。

//stories\Button.stories.ts
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';

import { Button } from './Button';

const meta = {
  title: 'Example/Button',  //Storybook上のサイドバーに表示される階層
  component: Button, // 対象のコンポーネント
  parameters: {
    layout: 'centered', // Storybook上でのコンポーネントの表示位置
  },
  tags: ['autodocs'], //Docs(ドキュメント)を表示
  argTypes: {
    backgroundColor: { control: 'color' }, //backgroundColor(string)プロパティをカラーピッカーで入力できるようにする
  },
  args: { onClick: fn() },
} satisfies Meta<typeof Button>;

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

// Buttonコンポーネントの各Storyを定義
export const Primary: Story = {
  args: {
    primary: true,
    label: 'Button',
  },
};

export const Secondary: Story = {
  args: {
    label: 'Button',
  },
};
...他のストーリー

Storybookにコンポーネントを追加する

GUIで簡単に追加できるようになったので、今回は GUIでの追加方法 を紹介します!

追加する投稿フォームのコンポーネント

import React, { useState } from "react";
import { TextField, Button, Box } from "@mui/material";

type PostFormProps = {
  onAddPost: (message: string) => void;
};

/**
 * 投稿フォームのコンポーネント
 */
export const PostForm = ({ onAddPost }: PostFormProps) => {
  const [message, setMessage] = useState("");
  const maxLength = 150;

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setMessage(e.target.value);
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (message.trim() && message.length <= maxLength) {
      onAddPost(message.trim());
      setMessage("");
    }
  };

  return (
    <Box
      component="form"
      onSubmit={handleSubmit}
      sx={{
        display: "flex",
        flexDirection: "column",
        gap: 2,
        marginBottom: 2,
      }}
    >
      <TextField
        label="投稿内容"
        value={message}
        onChange={handleChange}
        variant="outlined"
        multiline
        minRows={3}
        fullWidth
        required
        helperText={
          message.length > maxLength
            ? "150文字以内で入力してください"
            : `${message.length}/${maxLength}`
        }
        error={message.length > maxLength}
      />
      <Button
        type="submit"
        variant="contained"
        color="primary"
        size="large"
        fullWidth
        disabled={!message.trim() || message.length > maxLength}
      >
        投稿
      </Button>
    </Box>
  );
};

今回は、features/内にコンポーネントを追加するため、Storybookの設定ファイルにパスを指定します。

//storybook\main.ts
import type { StorybookConfig } from "@storybook/nextjs";

const config: StorybookConfig = {
  stories: [
    "../stories/**/*.mdx",
    "../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)",
    "../features/**/*.stories.@(js|jsx|mjs|ts|tsx)", //今回追加するディレクトリ
  ],
...
};
export default config;

Storybookを起動すると、画面左上に「+」ボタンがあります。

「+」ボタンを押すと、コンポーネント選択のモーダルが表示されるので、 Storybookに追加したいコンポーネントを選択。

少し待つと...

追加後のStorybook

Storybook用のファイルが自動生成され、コンポーネントが追加されます👏

生成されたファイル : features\Demo\PostForm.stories.tsx

import type { Meta, StoryObj } from '@storybook/react';

import { PostForm } from './PostForm';

const meta = {
  component: PostForm,
} satisfies Meta<typeof PostForm>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {};

インタラクションテストを導入する

インタラクションテストとは、ボタンのクリックやテキスト入力などのユーザー操作を自動でシミュレーションできるテストです。
Storybookでは play関数を使うことで、ユーザーの操作を再現しながらコンポーネントの動作を確認し、期待通りに動作するかを検証できます。

今回は以下の2パターンを追加

  • Default:テキスト入力 → 「投稿」ボタン押す一連の流れ
  • 文字数上限:文字数が超過した時のボタン挙動
import type { Meta, StoryObj } from "@storybook/react";
import { PostForm } from "./PostForm";
import { expect, fn, userEvent, within } from "@storybook/test";

const meta = {
  component: PostForm,
  args: {
    onAddPost: fn(), // 関数をモック化
  },
} satisfies Meta<typeof PostForm>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
  play: async ({ canvasElement, args, step }) => {
    const canvas = within(canvasElement);
    const messageInput = canvas.getByRole("textbox", { name: "投稿内容" });
    const submitButton = canvas.getByRole("button", { name: "投稿" });
    await step("メッセージを入力", async () => {
      await userEvent.type(messageInput, "テストメッセージ", { delay: 50 });
    });
    await step("投稿ボタンを押下", async () => {
      await userEvent.click(submitButton);
      expect(args.onAddPost).toHaveBeenCalledWith("テストメッセージ"); // onAddPostが呼ばれたことを確認
      expect(messageInput).toHaveValue(""); // メッセージがクリアされていることを確認
    });
  },
};

export const 文字数上限: Story = {
  play: async ({ canvasElement, step }) => {
    const canvas = within(canvasElement);
    const messageInput = canvas.getByRole("textbox", {
      name: "投稿内容",
    });
    const submitButton = canvas.getByRole("button", { name: "投稿" });
    await step("文字数上限を超えた場合", async () => {
      messageInput.focus();
      await userEvent.paste("えんだ" + "あ".repeat(147));
    });
    await step(
      "エラーメッセージが表示され、ボタンが無効になっていること",
      async () => {
        expect(submitButton).toBeDisabled(); // 投稿ボタンが無効化されていることを確認
        const errorMessage = canvas.getByText("150文字以内で入力してください");
        expect(errorMessage).toBeInTheDocument(); // エラーメッセージが表示されることを確認
      }
    );
  },
};

実際の画面

ドキュメントページを作成する

Storybookにtags: ["autodocs"]を追加するだけで、Docsページが自動で作成できます!
JSDocで補足なども書いておけば、その内容もDocsページに反映されるため、手間なくコンポーネントの仕様をドキュメント化できます。

/**
 * ## 投稿フォームコンポーネント
 * メッセージの入力欄と投稿ボタンが表示される
 *
 * - 未入力の場合は「投稿」ボタンは非活性
 * - 150文字以上の場合は「投稿」ボタンは非活性
 */
const meta = {
  component: PostForm,
  args: {
    onAddPost: fn(),
  },
  tags: ["autodocs"], // ドキュメントページを追加
} satisfies Meta<typeof PostForm>;

export default meta;

type Story = StoryObj<typeof meta>;

/**
 * 初期表示
 */
export const Default: Story = {};

コンポーネント内のJSDocもDocsページに反映されます

type PostFormProps = {
  /**
   * 投稿ボタン押下時のコールバック関数
   */
  onAddPost: (message: string) => void;
};

このように、StorybookのDocsページを活用すれば、 ドキュメント作成の手間を減らしながら、コンポーネントのStoryと仕様を一緒に管理することできます!

参考Automatic documentation and Storybook

Storybookを使って感じたメリット

Storybookを使ってみて、特に良かった点を紹介します。

  • コンポーネント単体の状態をすぐに確認できる
    Storybookを見れば自分が実装していないコンポーネントの仕様も把握しやすいため、キャッチアップやコンポーネントの再利用の判断などが進めやすくなりました。

  • インタラクションテストでテスト内容を把握しやすい
    ブラウザ上でユーザー操作をシミュレーションできるため、「どんな操作をテストしているのか?」が視覚的に分かりやすい。 (ステップ毎にUIの挙動を確認できるのも便利!)

まとめ

今回は基本的な導入方法を紹介しましたが、他にもCIに組み込んでテストを自動化したり、
ビジュアルテストアクセシビリティテストもできたりと、さらに便利に活用することも可能です。
開発効率をさらに高めたい方は、ぜひ試してみてください!

この記事が、Storybook導入の参考になれば幸いです!
最後までお読みいただき、ありがとうございました! 😊


アドグローブでは、さまざまなポジションで一緒に働く仲間を募集しています!
詳細については下記からご確認ください。みなさまからのご応募お待ちしております。

採用情報