【個人学習】React Hook Form × Zod × Storybook × Jest × React Testing Library

## はじめに

React、Next.js、TypeScriptを触れる中で、自身がより**品質管理**の面を強化したいと思い、導入するところから自己学習で勉強しました。
使用した主な技術は以下です。

- React
- Next.js
- TypeScript
- フォームのライブラリ: React Hook Form
- バリデーション: Zod
- UIカタログ: Storybook
- テスト: React Testing Library + Jest
- スタイリング: Tailwind CSS

## なぜこの構成にしたのか？

フォーム開発では、「正しくバリデーションされるか」「エラー表示が適切か」など、細かな品質担保が重要になります。

特に今回の目標は、

- バリデーションロジックを型安全に管理したい
- UIの見た目とエラーパターンを整理しておきたい
- テストでフォームの振る舞いを保証したい

という理由から、この技術スタックを採用しました。

## 各ツールの役割と実装ポイント

### React Hook Form

- 軽量で、フォーム管理の負担が少ない。
- バリデーションもresolverを使うだけで外部ライブラリと簡単に連携できる。

```tsx
const { register, handleSubmit, formState: { errors } } = useForm<RegisterFormInputs>({
  resolver: zodResolver(registerSchema),
});
```

### Zod

- TypeScriptとの親和性が非常に高い。
- スキーマと型を一元管理できるので、バリデーションルールの漏れを防止できる。

```tsx
export const registerSchema = z
  .object({
    name: z.string().min(2, "名前は2文字以上で入力してください"),
    email: z.string().email("有効なメールアドレスを入力してください"),
    password: z.string().min(6, "6文字以上で入力してください"),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "パスワードが一致しません",
    path: ["confirmPassword"],
  });

export type RegisterFormInputs = z.infer<typeof registerSchema>;
```

### Storybook

- コンポーネント単位でUI・エラー表示パターンを確認できる。
- 異常系（エラー表示時）も簡単に切り替えて確認できるので、デザイナーや他メンバーとのレビューがスムーズになった。

![スクリーンショット 2025-04-29 21.37.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3288047/43b9046e-2e31-4bc9-b896-eb68b99e9b92.png)

## Testing Library

- ユーザー視点で、フォーム操作〜エラー表示までの挙動をテストできる。

```tsx
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { RegisterForm } from "@/app/components/RegisterForm";
import * as api from "@/app/lib/api";

jest.mock("@/app/lib/api", () => ({
  sendRegisterRequest: jest.fn(),
}));

describe("RegisterForm", () => {
  it("renders all input fields", () => {
    render(<RegisterForm />);
    expect(screen.getByLabelText("名前")).toBeInTheDocument();
    expect(screen.getByLabelText("メールアドレス")).toBeInTheDocument();
    expect(screen.getByLabelText("パスワード")).toBeInTheDocument();
    expect(screen.getByLabelText("パスワード確認")).toBeInTheDocument();
  });

  it("shows validation errors on submit without input", async () => {
    render(<RegisterForm />);
    fireEvent.click(screen.getByText("登録"));

    await waitFor(() => {
      expect(
        screen.getByText("名前は2文字以上で入力してください")
      ).toBeInTheDocument();
      expect(
        screen.getByText("有効なメールアドレスを入力してください")
      ).toBeInTheDocument();
      expect(
        screen.getByText("6文字以上で入力してください")
      ).toBeInTheDocument();
    });
  });

  it("submits form when valid data is entered", async () => {
    const mockSendRegister = jest.spyOn(api, "sendRegisterRequest");
    // .mockResolvedValue({ success: true });

    render(<RegisterForm />);
    fireEvent.input(screen.getByLabelText("名前"), {
      target: { value: "太郎" },
    });
    fireEvent.input(screen.getByLabelText("メールアドレス"), {
      target: { value: "taro@example.com" },
    });
    fireEvent.input(screen.getByLabelText("パスワード"), {
      target: { value: "password123" },
    });
    fireEvent.input(screen.getByLabelText("パスワード確認"), {
      target: { value: "password123" },
    });

    fireEvent.click(screen.getByText("登録"));

    await waitFor(() => {
      expect(mockSendRegister).toHaveBeenCalledWith({
        name: "太郎",
        email: "taro@example.com",
        password: "password123",
        confirmPassword: "password123",
      });
    });
  });
});
```

## 工夫したこと

### READMEに設計方針やディレクトリ構成を明記

- ディレクトリ構成（例：src/components/, src/lib/, src/schemas/ など）

- 技術選定理由（React Hook Form / Zod を選んだ背景）

- 各コンポーネントの責務と依存関係

- データフローやバリデーションの設計方針


### コンポーネントを分割し、責務を明確にする

フォームに必要なUIパーツ（例：TextInput, FormError）を共通コンポーネントとして分割しました。

```tsx
type TextInputProps = {
  label?: string;
  name: string;
} & React.InputHTMLAttributes<HTMLInputElement>;

export const TextInput = ({ label, name, ...props }: TextInputProps) => (
  <div>
    {label && (
      <label htmlFor={name} className="text-sm font-semibold">
        {label}
      </label>
    )}
    <input id={name} name={name} {...props} className="border p-2" />
  </div>
);
```

- TextInputは純粋にUIだけを担当

- エラー表示はFormErrorに分離

- バリデーションロジックはzodとregisterSchemaに集約

このように責務ごとにコンポーネントを分割することで、StorybookでのUI確認や単体テストがしやすくなり、保守性・再利用性が大幅に向上しました。

### おわりに

ここまで読んでいただきありがとうございました！
今後はUIコンポーネントを作る際も、見た目だけでなく「なぜこう設計するのか？」を意識しながら、より良いアーキテクチャと設計判断ができるフロントエンドエンジニアを目指していきたいと思います。