React + Vite のプロジェクトに Ladle を組み込んでみた

Ladle を使って、UI カタログの表示、ビジュアルリグレッションテスト、API のモックをやってみました!
2023.12.15

はじめに

React + Vite のプロジェクトに Ladle を組み込んでみました。
Ladle は、React の UI カタログを作成するためのツールになります。
Storybook に比べ、パフォーマンスや導入コストなどを重視したツールになっています。

詳細については以下をご確認ください。

React + Vite + Tailwind CSS のプロジェクトを使用します。 環境構築は以下の記事を参考にしてください。

環境

"react": "^18.2.0",
"vite": "^5.0.0"
"tailwindcss": "^3.3.6",
"@ladle/react": "^4.0.2",
"@playwright/test": "^1.40.1",
"sync-fetch": "^0.5.2",
"swr": "^2.2.4"

Ladle 環境構築

必要なパッケージをインストールします。

npm install -D @ladle/react

Ladle の設定を行います。

tsconfig.json

{
  "compilerOptions": {
    "jsx": "react-jsx"
  },
  "include": ["src", ".ladle"]
}

package.json

"scripts": {
  "dev:ladle": "npx ladle serve"
}

これだけで最低限の環境構築は完了です。
動作確認を行いましょう!
簡単なボタンコンポーネントを作成します。

src/components/button.tsx

export const Button = () => {
  return <button>クリック</button>;
};

作成したボタンコンポーネントを表示するために、ストーリーを作成する必要があります。

src/components/button.stories.tsx

import type { StoryDefault, Story } from "@ladle/react";
import { Button } from "./button";

export default {
  title: "Components / Button",
} satisfies StoryDefault;

export const DefaultButton: Story = () => <Button />;

Ladle を起動します。

npm run dev:ladle

Tailwind CSS の設定

Ladle で Tailwind CSS を使用するために、設定を行います。
Tailwind CSS 以外を設定したい場合は、下記を参考にしてください。

src/index.css

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

.ladle/components.tsxを作成し、Tailwind CSS を読み込みます。

.ladle/components.tsx

import "../src/index.css";

以上で設定は完了です。動作確認を行います。

npm install clsx

src/components/button.tsx

import clsx from "clsx";
import { FC, useMemo } from "react";

export interface ButtonProps {
  children: React.ReactNode;
  variant?: "contained" | "outlined" | "text";
}
export const Button: FC<ButtonProps> = ({
  children,
  variant = "contained",
}) => {
  const baseClass = "rounded-md px-4 py-2";
  const variantClass = useMemo(() => {
    switch (variant) {
      case "contained":
        return "bg-blue-500 text-white hover:bg-blue-600";
      case "outlined":
        return "border border-blue-500 text-blue-500 hover:bg-blue-500 hover:text-white";
      case "text":
        return "text-blue-500 hover:bg-blue-100";
    }
  }, [variant]);

  return <button className={clsx(baseClass, variantClass)}>{children}</button>;
};

src/components/button.stories.tsx

import type { StoryDefault, Story } from "@ladle/react";
import { Button } from "./button";

export default {
  title: "Components / Button",
} satisfies StoryDefault;

export const Contained: Story = () => (
  <Button variant="contained">
    <p>クリック</p>
  </Button>
);
export const Outlined: Story = () => (
  <Button variant="outlined">
    <p>クリック</p>
  </Button>
);
export const Text: Story = () => (
  <Button variant="text">
    <p>クリック</p>
  </Button>
);

引数の設定

Ladle では、ストーリーに引数を設定することができます。
先ほどのコードを変更してvariantを引数に出してみましょう!

src/components/button.stories.tsx

import type { StoryDefault, Story } from "@ladle/react";
import { Button } from "./button";

interface ButtonProps {
  variant: "contained" | "outlined" | "text";
}

export default {
  title: "Components / Button",
} satisfies StoryDefault;

export const Contained: Story<ButtonProps> = ({ variant }) => (
  <Button variant={variant}>クリック</Button>
);
Contained.args = {
  variant: "contained",
};

export const Outlined: Story<ButtonProps> = ({ variant }) => (
  <Button variant={variant}>クリック</Button>
);
Outlined.args = {
  variant: "outlined",
};

export const Text: Story<ButtonProps> = ({ variant }) => (
  <Button variant={variant}>クリック</Button>
);
Text.args = {
  variant: "text",
};

Story.bindを使用して、テンプレートから派生したストーリーを作成し、コードの共通化をすることもできます!

src/components/button.stories.tsx

import type { StoryDefault, Story } from "@ladle/react";
import { Button } from "./button";

interface ButtonProps {
  variant: "contained" | "outlined" | "text";
}

export default {
  title: "Components / Button",
} satisfies StoryDefault;

const Template: Story<ButtonProps> = ({ variant }) => (
  <Button variant={variant}>クリック</Button>
);

export const Contained = Template.bind({});
Contained.args = {
  variant: "contained",
};

export const Outlined = Template.bind({});
Outlined.args = {
  variant: "outlined",
};

export const Text = Template.bind({});
Text.args = {
  variant: "text",
};

ビジュアルリグレッションテスト

