react-hook-formの素振り
まず vite init
で react-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
で動作確認。
React Hook Formを1年以上運用してきたちょっと良く使うためのTips in ログラス(と現状の課題)
こちらを参考に useDefaultForm
を作って型安全性を高めてみる。
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 };
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のバージョンアップの影響と思われる。
同記事の React Hook Formに依存するコンポーネントを分ける を参考に <Input />
<InputControl />
コンポーネントを作る。
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}
/>
);
}
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
に組み込む。
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にしようと思っている)。
zodでフォームバリデーションしてみる。
npm i @hookform/resolvers zod
以下のように組み込む。
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
で得るようにした。
vitest
+ testing-library/react
でテストを書く。
まずテストで操作したり検証したい要素に data-testid
を振っていく。
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}
/>
);
}
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}
/>
);
}
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;
テストを書く。
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()
で数値型変換すると良いようだった。
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)),
});
これで解決。テストを書いて良かった。
<input type="file" />
の実装とテストもやってみる。
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;
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();
});
});
<input type="file" />
を <FileInput />
<FileInputControl />
にリファクタして置き換えようとしたところ問題が発生。
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} />;
}
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で管理しなくて良いんじゃないかというのが現状の考えになった。