Closed7

react-hook-formの素振り

dyoshikawadyoshikawa

まず vite initreact-ts プロジェクトを作成。

npm i react-hook-form して https://github.com/react-hook-form/react-hook-form のREAME通りに App.tsx を書いてみる。

import { useForm } from "react-hook-form";

function App() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm();

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input {...register("firstName")} />
      <input {...register("lastName", { required: true })} />
      {errors.lastName && <p>Last name is required.</p>}
      <input {...register("age", { pattern: /\d+/ })} />
      {errors.age && <p>Please enter number for age.</p>}
      <input type="submit" />
    </form>
  );
}

export default App;

npm run dev で動作確認。

dyoshikawadyoshikawa

React Hook Formを1年以上運用してきたちょっと良く使うためのTips in ログラス(と現状の課題)

こちらを参考に useDefaultForm を作って型安全性を高めてみる。

src/use-default-form.ts
import {
  FieldValues,
  useForm,
  UseFormProps,
  UseFormReturn,
} from "react-hook-form";

const useDefaultForm = <T extends FieldValues>(
  props: UseFormProps<T> & {
    defaultValues: T;
  }
): UseFormReturn<T> => {
  return useForm(props);
};

export { useDefaultForm };
src/App.tsx
import { useDefaultForm } from "./use-default-form";

function App() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useDefaultForm<{ firstName: string; lastName: string; age: number }>({
    defaultValues: {
      firstName: "",
      lastName: "",
      age: 0,
    },
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input {...register("firstName")} />
      <input {...register("lastName", { required: true })} />
      {errors.lastName && <p>Last name is required.</p>}
      <input {...register("age", { pattern: /\d+/ })} />
      {errors.age && <p>Please enter number for age.</p>}
      <input type="submit" />
    </form>
  );
}

export default App;

そのままだとコンパイルエラーになったため一部修正している(詳細は元記事へコメント)。おそらくTypeScriptのバージョンアップの影響と思われる。

dyoshikawadyoshikawa

同記事の React Hook Formに依存するコンポーネントを分ける を参考に <Input /> <InputControl /> コンポーネントを作る。

src/input.tsx
import { Ref } from "react";

type InputProps = {
  value: string;
  onChange: (value: string) => void;
  onBlur?: () => void;
  inputRef?: Ref<HTMLInputElement>;
};

export function Input({
  value,
  onChange,
  onBlur,
  inputRef,
}: InputProps): JSX.Element {
  return (
    <input
      ref={inputRef}
      onChange={(e) => onChange(e.target.value)}
      onBlur={onBlur}
      value={value}
    />
  );
}
src/input-control.tsx
import {
  Control,
  FieldPath,
  FieldValues,
  useController,
} from "react-hook-form";
import { Input } from "./input";

type InputControlProps<T extends FieldValues> = {
  name: FieldPath<T>;
  control: Control<T>;
};

export function InputControl<T extends FieldValues>({
  name,
  control,
}: InputControlProps<T>): JSX.Element {
  const { field } = useController({
    name,
    control,
  });

  return (
    <Input
      inputRef={field.ref}
      onChange={field.onChange}
      onBlur={field.onBlur}
      value={field.value as string}
    />
  );
}

<InputControl />App.tsx に組み込む。

src/App.tsx
import { useDefaultForm } from "./use-default-form";
import { Input } from "./input";
import { InputControl } from "./input-control";

