🦁

型安全なフォームをReact+Zod+React Hook Formで構築し、テスト・Storybookまで品質管理

に公開

はじめに

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を使うだけで外部ライブラリと簡単に連携できる。
const { register, handleSubmit, formState: { errors } } = useForm<RegisterFormInputs>({
  resolver: zodResolver(registerSchema),
});

Zod

  • TypeScriptとの親和性が非常に高い。
  • スキーマと型を一元管理できるので、バリデーションルールの漏れを防止できる。
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・エラー表示パターンを確認できる。
  • 異常系(エラー表示時)も Storybook 上で簡単に確認できるため、デザイナーや他メンバーとの仕様レビューやUI確認がスムーズに。

スクリーンショット 2025-04-29 21.37.56.png

Testing Library

  • ユーザー視点で、フォーム操作〜エラー表示までの挙動をテストできる。
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)を責務ごとに切り出して共通化することで、Storybookでの単体確認やテストがしやすくなりました。

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の見た目だけでなく、「なぜこの設計にするのか?」という意図を持ったアーキテクチャ設計を意識しながら、より品質の高いフロントエンド開発を追求していきたいと考えています。

Discussion