📝

React Hook Form+Zodでフォームをシンプルに

に公開

この記事では果物・野菜フォームを例に、複雑なフォームの状態管理やバリデーションをReact Hook Form + Zod を活用してシンプルに改善する例を、Before → After形式で紹介したいと思います🙇‍♀️

💭 背景と要件

  • 「果物を入力する」「野菜を入力する」チェックボックス
  • 選択された項目のみ、名前+画像ファイルを最大3件まで登録
  • 選択された場合は最低1件必須
  • 既存データはAPIから取得し、フォーム初期値に反映
  • 登録データは FormData 形式でAPI送信

🙅‍♀️ Before:改善前のフォーム実装

果物・野菜フォームの初期実装です。チェックボックスの状態と入力フィールドの状態をそれぞれuseStateで管理し、バリデーションも手動で行っていました。

チェックボックスと状態の分離

const [hasFruit, setHasFruit] = useState(false)
const [hasVegetable, setHasVegetable] = useState(false)
  • それぞれのチェックボックスの状態を個別にuseStateで管理しています。

入力項目の配列状態管理

const [fruits, setFruits] = useState([{ name: "", file: null }])
const [vegetables, setVegetables] = useState([{ name: "", file: null }])
  • 初期入力として1件の空データを持ちますが、要素追加や編集も手動で制御する必要があります。

バリデーションと送信処理

const handleSubmit = async () => {
  const newErrors: string[] = []
  if (hasFruit) {
    if (fruits.length === 0) newErrors.push("果物を1件以上入力してください")
    if (fruits.some(f => !f.name || !f.file)) newErrors.push("すべての果物に名前と画像が必要です")
  }
  if (hasVegetable) {
    if (vegetables.length === 0) newErrors.push("野菜を1件以上入力してください")
    if (vegetables.some(v => !v.name || !v.file)) newErrors.push("すべての野菜に名前と画像が必要です")
  }
  if (newErrors.length) {
    setErrors(newErrors)
    return
  }

  const formData = new FormData()
  fruits.forEach((f, i) => {
    formData.append(`list[${i}][name]`, f.name)
    formData.append(`list[${i}][file]`, f.file!)
  })
  vegetables.forEach((v, i) => {
    formData.append(`list[${fruits.length + i}][name]`, v.name)
    formData.append(`list[${fruits.length + i}][file]`, v.file!)
  })

  setIsSubmitting(true)
  await sendAPI(userId, formData)
  setIsSubmitting(false)
}
  • 入力状態を一つずつ確認しながら、バリデーションとエラーハンドリングを手動で記述しています

入力UIの生成

{hasFruit &&
  fruits.map((f, i) => (
    <TextFileInput
      key={i}
      value={f.name}
      file={f.file}
      onChangeName={name => {
        const arr = [...fruits]
        arr[i].name = name
        setFruits(arr)
      }}
      onChangeFile={file => {
        const arr = [...fruits]
        arr[i].file = file
        setFruits(arr)
      }}
    />
  ))
}
  • 入力ごとに個別の状態変更関数を呼び出す必要があり、可読性・拡張性に課題があります。

❌ Before実装の主な課題

課題点 詳細
状態管理の分散 useStateを多用してチェック状態と各入力状態を個別に管理
バリデーションの複雑化 フォーム送信時にif文で個別チェックを実施
保守性の低さ 項目の増加に伴いコードが指数的に複雑化
UIとの結合 JSX内にロジックが密結合されているため再利用しにくい

🙆‍♀️ After:Zod + RHFでの改善後のコード

🔑 Zodスキーマの導入

Zodを使ってフォームデータの構造を定義し、各フィールドに必要な条件を設定しました。特に、refine関数で条件付きのバリデーションを明確にしています。

import { z } from "zod";

const inputItemSchema = z.object({
  name: z.string().min(1, "名前は必須です"),
  file: z.any().refine(f => f instanceof File, "ファイルをアップロードしてください"),
});

const itemFormSchema = z.object({
  hasFruit: z.boolean(),
  hasVegetable: z.boolean(),
  fruits: z.array(inputItemSchema).optional()
    .refine((arr, ctx) => ctx.parent.hasFruit ? (arr?.length ?? 0) > 0 : true, {
      message: "果物を選択した場合は最低1件必要です",
    }),
  vegetables: z.array(inputItemSchema).optional()
    .refine((arr, ctx) => ctx.parent.hasVegetable ? (arr?.length ?? 0) > 0 : true, {
      message: "野菜を選択した場合は最低1件必要です",
    }),
});

type Form = z.infer<typeof itemFormSchema>;

