✂️

【React Hook Form】フォーム内のフィールドを共通化する

2024/10/28に公開

フォームを作成していると、その一部をコンポーネントとして切り出したいときがあります。React Hook FormとZodでフォームバリデーションを実装していたのですが、型の持たせ方に工夫が必要だったので記事にまとめます。

前提

  • ここではサンプルとして、レシピを入力するフォームを想定します。
  • 赤枠で囲んだ部分が複数のフォームで共通化したい部分です。

スキーマは共有したい部分と、個別に必要な部分を分けて定義しています。

import { z } from "zod";

// 共通化したいスキーマ
const recipeSchema = z.object({
  title: z.string(),
  ingredients: z.array(z.object({ id: z.string() })),
});

// 塩分量のフィールドを持つスキーマ
const lowSodiumRecipeSchema = z
  .object({
    sodiumContent: z
      .string()
      .transform((value) => Number(value))
      .pipe(z.number()),
  })
  .merge(recipeSchema);

type RecipeSchemaType = z.infer<typeof recipeSchema>;
type LowSodiumRecipeSchemaType = z.infer<typeof lowSodiumRecipeSchema>;

export {
  recipeSchema,
  lowSodiumRecipeSchema,
  type RecipeSchemaType,
  type LowSodiumRecipeSchemaType,
};

問題

共通フィールドを切り出したコンポーネントは、広い方のスキーマの型 (塩分量あり) を受け取れる必要があります。しかし、Propsとして UseFormReturn<FieldValues> の様に定義するため、スキーマが包括関係にあるにも関わらず別々の型として認識されてしまいました。

次の書き方では、共通化したコンポーネントを利用する側でエラーになってしまいます。

RecipeFormFields.tsx
"use client";

import { UseFormReturn } from "react-hook-form";
import { RecipeSchemaType } from "../(schemas)";
import { useFieldArray } from "react-hook-form";
import { PropsWithChildren, useCallback } from "react";

type Props = PropsWithChildren<{
  form: UseFormReturn<RecipeSchemaType>;
}>;

/**
* 共通フィールドを切り出したコンポーネント
*/
export const RecipeFormFields = ({ form, children }: Props) => {
  const { fields, append } = useFieldArray<RecipeSchemaType>({
    control: form.control,
    name: "ingredients",
  });

  const handleAddIngredient = useCallback(() => {
    append({ id: "" });
  }, [append]);

  return (
    <>
      <div className="flex gap-x-2">
        <label htmlFor="title" className="after:content-[':']">
          レシピ名
        </label>
        <input id="title" {...form.register("title")} className="border" />
      </div>

      {children}

      <div className="flex flex-col gap-y-2">
        <label className="after:content-[':']">材料</label>
        <button
          type="button"
          onClick={handleAddIngredient}
          className="px-4 py-1 border rounded-lg w-fit"
        >
          材料を追加
        </button>

        {fields.map((field, index) => (
          <div key={field.id} className="flex gap-x-2">
            <div className="after:content-['.']">{index + 1}</div>
            <input
              {...form.register(`ingredients.${index}.id`)}
              className="border"
            />
          </div>
        ))}
      </div>
    </>
  );
};

LowSoliumRecipeForm.tsx
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { lowSodiumRecipeSchema, LowSodiumRecipeSchemaType } from "../(schemas)";
import { RecipeFormFields } from "./RecipeFormFields";

/**
* 塩分量ありのフォーム
**/
export const LowSodiumRecipeForm = () => {
  const defaultValues: LowSodiumRecipeSchemaType = {
    title: "",
    ingredients: [],
    sodiumContent: 0,
  };
  const form = useForm<LowSodiumRecipeSchemaType>({
    defaultValues,
    resolver: zodResolver(lowSodiumRecipeSchema),
  });

  const submitHandler = form.handleSubmit((data) => {
    console.log(data);
  });

  return (
    <form onSubmit={submitHandler} className="flex flex-col gap-y-4 w-fit">
      <RecipeFormFields form={form}>
        //              ^^^^^^^ ここでエラーになる                      
        <div className="flex gap-x-2">
          <label htmlFor="sodiumContent" className="after:content-[':']">
            塩分量 (g)
          </label>
          <input
            id="sodiumContent"
            type="number"
            {...form.register("sodiumContent")}
            className="border"
          />
        </div>
      </RecipeFormFields>

      <button
        type="submit"
        className="px-4 py-1 border rounded-lg w-fit text-white bg-blue-400"
      >
        送信
      </button>
    </form>
  );
};

解決

解決方法として、RecipeFormFileds.tsx に型引数でスキーマの型を渡せる様にします。呼び出し側でuseFormに使うスキーマの型を決められるため、差分をうまく吸収してくれます。

RecipeFormFields.tsx
@@ -5,11 +5,16 @@ import { RecipeSchemaType } from "../(schemas)";
 import { useFieldArray } from "react-hook-form";
 import { PropsWithChildren, useCallback } from "react";

-type Props = PropsWithChildren<{
-  form: UseFormReturn<RecipeSchemaType>;
+type Props<FieldValues> = PropsWithChildren<{
+  form: FieldValues extends RecipeSchemaType
+    ? UseFormReturn<FieldValues>
+    : never;
 }>;

-export const RecipeFormFields = ({ form, children }: Props) => {
+export const RecipeFormFields = <FieldValues extends RecipeSchemaType>({
+  form,
+  children,
+}: Props<FieldValues>) => {
   const { fields, append } = useFieldArray<RecipeSchemaType>({
     control: form.control,
     name: "ingredients",

おわりに

今回は型引数を使って、フォーム内のフィールドを別のコンポーネントに切り出す方法を紹介しました。共通化を達成するだけであれば、フィールドを任意項目にするといったやり方もあるのですが、スキーマに手を加える必要がないためこの方法が気に入っています。

https://github.com/yuki-yamamura/react-hook-form-schema-separation

frontend flat

Discussion