こんにちは、かつコーチです。
前回はReact Query(TanStack Query)を使ったデータ取得の効率化について解説しました。
データを「取得して表示する」側の話が一段落したので、今回は反対に「ユーザーからデータを受け取る」側、フォーム入力とバリデーションを扱います。
Reactでフォームを作るとき、useStateで愚直に書いていくと入力欄が増えるたびにコードが煩雑になっていきます。
そこで登場するのがReact Hook Formです。
React Hook Formとは
なぜ素のuseStateでフォームを作ると大変なのか
まずはuseStateだけでフォームを作った場合のイメージを見てみましょう。
import { useState } from "react";
const SignupForm = () => {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [nameError, setNameError] = useState("");
const [emailError, setEmailError] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setNameError(name === "" ? "名前は必須です" : "");
setEmailError(!email.includes("@") ? "メールアドレスの形式が不正です" : "");
// ここからさらに送信処理が続く…
};
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={(e) => setName(e.target.value)} />
{nameError && <p>{nameError}</p>}
<input value={email} onChange={(e) => setEmail(e.target.value)} />
{emailError && <p>{emailError}</p>}
<button type="submit">送信</button>
</form>
);
};
入力項目が2つだけでもこの分量です。
項目が10個、20個と増えたフォームを想像すると、状態変数とバリデーション処理の管理だけでコードが膨大になることが分かるかと思います。
また、onChangeのたびに再レンダリングが発生するため、大きなフォームではパフォーマンス面でも不利になりがちです。
React Hook Formが解決すること
React Hook Formは、入力値の管理を非制御コンポーネント(uncontrolled component)の仕組みに寄せることで、再レンダリングを最小限に抑えながらフォームを扱えるライブラリです。
状態変数を自分で1つずつ用意する必要がなく、バリデーションルールも宣言的に指定できるため、コード量が大幅に減ります。
基本の使い方
useFormとregisterで入力欄を作る
import { useForm } from "react-hook-form";
type FormValues = {
name: string;
email: string;
};
const SignupForm = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>();
const onSubmit = (data: FormValues) => {
console.log("送信データ:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name", { required: "名前は必須です" })} />
{errors.name && <p>{errors.name.message}</p>}
<input
{...register("email", {
required: "メールアドレスは必須です",
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: "メールアドレスの形式が不正です",
},
})}
/>
{errors.email && <p>{errors.email.message}</p>}
<button type="submit">送信</button>
</form>
);
};
export default SignupForm;
register("name", { required: ... })のように書くだけで、入力欄の値の追跡とバリデーションルールの適用が同時に行われます。
handleSubmit(onSubmit)は、バリデーションを通過した場合のみonSubmitを実行してくれるため、送信処理の中で毎回バリデーション結果をチェックする必要がありません。
バリデーションルールの指定
registerの第2引数には、requiredのほかにもさまざまなルールを指定できます。
| ルール | 役割 |
|---|---|
required | 必須入力 |
minLength / maxLength | 文字数の下限・上限 |
min / max | 数値の下限・上限 |
pattern | 正規表現によるフォーマットチェック |
validate | 任意のロジックによるカスタムバリデーション |
validateを使うと、「パスワードと確認用パスワードが一致しているか」のような、単純なルールでは表現できないチェックも書けます。
<input
{...register("passwordConfirm", {
validate: (value, formValues) =>
value === formValues.password || "パスワードが一致しません",
})}
/>
よくあるつまずきポイント:valueとonChangeを両方書いてしまう
制御コンポーネントの癖が抜けない
React Hook Formを使い始めた頃、私はuseStateでフォームを作っていた頃の癖が抜けず、registerとvalue・onChangeを両方書いてしまい、動作がおかしくなった経験があります。
❌ Before:registerとvalue・onChangeを両方指定する
const [name, setName] = useState("");
<input
{...register("name")}
value={name} // registerが管理している値と競合する
onChange={(e) => setName(e.target.value)} // registerのonChangeを上書きしてしまう
/>
registerは内部で独自にref・onChange・nameなどを設定しています。
そこに自前のvalueやonChangeを後から書いてしまうと、React Hook Form側の値の追跡が正しく機能しなくなり、バリデーションが反応しない・送信データが空になるといった不具合が起きます。
✅ After:registerだけに入力欄の管理を任せる
<input {...register("name", { required: "名前は必須です" })} />
React Hook Formを使う場合、入力欄の値の管理は基本的に全てregisterに任せ、useStateで二重管理しないのが鉄則です。
初期値を入れたい場合は、useFormのdefaultValuesオプションを使いましょう。
const { register } = useForm<FormValues>({
defaultValues: { name: "", email: "" },
});
応用・一歩先の使い方
Zodと組み合わせたスキーマバリデーション
registerにルールを1つずつ書いていく方法は手軽ですが、項目数が増えると見通しが悪くなります。
そこで、Zodのようなスキーマバリデーションライブラリと@hookform/resolversを組み合わせる方法がよく使われます。
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
name: z.string().min(1, "名前は必須です"),
email: z.string().email("メールアドレスの形式が不正です"),
});
type FormValues = z.infer<typeof schema>;
const SignupForm = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
const onSubmit = (data: FormValues) => {
console.log("送信データ:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name")} />
{errors.name && <p>{errors.name.message}</p>}
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<button type="submit">送信</button>
</form>
);
};
バリデーションルールをスキーマとして一箇所にまとめることで、フォームの構造とバリデーションロジックが分離され、見通しがよくなります。
エラーメッセージの表示とUX
エラーメッセージは、入力欄のすぐ下に表示するのが基本ですが、modeオプションでバリデーションのタイミングも調整できます。
useForm<FormValues>({
mode: "onBlur", // 入力欄からフォーカスが外れたタイミングで検証する
});
デフォルトのonSubmit(送信時にまとめて検証)に対し、onBlurやonChangeを指定すると、ユーザーが入力している最中にリアルタイムでフィードバックを返せます。
ただしonChangeはキー入力のたびに検証が走るため、入力途中で赤いエラー表示が何度も出てユーザーを急かしてしまうこともあるので、フォームの性質に合わせて選びましょう。
まとめ
この記事のポイント
useStateでフォームを作ると、項目数に比例してコードが煩雑になる- React Hook Formは
registerで入力欄の値の管理とバリデーションを同時に行える registerと自前のvalue・onChangeを併用すると値の追跡が壊れるため避ける- Zod等のスキーマバリデーションと組み合わせると、ルールを一箇所にまとめられる
modeオプションでバリデーションのタイミング(送信時/フォーカスアウト時など)を調整できる
次に読むべき記事
→ 次の記事:Reactアプリに認証機能を実装するパターン