🔨 RHFを利用したフォーム構築

React Hook Form (useForm) を用いて、フォーム状態を集中管理しています。また、フォームの状態変更を監視するためにwatch()を活用しています。初期データの読み込みについても、RHFのreset()関数を使用することでシンプルになりました。

const methods = useForm<Form>({
  resolver: zodResolver(itemFormSchema),
  defaultValues: { hasFruit: false, hasVegetable: false, fruits: [], vegetables: [] },
});
const { setValue, register, watch, control, handleSubmit, formState, reset } = methods;

const { fields: fruitFields, append: addFruit } = useFieldArray({ control, name: "fruits" });
const { fields: vegetableFields, append: addVegetable } = useFieldArray({ control, name: "vegetables" });

const hasFruit = watch("hasFruit");
const hasVegetable = watch("hasVegetable");

useEffect(() => {
  //初期データの読み込み
  getAPI(userId).then(data => reset(data));
}, [userId, reset]);

const onSubmit = async (data: Form) => {
  const formData = new FormData();

  data.fruits?.forEach((fruit, i) => {
    formData.append(`list[${i}][name]`, fruit.name);
    formData.append(`list[${i}][file]`, fruit.file);
  });

  data.vegetables?.forEach((vegetable, i) => {
    const index = (data.fruits?.length ?? 0) + i;
    formData.append(`list[${index}][name]`, vegetable.name);
    formData.append(`list[${index}][file]`, vegetable.file);
  });

  await sendAPI(userId, formData);
};


📌 フォームのレンダリング

フォーム入力欄を動的に生成するためにuseFieldArrayを活用しています。これにより、繰り返し入力項目の追加・削除が簡単になります。また、register()関数を各入力フィールドに接続することで、自動的にフォーム状態と連携し、バリデーションも即時反映されます。

<FormProvider {...methods}>
  <form onSubmit={handleSubmit(onSubmit)}>
    <label>
      <input type="checkbox" {...register("hasFruit")} />
    </label>
    {hasFruit && fruitFields.map((field, i) => (
      <div key={field.id}>
        <input {...register(`fruits.${i}.name`)} placeholder="果物の名前" />
        <input type="file" onChange={(e) => {
                setValue(`fruits.${i}.file`, e.target.files?.[0])
              }} />
      </div>
    ))}
    {hasFruit && fruitFields.length < 3 && <button type="button" onClick={() => addFruit({ name: "", file: undefined })}>追加</button>}

    <label>
      <input type="checkbox" {...register("hasVegetable")} /> 
    </label>
    {hasVegetable && vegetableFields.map((field, i) => (
      <div key={field.id}>
        <input {...register(`vegetables.${i}.name`)} placeholder="野菜の名前" />
        <input type="file" onChange={(e) => {
                setValue(`vegetables.${i}.file`, e.target.files?.[0])
              }} />
      </div>
    ))}
    {hasVegetable && vegetableFields.length < 3 && <button type="button" onClick={() => addVegetable({ name: "", file: undefined })}>追加</button>}

        {formState.errors.fruits?.root?.message && (
          <p style={{ color: "red" }}>{formState.errors.fruits.root.message}</p>
        )}
        
        {formState.errors.vegetables?.root?.message && (
          <p style={{ color: "red" }}>{formState.errors.vegetables.root.message}</p>
        )}


    <button disabled={formState.isSubmitting} type="submit">送信</button>
  </form>
</FormProvider>

🚩 改善ポイントの詳細解説

  • 状態管理を一元化: RHFのuseFormで全状態を管理
  • バリデーションをスキーマで集中管理: Zodスキーマで条件付き必須入力を明確化
  • 初期値設定を単純化: methods.reset(apiResponse)で一括初期化
  • 繰り返し入力の簡略化: useFieldArrayで動的入力の処理が簡単に
  • UIとロジックの分離: UIは純粋な表示ロジック、バリデーションとデータ処理はスキーマとRHFに委譲

📝 おわりに

Zodを導入することで、条件分岐が多くなりがちなフォームのバリデーションロジックを
スキーマとして明確に定義できるようになり、コードの意図がより読み取りやすくなりました。

さらに、React Hook Formと組み合わせることで、状態管理や初期値設定、エラーハンドリングまで
一貫した方法で実装でき、可読性と保守性を大幅に改善することができました。

今後もフォームの仕様が拡張された際にも柔軟に対応できる構造になったと感じています。

同じような課題をお持ちの方々にとって、本記事が少しでも参考になれば幸いです🙌

BLT SDC Tech Blog

Discussion