function App() {
  const {
    register,
    handleSubmit,
    formState: { errors },
    control,
  } = useDefaultForm<{ firstName: string; lastName: string; age: number }>({
    defaultValues: {
      firstName: "",
      lastName: "",
      age: 0,
    },
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <InputControl name="firstName" control={control}></InputControl>
      <InputControl name="lastName" control={control}></InputControl>
      {errors.lastName && <p>Last name is required.</p>}
      <InputControl name="age" control={control}></InputControl>
      {errors.age && <p>Please enter number for age.</p>}
      <input type="submit" />
    </form>
  );
}

export default App;

バリデーションの制御が失われたが一旦後に回す(zodにしようと思っている)。

dyoshikawadyoshikawa

zodでフォームバリデーションしてみる。

npm i @hookform/resolvers zod

以下のように組み込む。

src/App.tsx
import { useDefaultForm } from "./use-default-form";
import { InputControl } from "./input-control";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const formSchema = z.object({
  firstName: z.string(),
  lastName: z.string().min(1),
  age: z.number().min(0),
});

type Form = z.infer<typeof formSchema>;

function App() {
  const {
    handleSubmit,
    formState: { errors },
    control,
  } = useDefaultForm<Form>({
    defaultValues: {
      firstName: "",
      lastName: "",
      age: 0,
    },
    resolver: zodResolver(formSchema),
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <InputControl name="firstName" control={control}></InputControl>
      <InputControl name="lastName" control={control}></InputControl>
      {errors.lastName && <p>Last name is required.</p>}
      <InputControl name="age" control={control}></InputControl>
      {errors.age && <p>Please enter number for age.</p>}
      <input type="submit" />
    </form>
  );
}

export default App;

Form 型をzodから .infer で得るようにした。

dyoshikawadyoshikawa

vitest + testing-library/react でテストを書く。

まずテストで操作したり検証したい要素に data-testid を振っていく。

src/input.tsx
import { Ref } from "react";

type InputProps = {
  value: string;
  onChange: (value: string) => void;
  onBlur?: () => void;
  inputRef?: Ref<HTMLInputElement>;
  dataTestId?: string;
};

export function Input({
  value,
  onChange,
  onBlur,
  inputRef,
  dataTestId,
}: InputProps): JSX.Element {
  return (
    <input
      ref={inputRef}
      onChange={(e) => onChange(e.target.value)}
      onBlur={onBlur}
      value={value}
      data-testid={dataTestId}
    />
  );
}
src/input-control.tsx
import {
  Control,
  FieldPath,
  FieldValues,
  useController,
} from "react-hook-form";
import { Input } from "./input";

type InputControlProps<T extends FieldValues> = {
  name: FieldPath<T>;
  control: Control<T>;
  dataTestId?: string;
};

export function InputControl<T extends FieldValues>({
  name,
  control,
  dataTestId,
}: InputControlProps<T>): JSX.Element {
  const { field } = useController({
    name,
    control,
  });

  return (
    <Input
      inputRef={field.ref}
      onChange={field.onChange}
      onBlur={field.onBlur}
      value={field.value as string}
      dataTestId={dataTestId}
    />
  );
}
src/App.tsx
import { useDefaultForm } from "./use-default-form";
import { InputControl } from "./input-control";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const formSchema = z.object({
  firstName: z.string(),
  lastName: z.string().min(1),
  age: z.number().min(0),
});

type Form = z.infer<typeof formSchema>;

function App() {
  const {
    handleSubmit,
    formState: { errors },
    control,
  } = useDefaultForm<Form>({
    defaultValues: {
      firstName: "",
      lastName: "",
      age: 0,
    },
    resolver: zodResolver(formSchema),
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <InputControl
        name="firstName"
        control={control}
        dataTestId="firstName"
      ></InputControl>
      <InputControl
        name="lastName"
        control={control}
        dataTestId="lastName"
      ></InputControl>
      {errors.lastName && (
        <p data-testid="lastNameError">Last name is required.</p>
      )}
      <InputControl
        name="age"
        control={control}
        dataTestId="age"
      ></InputControl>
      {errors.age && <p data-testid="ageError">Please enter number for age.</p>}
      <input type="submit" data-testid="submitButton" />
    </form>
  );
}

export default App;

テストを書く。

App.test.tsx
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import App from "./App";
import userEvent from "@testing-library/user-event";

test("lastNameのバリデーションテスト", async () => {
  const user = userEvent.setup();

  render(<App />);

  // lastName未入力でSubmitするとエラーメッセージを表示する
  await user.click(await screen.findByTestId("submitButton"));
  expect(await screen.findByTestId("lastNameError")).toBeInTheDocument();

  // lastNameを入力してSubmitするとエラーメッセージを非表示にする
  await user.type(await screen.findByTestId("lastName"), "Doe");
  await user.click(await screen.findByTestId("submitButton"));
  await waitFor(() => {
    expect(screen.queryByTestId("lastNameError")).not.toBeInTheDocument();
  });
});

test("ageのバリデーションテスト", async () => {
  const user = userEvent.setup();

  render(<App />);

  // ageに数字でない値を入力してSubmitするとエラーメッセージを表示する
  await user.type(await screen.findByTestId("age"), "INVALID");
  await user.click(await screen.findByTestId("submitButton"));
  expect(await screen.findByTestId("ageError")).toBeInTheDocument();

  // ageに数字を入力してSubmitするとエラーメッセージを非表示にする
  await user.clear(await screen.findByTestId("age"));
  await user.type(await screen.findByTestId("age"), "20");
  await user.click(await screen.findByTestId("submitButton"));
  await waitFor(() => {
    expect(screen.queryByTestId("ageError")).not.toBeInTheDocument();
  });
});

Zodを使ってバリデーションするときに文字列→数値変換を挟む

こちらによると z.preprocess() で数値型変換すると良いようだった。

src/App.tsx
const formSchema = z.object({
  firstName: z.string(),
  lastName: z.string().min(1),
- age: z.number().min(0),
+ age: z.preprocess((v) => Number(v), z.number().min(0)),
});

これで解決。テストを書いて良かった。

dyoshikawadyoshikawa

<input type="file" /> の実装とテストもやってみる。

src/App.tsx
import { useDefaultForm } from "./use-default-form";
import { InputControl } from "./input-control";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const formSchema = z.object({
  firstName: z.string(),
  lastName: z.string().min(1),
  age: z.preprocess((v) => Number(v), z.number().min(0)),
  image: z.instanceof(FileList).refine((fileList) => fileList.length > 0, {
    message: "Must select file",
  }),
});

type Form = z.infer<typeof formSchema>;

function App() {
  const {
    register,
    handleSubmit,
    formState: { errors },
    control,
  } = useDefaultForm<Partial<Form>>({
    defaultValues: {
      firstName: "",
      lastName: "",
      age: 0,
      image: undefined,
    },
    resolver: zodResolver(formSchema),
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <InputControl name="firstName" control={control} dataTestId="firstName" />
      <InputControl name="lastName" control={control} dataTestId="lastName" />
      {errors.lastName && (
        <p data-testid="lastNameError">Last name is required.</p>
      )}
      <InputControl name="age" control={control} dataTestId="age" />
      {errors.age && <p data-testid="ageError">Please enter number for age.</p>}
      <input type="file" data-testid="image" {...register("image")} />
      {errors.image && <p data-testid="imageError">Please select image.</p>}
      <input type="submit" data-testid="submitButton" />
    </form>
  );
}

export default App;
src/App.test.tsx
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import App from "./App";
import userEvent from "@testing-library/user-event";

test("lastNameのバリデーションテスト", async () => {
  const user = userEvent.setup();

  render(<App />);

  // lastName未入力でSubmitするとエラーメッセージを表示する
  await user.click(await screen.findByTestId("submitButton"));
  expect(await screen.findByTestId("lastNameError")).toBeInTheDocument();

  // lastNameを入力してSubmitするとエラーメッセージを非表示にする
  await user.type(await screen.findByTestId("lastName"), "Doe");
  await user.click(await screen.findByTestId("submitButton"));
  await waitFor(() => {
    expect(screen.queryByTestId("lastNameError")).not.toBeInTheDocument();
  });
});

test("ageのバリデーションテスト", async () => {
  const user = userEvent.setup();

  render(<App />);

  // ageに数字でない値を入力してSubmitするとエラーメッセージを表示する
  await user.type(await screen.findByTestId("age"), "INVALID");
  await user.click(await screen.findByTestId("submitButton"));
  expect(await screen.findByTestId("ageError")).toBeInTheDocument();

  // ageに数字を入力してSubmitするとエラーメッセージを非表示にする
  await user.clear(await screen.findByTestId("age"));
  await user.type(await screen.findByTestId("age"), "20");
  await user.click(await screen.findByTestId("submitButton"));
  await waitFor(() => {
    expect(screen.queryByTestId("ageError")).not.toBeInTheDocument();
  });
});

test("imageのバリデーションテスト", async () => {
  const user = userEvent.setup();

  render(<App />);

  // 画像を選択せずSubmitするとエラーメッセージを表示する
  await user.click(await screen.findByTestId("submitButton"));
  expect(await screen.findByTestId("imageError")).toBeInTheDocument();

  // 画像を選択してSubmitするとエラーメッセージを非表示にする
  const file = new File(["test"], "test.png", {
    type: "image/png",
  });
  await user.upload(await screen.findByTestId("image"), file);
  await user.click(await screen.findByTestId("submitButton"));
  await waitFor(() => {
    expect(screen.queryByTestId("imageError")).not.toBeInTheDocument();
  });
});

参考:
react-hook-form と zod でバリデーションのその先へ

dyoshikawadyoshikawa

<input type="file" /><FileInput /> <FileInputControl /> にリファクタして置き換えようとしたところ問題が発生。

src/file-input-control.tsx
import {
  Control,
  FieldPath,
  FieldValues,
  useController,
} from "react-hook-form";

type FileInputControlProps<T extends FieldValues> = {
  name: FieldPath<T>;
  control: Control<T>;
  dataTestId?: string;
};

export function FileInputControl<T extends FieldValues>({
  name,
  control,
  dataTestId,
}: FileInputControlProps<T>): JSX.Element {
  const { field } = useController({
    name,
    control,
  });

  return <input type="file" {...field} data-testid={dataTestId} />;
}
src/App.tsx
import { useDefaultForm } from "./use-default-form";
import { InputControl } from "./input-control";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { FileInputControl } from "./file-input-control";

const formSchema = z.object({
  firstName: z.string(),
  lastName: z.string().min(1),
  age: z.preprocess((v) => Number(v), z.number().min(0)),
  image: z.instanceof(FileList).refine((fileList) => {
    return fileList.length > 0;
  }),
});

type Form = z.infer<typeof formSchema>;

function App() {
  const {
    register,
    handleSubmit,
    formState: { errors },
    control,
  } = useDefaultForm<Partial<Form>>({
    defaultValues: {
      firstName: "",
      lastName: "",
      age: 0,
      image: undefined,
    },
    resolver: zodResolver(formSchema),
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <InputControl name="firstName" control={control} dataTestId="firstName" />
      <InputControl name="lastName" control={control} dataTestId="lastName" />
      {errors.lastName && (
        <p data-testid="lastNameError">Last name is required.</p>
      )}
      <InputControl name="age" control={control} dataTestId="age" />
      {errors.age && <p data-testid="ageError">Please enter number for age.</p>}
      <FileInputControl name="image" control={control} dataTestId="image" />
      {/* <input type="file" data-testid="image" {...register("image")} /> */}
      {errors.image && <p data-testid="imageError">Please select image.</p>}
      <input type="submit" data-testid="submitButton" />
    </form>
  );
}

export default App;

このようにしたところテストが通らなくなる。

Error: expect(element).not.toBeInTheDocument()

expected document not to contain element, found <p
  data-testid="imageError"
>
  Please select image.
</p>

実際、ブラウザ上で手動で試しても同じ問題が再現する。画像をセットしてもバリデーションエラーが消えてくれない。

コード変えて試したりググったりドキュメント読んだりでしばらく格闘した結果、

  • 公式ドキュメントに <input type="file" /> のケースの言及がないように見える(見落としているかも)
  • Twitterやブログで <input type="file" /> + react-hook-formでハマったという報告が多々見られる
  • 画像のバリデーションはreact-hook-formやzodで予め用意されているルールの組み合わせでは実現が難しく、結局自前で作り込む必要がある
  • 実際のところ、画像の保存はテキスト項目の保存と別枠であることが多い
    • そもそもフォームが別、APIが別、S3への署名付きPOST送信など

上記より、 <input type="file" /> は無理にreact-hook-formで管理しなくて良いんじゃないかというのが現状の考えになった。

このスクラップは2023/02/03にクローズされました