React でフォーム管理入門:React Hook Form で楽にバリデーションまで作る
概要
React でフォームを作ると、useState を入力欄の数だけ並べ、onChange を書き、バリデーションを手で組み、エラー表示の状態も別途管理し……と、あっという間にコンポーネントが肥大化します。React Hook Form(以下 RHF)は、この定型作業を大幅に減らし、しかも「入力のたびに全体が再レンダリングされる」問題も抑えてくれるライブラリです。
この記事では、素の useState で作ったフォームを出発点に、RHF へ置き換え、最終的に Zod でスキーマバリデーションまで通すところをハンズオンで進めます。
環境
- Node.js 20 系
- React 18 / TypeScript 5
- Vite(react-ts テンプレート)
- react-hook-form 7 系 / zod 3 系 / @hookform/resolvers 3 系
npm install react-hook-form zod @hookform/resolvers
素の useState だと何がつらいか
import { useState } from "react";
export function SignUpFormNaive() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const next: typeof errors = {};
if (!email.includes("@")) next.email = "メールアドレスの形式が不正です";
if (password.length < 8) next.password = "8文字以上で入力してください";
setErrors(next);
if (Object.keys(next).length > 0) return;
console.log({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
{errors.email && <p>{errors.email}</p>}
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{errors.password && <p>{errors.password}</p>}
<button type="submit">登録</button>
</form>
);
}
問題点は主に3つです。
- 入力欄が増えるほど
useStateとonChangeが増殖する - バリデーションのルールがロジックに埋もれ、再利用しづらい
- 1文字打つたびに
setStateが走り、フォーム全体が再レンダリングされる
RHF へ置き換える
RHF の中心は useForm フックと、それが返す register です。register を input に展開するだけで、値の追跡・onChange・onBlur の配線が済みます。
import { useForm } from "react-hook-form";
type FormValues = {
email: string;
password: string;
};
export function SignUpForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>();
const onSubmit = (data: FormValues) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register("email", {
required: "メールアドレスは必須です",
pattern: {
value: /^[^@\s]+@[^@\s]+\.[^@\s]+$/,
message: "メールアドレスの形式が不正です",
},
})}
/>
{errors.email && <p>{errors.email.message}</p>}
<input
type="password"
{...register("password", {
required: "パスワードは必須です",
minLength: { value: 8, message: "8文字以上で入力してください" },
})}
/>
{errors.password && <p>{errors.password.message}</p>}
<button type="submit" disabled={isSubmitting}>
登録
</button>
</form>
);
}
ポイントを整理します。
useForm<FormValues>()に型を渡すと、registerのフィールド名やonSubmitの引数が型チェックされるhandleSubmitはバリデーションを通過したときだけonSubmitを呼ぶformState.errorsにフィールド名ごとのエラーが入るisSubmittingを使えば二重送信防止のボタン無効化が簡単に書ける
useState を1つも書いていない点に注目してください。値の保持は RHF が内部で持ちます。
なぜ再レンダリングが減るのか
RHF は既定で 非制御コンポーネント(uncontrolled) として動きます。register は内部で ref を使って DOM から値を読むため、1文字打つたびに React の state が更新されるわけではありません。結果として、入力中にコンポーネントツリーが再描画され続けることを避けられます。
入力中の値をリアルタイムに画面へ反映したいときは watch を使います。ただし watch した箇所は再レンダリングの対象になるので、必要な範囲に限定するのがコツです。
const email = watch("email");
Zod でスキーマバリデーションにする
バリデーションを1か所のスキーマに集約し、そこから型も導出できるのが Zod との組み合わせです。
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z
.object({
email: z.string().email("メールアドレスの形式が不正です"),
password: z.string().min(8, "8文字以上で入力してください"),
passwordConfirm: z.string(),
})
.refine((v) => v.password === v.passwordConfirm, {
message: "パスワードが一致しません",
path: ["passwordConfirm"],
});
type FormValues = z.infer<typeof schema>;
export function SignUpFormWithZod() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
const onSubmit = (data: FormValues) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register("password")} />
{errors.password && <p>{errors.password.message}</p>}
<input type="password" {...register("passwordConfirm")} />
{errors.passwordConfirm && <p>{errors.passwordConfirm.message}</p>}
<button type="submit">登録</button>
</form>
);
}
嬉しい点は2つです。
z.infer<typeof schema>でスキーマから型を自動生成できるので、型定義とバリデーションが二重管理にならない.refineでフィールドをまたいだ検証(パスワード一致など)を宣言的に書ける
UI ライブラリなど「制御された入力」との連携
value/onChange を渡す前提のコンポーネント(Material UI や React Select など)は register をそのまま展開できません。この場合は Controller を使います。
import { Controller, useForm } from "react-hook-form";
import Select from "react-select";
const options = [
{ value: "jp", label: "日本" },
{ value: "us", label: "アメリカ" },
];
function CountryField() {
const { control, handleSubmit } = useForm<{ country: string }>();
return (
<form onSubmit={handleSubmit((d) => console.log(d))}>
<Controller
name="country"
control={control}
rules={{ required: "国を選択してください" }}
render={({ field }) => (
<Select
options={options}
value={options.find((o) => o.value === field.value)}
onChange={(opt) => field.onChange(opt?.value)}
/>
)}
/>
<button type="submit">送信</button>
</form>
);
}
確認方法
- 上記コンポーネントを Vite プロジェクトに配置し、
npm run devで起動する - 何も入力せず送信 → 各フィールドの必須エラーが表示されること
- 不正な形式のメールを入力 → 形式エラーが表示されること
- パスワードと確認用を食い違わせる →
passwordConfirmにエラーが出ること - すべて正しく入力して送信 →
console.logに値が出力され、エラーが消えること
注意点
- 既定は非制御。他ライブラリと組む場合は
Controllerを挟む前提で設計する watchの乱用は再レンダリング増につながる。表示更新に必要な範囲に絞る- 初期値は
useForm({ defaultValues })で渡す。編集画面では API 取得後にreset(data)で流し込むのが定石 - スキーマと型の単一ソース化を狙うなら Zod との併用を最初から入れておくと後が楽
まとめ
React Hook Form を使うと、useState の羅列・手書きバリデーション・再レンダリング問題という「フォームあるある」の大半を解消できます。まず register と handleSubmit で置き換え、次に Zod でバリデーションと型を1か所に集約する、という順で導入するのがおすすめです。小さいフォームでも恩恵はありますが、フィールド数が増えるほど差が効いてきます。
