tekiehei2317's blog

bulletproof-reactを参考にzodとreact-hook-formを組み合わせる

投稿日: 2022-12-08

zodとreact-hook-formの組み合わせを考え、Render propとカスタムフックの2通りで実装してみました。コードはこちらにあります。

playground/zod-form at main · tekihei2317/playground

フォームは、ユーザーからの入力を扱いやすい形式に変換するものと考えられます。これをzodのtransformで行います。

例えば、ユーザー登録フォームで誕生日を入力するとします。ユーザーにYYYYMMDDの形式の文字列で入力してもらい、使う側からはDateで扱いたいとします。これは例えば以下のように実装できます。

import { z } from "zod";

function isValidDate(date: Date): boolean {
  return !Number.isNaN(date.getTime());
}

function toYmd(dateString: string): string {
  return `${dateString.substring(0, 4)}-${dateString.substring(
    4,
    6
  )}-${dateString.substring(6, 8)}`;
}

export const dateString = z
  .string()
  .regex(/^[0-9]{8}$/, { message: "日付を正しい形式で入力してください" })
  .refine((val) => isValidDate(new Date(toYmd(val))), {
    message: "有効な日付ではありません",
  })
  .transform((val) => new Date(toYmd(val)));

dateString.safeParse("20220101"); // { success: true, data: 2022-01-01T00:00:00.000Z }
dateString.safeParse("invalid format"); // { success: false, error: [...] }
dateString.safeParse("20220000"); // { success: false, error: [...] }

このように、変換処理をzodで行う前提で、フォームのコンポーネントを作ってみました。

Render propで実装する

bulletproof-reactのFormコンポーネントを参考に実装してみました。

bulletproof-react/Form.tsx at master · alan2207/bulletproof-react

bulletproof-reactの実装では、Formコンポーネントにスキーマの型とフォームのフィールドの型を渡していますが、後者は前者から推論できるのでスキーマの型だけ渡すようにしました。

ポイントは、onSubmitにはtransformで変換した値が入るので、型にz.outputを使っていることです。zodのスキーマでtransformを使う場合は、z.inputがスキーマの型で、z.outputが変換後の型になります。

import { z, ZodType } from "zod";
import {
  useForm,
  FieldValues,
  UseFormReturn,
  SubmitHandler,
  UseFormProps,
} from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

type FormProps<TSchema extends ZodType<FieldValues>> = {
  schema: TSchema;
  // onSubmitには変換後の値が入るので、z.outputを使う
  onSubmit: SubmitHandler<z.output<TSchema>>;
  children: (methods: UseFormReturn<z.input<TSchema>>) => React.ReactNode;
  options?: UseFormProps<z.input<TSchema>>;
} & Omit<React.ComponentProps<"form">, "onSubmit" | "children">;

export const Form = <TSchema extends ZodType<FieldValues>>({
  schema,
  onSubmit,
  children,
  options,
  ...props
}: FormProps<TSchema>) => {
  const methods = useForm<z.input<TSchema>>({
    resolver: zodResolver(schema),
    ...options,
  });
  const handleSubmit = methods.handleSubmit(onSubmit);

  return (
    <form onSubmit={handleSubmit} {...props}>
      {children(methods)}
    </form>
  );
};

使用側は以下のようになります。ジェネリクスは推論してくれるので書かなくても良いです。

const registrationFormSchema = z.object({
  userName: stringSchema.max(15),
  email: stringSchema.email(),
  birthDate: dateString,
});

const FormUsingRenderProp = () => {
  return (
    <Form<typeof registrationFormSchema>
      schema={registrationFormSchema}
      onSubmit={(d) => console.log(d)}
      className="flex flex-col gap-4 items-start max-w-xl mx-auto"
      options={{
        defaultValues: {
          birthDate: "20000101",
        },
      }}
    >
      {({ register, formState: { errors } }) => (
        <>
          <label>
            ユーザー名
            <input {...register("userName")} className="border" />
            <p className="text-sm text-red-500">{errors.userName?.message}</p>
          </label>
          <label>
            メールアドレス
            <input {...register("email")} className="border" />
            <p className="text-sm text-red-500">{errors.email?.message}</p>
          </label>
          <label>
            生年月日
            <input {...register("birthDate")} className="border" />
            <p className="text-sm text-red-500">{errors.birthDate?.message}</p>
          </label>
          <button type="submit" className="bg-blue-200 px-4 py-2 rounded">
            登録
          </button>
        </>
      )}
    </Form>
  );
};

カスタムフックで実装する

カスタムフックでもできる気がしたので、useZodFormというカスタムフックを作ってみました。useZodFormの引数はzodのスキーマとuseFormのオプションで、戻り値はuseFormの戻り値とFormコンポーネントです。

type ZodFormProps<TSchema extends ZodType<FieldValues>> = {
  onSubmit: SubmitHandler<z.output<TSchema>>;
  children: React.ReactNode;
} & Omit<React.ComponentProps<"form">, "onSubmit" | "children">;

type UseZodFormReturn<TSchema extends ZodType<FieldValues>> = UseFormReturn<
  z.input<TSchema>
> & {
  Form: (props: ZodFormProps<TSchema>) => JSX.Element;
};

export function useZodForm<TSchema extends ZodType<FieldValues>>(
  schema: TSchema,
  options: UseFormProps<z.input<TSchema>> = {}
): UseZodFormReturn<TSchema> {
  const methods = useForm<z.input<TSchema>>({
    resolver: zodResolver(schema),
    ...options,
  });

  const Form = useCallback(
    ({ onSubmit, children, ...props }: ZodFormProps<TSchema>) => {
      const handleSubmit = methods.handleSubmit(onSubmit);

      return (
        <form onSubmit={handleSubmit} {...props}>
          {children}
        </form>
      );
    },
    [methods]
  );

  return { ...methods, Form };
}

使用側は以下のようになります。Render propを使った実装と比べると、registerformStateのスコープが広いことがデメリットですが、インデントが浅いので読みやすいです。

const FormUsingCustomHook = () => {
  const {
    Form,
    register,
    formState: { errors },
  } = useZodForm(registrationFormSchema, {
    defaultValues: {
      birthDate: "20000101",
    },
  });

  return (
    <Form
      onSubmit={(d) => console.log(d)}
      className="flex flex-col gap-4 items-start max-w-xl mx-auto"
    >
      <label>
        ユーザー名
        <input {...register("userName")} className="border" />
        <p className="text-sm text-red-500">{errors.userName?.message}</p>
      </label>
      <label>
        メールアドレス
        <input {...register("email")} className="border" />
        <p className="text-sm text-red-500">{errors.email?.message}</p>
      </label>
      <label>
        生年月日
        <input {...register("birthDate")} className="border" />
        <p className="text-sm text-red-500">{errors.birthDate?.message}</p>
      </label>
      <button type="submit" className="bg-blue-200 px-4 py-2 rounded">
        登録
      </button>
    </Form>
  );
};

まとめ

zodとreact-hook-formを組み合わせてフォームを実装しました。zodのtransformを使って、ユーザーの入力を扱いやすい形に変換しました。また、Render propとカスタムフックの2通りの方法で通りの方法で実装し、長所と短所を比較しました。

参考