React Hook Form、Zodでフォームを管理する

React Hook Form、Zodでフォームを管理する

はじめに

入力項目が非常に多い多段階フォームは、それ自体が保守しにくいだけでなく、下書き保存や初期値の設定などの機能が組み合わさると、非常に速いスピードで複雑化します。そのため、formを扱うには効率的な「戦略」が必要です。本稿では、TypeScript、Reactの環境でreact-hook-form(RHF)、Zod、Jotaiを活用し、複雑なフォームを戦略的に管理する方法を整理します。

複雑なフォームはいつから壊れ始めるのか?

フォームが巨大になるほど、次のような問題が発生します。

1. コンポーネントが巨大になる。

- 入力フィールド、検証ロジック、条件付きレンダリング、エラー処理ロジックが増加し、そのフォームを管理するコンポーネントが幾何級数的に大きくなります。

2. 値の欠落、または不要なデータが発生する。

- 条件付きフィールドがある場合、現在の条件に合わないフィールドの値が失われたり、不要なまま保存されたりします。

3. タイミングが多様になるほど、構造が急激に複雑になる。

- 下書き保存機能がある場合や条件付きフィールドが複数ある場合、値が保存または変更されるタイミングに応じて非常に多くのケースに対応する必要があるため、コードが急激に複雑になります。

管理戦略の設定

上記のような問題を単純に一対一で処理するのではなく、効率的な管理原則を設定し、それに沿って対応することにしました。私が設定した戦略は次のとおりです。

1. 重複するロジックを最小限に抑え、コード全体を簡潔にする

2. 条件が変更されたら、不要になった条件付きフィールドの値を初期化する。

3. 条件値とタイミングを基準に対応ロジックを構造化する。

4. 段階ごとに入力値を検証し、最終保存時に全体検証をもう一度実行する。

戦略の具体化

(1) 共通型の定義

// schemas/common.ts
import { z } from "zod";

export const MemberType = z.enum(["personal", "employee", "freelancer", "business"]);
export type MemberType = z.infer<typeof MemberType>;

export const basicProfileSchema = z.object({
  nickname: z.string().min(2, "닉네임은 2자 이상이어야 합니다.").max(20),
  ...
});

(2) 段階ごとにコードを分離し、RHFのextend()とZodのdiscriminatedUnionを活用する

// schemas/steps/job.schema.ts
import { z } from "zod";
import { MemberType } from "../common";

const baseJob = z.object({
  memberType: MemberType,
  ...
});

const employeeJob = baseJob.extend({
  memberType: z.literal("employee"),
  ...
});

const freelancerJob = baseJob.extend({
  memberType: z.literal("freelancer"),
  ...
});

const businessJob = baseJob.extend({
  memberType: z.literal("business"),
  ...
});

const personalJob = baseJob.extend({
  memberType: z.literal("personal"),
  ...
});

export const jobStepSchema = z.discriminatedUnion("memberType", [
  personalJob,
  employeeJob,
  freelancerJob,
  businessJob,
]);

export type JobStepForm = z.infer<typeof jobStepSchema>;

(3) fullモデルとして組み合わせる

// schemas/profile.schema.ts
import { z } from "zod";
import { basicProfileSchema } from "./common";
import { jobStepSchema } from "./steps/job.schema";

export const profileSchema = z.object({
  profile: basicProfileSchema,
  job: jobStepSchema,
  ...
});

export type ProfileForm = z.infer<typeof profileSchema>;

このとき、必要に応じてZodのrefine()、superRefine()関数を使用して値を検証し、Jotaiで現在の段階と下書き保存値を保存します。

コンポーネントの実装例

コンポーネントは次のように実装します。下書き保存値がある場合は初期設定を行い、下書き保存および現在の段階・完了段階の保存は、現在の段階のformがsubmitされた場合に実行します。

// steps/Job.tsx
import React, { useEffect } from "react";
import { useAtom } from "jotai";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

import { draftAtom, completedStepsAtom, currentStepAtom } from "../state/profileWizard.atoms";
import { mergeDraft } from "../state/mergeDraft";
import { jobStepSchema, type JobStepForm } from "../schemas/steps/Job.schema";

export function Job() {
  const [draft, setDraft] = useAtom(draftAtom); // 임시 저장값 atom
  const [completedSteps, setCompletedSteps] = useAtom(completedStepsAtom); // 완료된 단계를 저장하는 atom
  const [currentStep, setCurrentStep] = useAtom(currentStepAtom); // 현재 단계를 나타내는 atom

  const form = useForm<JobStepForm>({
    resolver: zodResolver(jobStepSchema),
    defaultValues: {
      ...
    },
  });
  const memberType = form.watch("memberType");

  const handleSubmit = (values) => {
    // 1) 현재 스텝 값을 draft에 저장
    setDraft(mergeDraft(draft, { job: values }));

    // 2) 스텝 완료 처리
    const nextCompleted = new Set(completedSteps);
    nextCompleted.add(4); setCompletedSteps(nextCompleted);

    // 3) 다음 스텝 이동
    setCurrentStep((currentStep + 1) as any);
  }

  // 스텝 진입 시 임시 저장값으로 초기화
  useEffect(() => {
    if (!!draft?.job) {
      form.reset(draft.job);
    }
  }, []);

  // 조건 변경 시 불필요한 조건부 필드의 값 초기화 (관리 전략 2)
  useEffect(() => {
    const base = form.getValues();

    if (memberType === "employee") {
      form.reset({
        memberType,
        role: base.role,
        ...
      } as JobStepForm);
    }

    if (memberType === "freelancer") {
      form.reset({
        memberType,
        role: base.role,
        ...
      } as JobStepForm);
    }

    if (memberType === "business") {
      form.reset({
        memberType,
        role: base.role,
        ...
      } as JobStepForm);
    }

    if (memberType === "personal") {
      form.reset({
        memberType,
        role: base.role,
        ...
      } as JobStepForm);
    }
  }, [memberType]);

  return (
    ...
  )
};

おわりに

以上のように、簡単なサンプルコードを通して、複雑なフォームを扱うための戦略の設定と適用について説明しました。このように基本原則と戦略を先に設定することで、フォームをより効果的に制御できるだけでなく、開発および保守にかかる時間も大幅に短縮できました。適切な方針によっても、品質の高いコードを生み出せることを実感した経験でした。

5月

Site footer