UI カタログを使用していると、欲しくなってくるのがビジュアルリグレッションテストだと思います。
ビジュアルリグレッションテストとは、コンポーネントの見た目が変わっていないか画像を用いて確認するテストです。
Ladle とPlaywrightを組み合わせることで実現することができます。
ビジュアルリグレッションテストをやってみましょう!

npm install -D @playwright/test sync-fetch @types/sync-fetch
npx playwright install

playwright.config.ts

export default {
  webServer: {
    command: "npm run dev:ladle",
    url: "http://localhost:61000",
    reuseExistingServer: true,
  },
};

package.json

"scripts": {
  "ladle:test": "playwright test",
  "ladle:test:update": "playwright test -u"
},

tests/snapshot.spec.ts

import { test, expect } from "@playwright/test";
import fetch from "sync-fetch";

const url = "http://localhost:61000";
const stories = fetch(`${url}/meta.json`).json().stories;

Object.keys(stories).forEach((storyKey) => {
  test(`${storyKey} - compare snapshots`, async ({ page }) => {
    await page.goto(`${url}/?story=${storyKey}&mode=preview`);
    await page.waitForSelector("[data-storyloaded]");
    await expect(page).toHaveScreenshot(`${storyKey}.png`);
  });
});

以上でビジュアルリグレッションテストの環境構築は完了です。
実際に動作させてみましょう!

npm run ladle:test

最初の実行は以下のようなエラーが出ると思います。 原因は比較用の画像がないためです。

もう一度実行してみましょう。 次は成功して以下のようになると思います。

次に、コンポーネントの見た目を変更してみましょう。
Containedコンポーネントのvariantoutlinedに変更します。

src/components/button.stories.tsx

export const Contained = Template.bind({});
Contained.args = {
  variant: "outlined",
};

実行すると、以下のようにエラーが出ると思います。
また、プロジェクト直下にtest-resultsディレクトリが作成されており、 その中に変更前、変更後、差分の画像が保存されていると思います。
この画像を見ることで、どのような変更があったのかを確認することができます。

コンポーネントの見た目を変更した場合、その差分が意図したものであると確認できたら、
npm run ladle:test:updateを実行して、比較用の画像を更新します。

MSW

Ladle では、MSWを使用して API をモックすることができます。
モックを使用することでサーバーサイドを待たずに開発することができ、 またAPI のレスポンスを変更することで、レスポンスごとの表示を確認することができます。
API のモックをやってみましょう!

ladle で MSW を有効にします。

.ladle/config.mjs

/** @type {import('@ladle/react').UserConfig} */
export default {
  addons: {
    msw: {
      enabled: true,
    },
  },
};

これだけで設定は完了です。
動作確認を行います。

npm install swr

src/config.ts

export const apiUrl = "https://example.com/api";

src/pages/users.tsx

import { FC, useMemo } from "react";
import { apiUrl } from "../config";
import useSWR, { Fetcher } from "swr";

interface User {
  id: number;
  name: string;
  email: string;
}

const UsersPage: FC = () => {
  const fetcher: Fetcher<User[], string> = (url: string) =>
    fetch(url).then((r) => r.json());
  const { data: users, error } = useSWR(`${apiUrl}/users`, fetcher);

  const userList = useMemo(() => {
    if (error) return <div>データの取得に失敗しました。</div>;
    if (!users) return <div>Loading...</div>;
    return (
      <>
        {users.map((user) => (
          <li key={user.id}>
            <p>
              {user.name} / {user.email}
            </p>
          </li>
        ))}
      </>
    );
  }, [users, error]);

  return (
    <div>
      <h1 className="text-2xl">利用者一覧</h1>
      <ul className="m-4 list-disc">{userList}</ul>
    </div>
  );
};
export default UsersPage;

src/pages/users.stories.tsx

import { useSWRConfig } from "swr";
import type { StoryDefault, Story } from "@ladle/react";
import { msw } from "@ladle/react";
import { apiUrl } from "../config";
import UsersPage from "./users";

export default {
  title: "Pages / UsersPage",
} satisfies StoryDefault;

const Template: Story = () => {
  // mutateを使用して、SWR のキャッシュを更新させる	 
  const { mutate } = useSWRConfig();	 
  mutate(`${apiUrl}/users`);
  return <UsersPage />;
};

export const Default = Template.bind({});
Default.msw = [
  msw.http.get(`${apiUrl}/users`, () => {
    return msw.HttpResponse.json([
      { id: 1, name: "John", email: "john@gmail.com" },
      { id: 2, name: "Bob", email: "bob@gmail.com" },
      { id: 3, name: "Alice", email: "alice@gmail.com" },
    ]);
  }),
];

export const Loading = Template.bind({});
Loading.msw = [
  msw.http.get(`${apiUrl}/users`, () => {
    return msw.HttpResponse.json(null);
  }),
];

export const Error = Template.bind({});
Error.msw = [
  msw.http.get(`${apiUrl}/users`, () => {
    return new msw.HttpResponse("Internal Server Error", { status: 500 });
  }),
];

さいごに

React + Vite のプロジェクトに Ladle を組み込んでみました。
Ladle を使用することで、開発効率を上げることができると思います。
ぜひ、Ladle を使用してみてください!