Part 10 · 実務で使う道具
56 / 64 章
React Hook Form
項目が増えたフォームを、描き直さずに扱う
Part 5 で、制御コンポーネントと入力チェックをやりました。 そこで「項目が増えたら道具を使う」と書きました。
その道具の話です。
React Hook Form は、 フォームで毎回書くことになる面倒を まとめて引き受けてくれるライブラリです。
1 文字ごとに描き直している
まず、いままでのやり方を見ます。useState で 1 項目ずつ持つ、制御コンポーネントです。
名前を打ちながら、右上の数字を見る
1 文字打つごとに数字が増えます。しかも増えるのは名前の欄だけではありません。フォーム全体が描き直されています。
項目が 3 つならこれでも困りません。 20 項目のフォームで、1 文字ごとに 20 個の入力欄が描き直されるとどうでしょう。入力が引っかかる感覚として現れはじめます。
加えて、Part 5 で見たとおり、 エラー文の管理・いつ検査するか・送信中の制御を 自分で書くことになります。
controlled-form.tsx
"use client";
import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";
export function ControlledForm() {
useTrackDemoRender();
// 1 文字打つたびに state が変わる = このフォーム全体が描き直される
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [age, setAge] = useState("");
return (
<div className="flex flex-col gap-3">
<Input
placeholder="名前"
aria-label="名前"
value={name}
onChange={(event) => setName(event.target.value)}
/>
<Input
placeholder="メールアドレス"
aria-label="メールアドレス"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
<Input
placeholder="年齢"
aria-label="年齢"
value={age}
onChange={(event) => setAge(event.target.value)}
/>
<Button size="sm" disabled>
送信
</Button>
</div>
);
}入力欄を state から切り離す
React Hook Form の中心にあるのが、この 1 行です。
<Input placeholder="名前" {...register("name")} />value も onChange も書いていません。register が返しているのは、onChange と、「この入力欄はどれか」を React Hook Form に教えるための仕掛けです。
Part 4 でやった「state は最小限にする」の徹底版です。 画面に出すのに state が要らないなら、持たない。
rhf-form.tsx
"use client";
import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { z } from "zod";
// 入力の決まりを 1 か所に書く。エラー文もここに書く
const schema = z.object({
name: z.string().min(1, "名前を入力してください"),
email: z.email("メールアドレスの形式が正しくありません"),
age: z
.number({ error: "数字を入力してください" })
.min(18, "18 歳以上で入力してください"),
});
// 決まりから型を作る。型を別に書かなくてよい
type FormValues = z.infer<typeof schema>;
export function RhfForm() {
useTrackDemoRender();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
// 一度エラーを出した項目だけ、打つたびに再判定する
mode: "onTouched",
});
const [sent, setSent] = useState<FormValues | null>(null);
// 検査を通ったときだけ、この中が呼ばれる
const onSubmit = handleSubmit(async (values) => {
await new Promise((resolve) => setTimeout(resolve, 600));
setSent(values);
});
return (
<form onSubmit={onSubmit} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
{/* value も onChange も書かない。register が繋いでくれる */}
<Input
placeholder="名前"
aria-label="名前"
aria-invalid={errors.name !== undefined}
aria-describedby={errors.name ? "name-error" : undefined}
{...register("name")}
/>
{errors.name && (
<p id="name-error" role="alert" className="text-sm text-destructive">
{errors.name.message}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<Input
placeholder="メールアドレス"
aria-label="メールアドレス"
aria-invalid={errors.email !== undefined}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert" className="text-sm text-destructive">
{errors.email.message}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<Input
type="number"
placeholder="年齢"
aria-label="年齢"
aria-invalid={errors.age !== undefined}
aria-describedby={errors.age ? "age-error" : undefined}
// 入力欄の値は文字列なので、数値に変換してから検査に渡す
{...register("age", { valueAsNumber: true })}
/>
{errors.age && (
<p id="age-error" role="alert" className="text-sm text-destructive">
{errors.age.message}
</p>
)}
</div>
<Button type="submit" size="sm" disabled={isSubmitting}>
{isSubmitting ? "送信中…" : "送信"}
</Button>
{sent && (
<p className="text-sm text-muted-foreground">
送信しました: {sent.name} / {sent.email} / {sent.age} 歳
</p>
)}
</form>
);
}入力の決まりを 1 か所に書く
検査の内容は zod で書きます。 項目ごとに、条件とエラー文を並べるだけです。
Part 5 では、この検査を if で自分で書いていました。 項目が増えるほど、条件と表示が離れていきます。 ここでは決まりが 1 か所にまとまります。
type FormValues = z.infer<typeof schema>;そして型を別に書く必要がありません。z.infer が、決まりから型を作ってくれます。
rhf-form.tsx
"use client";
import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { z } from "zod";
// 入力の決まりを 1 か所に書く。エラー文もここに書く
const schema = z.object({
name: z.string().min(1, "名前を入力してください"),
email: z.email("メールアドレスの形式が正しくありません"),
age: z
.number({ error: "数字を入力してください" })
.min(18, "18 歳以上で入力してください"),
});
// 決まりから型を作る。型を別に書かなくてよい
type FormValues = z.infer<typeof schema>;
export function RhfForm() {
useTrackDemoRender();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
// 一度エラーを出した項目だけ、打つたびに再判定する
mode: "onTouched",
});
const [sent, setSent] = useState<FormValues | null>(null);
// 検査を通ったときだけ、この中が呼ばれる
const onSubmit = handleSubmit(async (values) => {
await new Promise((resolve) => setTimeout(resolve, 600));
setSent(values);
});
return (
<form onSubmit={onSubmit} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
{/* value も onChange も書かない。register が繋いでくれる */}
<Input
placeholder="名前"
aria-label="名前"
aria-invalid={errors.name !== undefined}
aria-describedby={errors.name ? "name-error" : undefined}
{...register("name")}
/>
{errors.name && (
<p id="name-error" role="alert" className="text-sm text-destructive">
{errors.name.message}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<Input
placeholder="メールアドレス"
aria-label="メールアドレス"
aria-invalid={errors.email !== undefined}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert" className="text-sm text-destructive">
{errors.email.message}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<Input
type="number"
placeholder="年齢"
aria-label="年齢"
aria-invalid={errors.age !== undefined}
aria-describedby={errors.age ? "age-error" : undefined}
// 入力欄の値は文字列なので、数値に変換してから検査に渡す
{...register("age", { valueAsNumber: true })}
/>
{errors.age && (
<p id="age-error" role="alert" className="text-sm text-destructive">
{errors.age.message}
</p>
)}
</div>
<Button type="submit" size="sm" disabled={isSubmitting}>
{isSubmitting ? "送信中…" : "送信"}
</Button>
{sent && (
<p className="text-sm text-muted-foreground">
送信しました: {sent.name} / {sent.email} / {sent.age} 歳
</p>
)}
</form>
);
}できあがり
打っても数字が増えない。空のまま送信するとエラーが出る
試してみてください。
- 打っても数字が増えません(エラーが出たときだけ増えます)。 なおこちらは、開いた時点で react-hook-form の準備のぶん
render 2から始まります - 空のまま送信すると、3 つとも同時にエラーが出ます
- 一度エラーが出た欄は、打ち直すとその場で消えます
- 送信中はボタンが「送信中…」になり、押せなくなります
3 つめ(打ち直すと消える)は、何も書かなくても既定でそうなります。 4 つめは isSubmitting のおかげ、 最初にエラーを出す時機は mode: "onTouched" です。 Part 5 で自分で組み立てた「いつエラーを出すか」の作法が、設定 1 つで手に入っています。
const onSubmit = handleSubmit(async (values) => {
// 検査を通ったときだけ、ここが呼ばれる
});handleSubmit が検査を済ませてから呼ぶので、この中では値が正しいことが保証されています。 (Part 5 で出てきた preventDefault も、 こちらでやってくれます)values には型も付いています。
rhf-form.tsx
"use client";
import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { z } from "zod";
// 入力の決まりを 1 か所に書く。エラー文もここに書く
const schema = z.object({
name: z.string().min(1, "名前を入力してください"),
email: z.email("メールアドレスの形式が正しくありません"),
age: z
.number({ error: "数字を入力してください" })
.min(18, "18 歳以上で入力してください"),
});
// 決まりから型を作る。型を別に書かなくてよい
type FormValues = z.infer<typeof schema>;
export function RhfForm() {
useTrackDemoRender();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
// 一度エラーを出した項目だけ、打つたびに再判定する
mode: "onTouched",
});
const [sent, setSent] = useState<FormValues | null>(null);
// 検査を通ったときだけ、この中が呼ばれる
const onSubmit = handleSubmit(async (values) => {
await new Promise((resolve) => setTimeout(resolve, 600));
setSent(values);
});
return (
<form onSubmit={onSubmit} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
{/* value も onChange も書かない。register が繋いでくれる */}
<Input
placeholder="名前"
aria-label="名前"
aria-invalid={errors.name !== undefined}
aria-describedby={errors.name ? "name-error" : undefined}
{...register("name")}
/>
{errors.name && (
<p id="name-error" role="alert" className="text-sm text-destructive">
{errors.name.message}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<Input
placeholder="メールアドレス"
aria-label="メールアドレス"
aria-invalid={errors.email !== undefined}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert" className="text-sm text-destructive">
{errors.email.message}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<Input
type="number"
placeholder="年齢"
aria-label="年齢"
aria-invalid={errors.age !== undefined}
aria-describedby={errors.age ? "age-error" : undefined}
// 入力欄の値は文字列なので、数値に変換してから検査に渡す
{...register("age", { valueAsNumber: true })}
/>
{errors.age && (
<p id="age-error" role="alert" className="text-sm text-destructive">
{errors.age.message}
</p>
)}
</div>
<Button type="submit" size="sm" disabled={isSubmitting}>
{isSubmitting ? "送信中…" : "送信"}
</Button>
{sent && (
<p className="text-sm text-muted-foreground">
送信しました: {sent.name} / {sent.email} / {sent.age} 歳
</p>
)}
</form>
);
}いつ使うか
いつでも使うべき、ではありません。覚えることが増えるからです。
| フォーム | 選ぶもの |
|---|---|
| 検索窓 1 つ、チェックボックス 1 つ | useState |
| 打つたびに他の表示が変わる(文字数カウントなど) | useState |
| 項目が 3 つ以上あり、検査が要る | React Hook Form |
| 項目の追加・削除ができる(動的なフォーム) | React Hook Form |
2 行目に注意してください。入力中の値を画面に出したい場合は、 結局その値を見張ることになるので、 非制御の利点が薄れます。
rhf-form.tsx
"use client";
import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { z } from "zod";
// 入力の決まりを 1 か所に書く。エラー文もここに書く
const schema = z.object({
name: z.string().min(1, "名前を入力してください"),
email: z.email("メールアドレスの形式が正しくありません"),
age: z
.number({ error: "数字を入力してください" })
.min(18, "18 歳以上で入力してください"),
});
// 決まりから型を作る。型を別に書かなくてよい
type FormValues = z.infer<typeof schema>;
export function RhfForm() {
useTrackDemoRender();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
// 一度エラーを出した項目だけ、打つたびに再判定する
mode: "onTouched",
});
const [sent, setSent] = useState<FormValues | null>(null);
// 検査を通ったときだけ、この中が呼ばれる
const onSubmit = handleSubmit(async (values) => {
await new Promise((resolve) => setTimeout(resolve, 600));
setSent(values);
});
return (
<form onSubmit={onSubmit} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
{/* value も onChange も書かない。register が繋いでくれる */}
<Input
placeholder="名前"
aria-label="名前"
aria-invalid={errors.name !== undefined}
aria-describedby={errors.name ? "name-error" : undefined}
{...register("name")}
/>
{errors.name && (
<p id="name-error" role="alert" className="text-sm text-destructive">
{errors.name.message}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<Input
placeholder="メールアドレス"
aria-label="メールアドレス"
aria-invalid={errors.email !== undefined}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert" className="text-sm text-destructive">
{errors.email.message}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<Input
type="number"
placeholder="年齢"
aria-label="年齢"
aria-invalid={errors.age !== undefined}
aria-describedby={errors.age ? "age-error" : undefined}
// 入力欄の値は文字列なので、数値に変換してから検査に渡す
{...register("age", { valueAsNumber: true })}
/>
{errors.age && (
<p id="age-error" role="alert" className="text-sm text-destructive">
{errors.age.message}
</p>
)}
</div>
<Button type="submit" size="sm" disabled={isSubmitting}>
{isSubmitting ? "送信中…" : "送信"}
</Button>
{sent && (
<p className="text-sm text-muted-foreground">
送信しました: {sent.name} / {sent.email} / {sent.age} 歳
</p>
)}
</form>
);
}理解できたか確かめる
確認クイズ
React Hook Form で打っても再レンダリングが起きないのはなぜ?
確認クイズ
z.infer<typeof schema> は何をしている?
確認クイズ
検索窓 1 つのフォームに React Hook Form を使うべき?
rhf-form.tsx
"use client";
import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { z } from "zod";
// 入力の決まりを 1 か所に書く。エラー文もここに書く
const schema = z.object({
name: z.string().min(1, "名前を入力してください"),
email: z.email("メールアドレスの形式が正しくありません"),
age: z
.number({ error: "数字を入力してください" })
.min(18, "18 歳以上で入力してください"),
});
// 決まりから型を作る。型を別に書かなくてよい
type FormValues = z.infer<typeof schema>;
export function RhfForm() {
useTrackDemoRender();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
// 一度エラーを出した項目だけ、打つたびに再判定する
mode: "onTouched",
});
const [sent, setSent] = useState<FormValues | null>(null);
// 検査を通ったときだけ、この中が呼ばれる
const onSubmit = handleSubmit(async (values) => {
await new Promise((resolve) => setTimeout(resolve, 600));
setSent(values);
});
return (
<form onSubmit={onSubmit} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
{/* value も onChange も書かない。register が繋いでくれる */}
<Input
placeholder="名前"
aria-label="名前"
aria-invalid={errors.name !== undefined}
aria-describedby={errors.name ? "name-error" : undefined}
{...register("name")}
/>
{errors.name && (
<p id="name-error" role="alert" className="text-sm text-destructive">
{errors.name.message}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<Input
placeholder="メールアドレス"
aria-label="メールアドレス"
aria-invalid={errors.email !== undefined}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert" className="text-sm text-destructive">
{errors.email.message}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<Input
type="number"
placeholder="年齢"
aria-label="年齢"
aria-invalid={errors.age !== undefined}
aria-describedby={errors.age ? "age-error" : undefined}
// 入力欄の値は文字列なので、数値に変換してから検査に渡す
{...register("age", { valueAsNumber: true })}
/>
{errors.age && (
<p id="age-error" role="alert" className="text-sm text-destructive">
{errors.age.message}
</p>
)}
</div>
<Button type="submit" size="sm" disabled={isSubmitting}>
{isSubmitting ? "送信中…" : "送信"}
</Button>
{sent && (
<p className="text-sm text-muted-foreground">
送信しました: {sent.name} / {sent.email} / {sent.age} 歳
</p>
)}
</form>
);
}この章のまとめ
- 制御コンポーネントは、1 文字ごとにフォーム全体を描き直している
- React Hook Form は値を state に持たないので、 打っても描き直されない
- zod で決まりを 1 か所に書き、そこから型も作る
handleSubmitの中は、検査を通った値だけが来る- 小さいフォームには要らない。項目が増えて検査が要るようになってから
rhf-form.tsx
"use client";
import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { z } from "zod";
// 入力の決まりを 1 か所に書く。エラー文もここに書く
const schema = z.object({
name: z.string().min(1, "名前を入力してください"),
email: z.email("メールアドレスの形式が正しくありません"),
age: z
.number({ error: "数字を入力してください" })
.min(18, "18 歳以上で入力してください"),
});
// 決まりから型を作る。型を別に書かなくてよい
type FormValues = z.infer<typeof schema>;
export function RhfForm() {
useTrackDemoRender();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
// 一度エラーを出した項目だけ、打つたびに再判定する
mode: "onTouched",
});
const [sent, setSent] = useState<FormValues | null>(null);
// 検査を通ったときだけ、この中が呼ばれる
const onSubmit = handleSubmit(async (values) => {
await new Promise((resolve) => setTimeout(resolve, 600));
setSent(values);
});
return (
<form onSubmit={onSubmit} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
{/* value も onChange も書かない。register が繋いでくれる */}
<Input
placeholder="名前"
aria-label="名前"
aria-invalid={errors.name !== undefined}
aria-describedby={errors.name ? "name-error" : undefined}
{...register("name")}
/>
{errors.name && (
<p id="name-error" role="alert" className="text-sm text-destructive">
{errors.name.message}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<Input
placeholder="メールアドレス"
aria-label="メールアドレス"
aria-invalid={errors.email !== undefined}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert" className="text-sm text-destructive">
{errors.email.message}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<Input
type="number"
placeholder="年齢"
aria-label="年齢"
aria-invalid={errors.age !== undefined}
aria-describedby={errors.age ? "age-error" : undefined}
// 入力欄の値は文字列なので、数値に変換してから検査に渡す
{...register("age", { valueAsNumber: true })}
/>
{errors.age && (
<p id="age-error" role="alert" className="text-sm text-destructive">
{errors.age.message}
</p>
)}
</div>
<Button type="submit" size="sm" disabled={isSubmitting}>
{isSubmitting ? "送信中…" : "送信"}
</Button>
{sent && (
<p className="text-sm text-muted-foreground">
送信しました: {sent.name} / {sent.email} / {sent.age} 歳
</p>
)}
</form>
);
}