- React
React Hook Formの使い方|zod v4連携のコピペ例とエラー対処
React Hook Formとは、入力欄の値を React の state ではなく DOM(非制御コンポーネント)で持ち、少ない再レンダーでフォームの入力・検証・送信を扱えるライブラリです。 zod と組み合わせると、「入力ルール」と「TypeScript の型」を1つのスキーマにまとめられます。
この記事では、react-hook-form 7.89.0、zod 4.6.5、@hookform/resolvers 5.9.1(2026年10月時点の npm 最新版) で、基本の register から useFieldArray、条件付き検証、transform、サーバーエラーの表示までを1つの完成コードにまとめました。Vite 8 + Playwright 1.56 でブラウザ操作した結果と、zod v3 の書き方を v4 で tsc にかけたときの実際の型エラーを載せています。zod 単体の API 一覧は別記事「Zodの使い方|v4対応のAPI早見表とエラー出力つき実例3つ」にまとめているので、ここでは React Hook Form と組み合わせる部分に集中します。
React Hook Form + zod の早見表
| やりたいこと | 書き方 | ポイント |
|---|---|---|
| 入力欄をつなぐ | {...register('name')} |
name・onChange・onBlur・ref が入る |
| 送信する | handleSubmit(onSubmit) |
検証が通ったときだけ onSubmit が呼ばれる |
| エラーを表示する | formState.errors.name?.message |
ネストは errors.items?.[0]?.qty |
| 初期値を入れる | useForm({ defaultValues }) |
全フィールドに入れると制御・非制御の警告を防げる |
| zod でまとめて検証 | resolver: zodResolver(schema) |
@hookform/resolvers/zod から import |
| 入力と出力で型を分ける | useForm<z.input<typeof s>, unknown, z.output<typeof s>> |
z.coerce・transform を使うとき |
| 行を増やす・消す | useFieldArray({ control, name: 'items' }) |
key には field.id を使う |
| 2項目を比べる | .refine(fn, { path: ['confirm'] }) |
path でエラーの表示先を決める |
| 選択によって必須を変える | z.discriminatedUnion('plan', [...]) |
条件ごとに別スキーマ |
| 文字列を数値に | z.coerce.number<string>() |
v4 は入力型が unknown になる点に注意 |
| サーバーのエラーを出す | setError('email', { type: 'server', message }) |
root.xxx でフォーム全体のエラー |
| UI 部品をつなぐ | <Controller render={({ field }) => ...} /> |
ref を受け取らない部品向け |
| 値を見る | watch('plan') / useWatch({ control, name }) |
useWatch は使った部品だけ再レンダー |
| 検証のタイミング | mode: 'onTouched' など |
既定は onSubmit |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「React Hook Formの使い方|zod v4連携のコピペ例とエラー対処」
動作確認した環境
| 項目 | バージョン |
|---|---|
| Node.js | 22.22.2 |
| react / react-dom | 19.3.0 |
| react-hook-form | 7.89.0 |
| zod | 4.6.5 |
| @hookform/resolvers | 5.9.1 |
| vite / @vitejs/plugin-react | 8.3.2 / 6.1.1 |
| TypeScript | 7.0.2 |
| @playwright/test | 1.56.1(Chromium 141) |
npm init -y && npm pkg set type=module
npm i react react-dom react-hook-form zod @hookform/resolvers
npm i -D vite @vitejs/plugin-react typescript @types/react @types/react-dom @playwright/test
"type": "module" を入れておくと、Vite 8 の設定ファイル読み込みで出る CommonJS の警告を避けられます。
基本の4つ:register・handleSubmit・errors・defaultValues
まず zod を使わない最小形です。register が入力欄と React Hook Form をつなぎ、handleSubmit が検証してから送信関数を呼ぶ、という役割分担を押さえれば十分です。
import { useForm } from 'react-hook-form'
type Values = { title: string; qty: number }
export function MiniForm() {
const { register, handleSubmit, formState: { errors } } = useForm<Values>({
defaultValues: { title: '', qty: 1 },
})
return (
<form onSubmit={handleSubmit((v) => console.log(v))} noValidate>
<input {...register('title', { required: 'タイトルは必須です' })} />
<p>{errors.title?.message}</p>
<input type="number" {...register('qty', { valueAsNumber: true, min: { value: 1, message: '1以上' } })} />
<p>{errors.qty?.message}</p>
<button>送信</button>
</form>
)
}
| 要素 | 役割 | つまずきやすい点 |
|---|---|---|
register(name, rules) |
入力欄の登録と組み込みルール | 数値は valueAsNumber: true がないと文字列のまま |
handleSubmit(onValid, onInvalid?) |
検証→送信 | onInvalid でエラー時の処理も書ける |
formState.errors |
フィールドごとのエラー | 分割代入で取り出すと購読が有効になる |
defaultValues |
初期値とリセット先 | reset() はこの値に戻る |
ルールが3つ以上になったら zod に移すと、型とルールが1か所にまとまり読みやすくなります。
zod v4 と連携する完成コード
ここからが本題です。会員登録と備品申請を合わせた架空のフォームで、基本・配列・条件付き必須・transform・サーバーエラー・Controller・useWatch をすべて1つにまとめました。2ファイルをそのまま貼れば動きます。
スキーマ(src/schema.ts)
import { z } from 'zod'
const itemSchema = z.object({
name: z.string().min(1, { error: '備品名を入力してください' }),
qty: z.coerce.number<string>().int().min(1, { error: '1以上を入力してください' }),
})
const base = z.object({
name: z.string().trim().min(1, { error: '名前を入力してください' }),
email: z.email({ error: 'メールアドレスの形式で入力してください' }),
age: z.coerce.number<string>({ error: '数字で入力してください' }).int().min(18, { error: '18以上を入力してください' }),
password: z.string().min(8, { error: '8文字以上で入力してください' }),
confirm: z.string(),
items: z.array(itemSchema).min(1, { error: '1行以上追加してください' }),
})
// 条件付き:plan が team のときだけ teamName を必須にする
const plan = z.discriminatedUnion('plan', [
z.object({ plan: z.literal('personal') }),
z.object({ plan: z.literal('team'), teamName: z.string().min(1, { error: 'チーム名を入力してください' }) }),
])
export const schema = z
.intersection(base, plan)
.refine((v) => v.password === v.confirm, { error: 'パスワードが一致しません', path: ['confirm'] })
.transform((v) => ({ ...v, name: v.name.toUpperCase() }))
export type FormInput = z.input<typeof schema> // 入力欄が持つ型(age は string)
export type FormOutput = z.output<typeof schema> // onSubmit が受け取る型(age は number)
zod v4 ではエラー文の指定が message から error に変わりました。 message も非推奨ながら動きますが、新しく書くなら error にそろえるのがおすすめです。
フォーム(src/App.tsx)
import { useState } from 'react'
import { useForm, useFieldArray, Controller, useWatch, type Control } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { schema, type FormInput, type FormOutput } from './schema'
// UIライブラリ風の部品(ref を受け取らず value / onChange だけを持つ)
function PlanSelect(props: { value: string; onChange: (v: string) => void; onBlur: () => void }) {
return (
<div role="radiogroup" onBlur={props.onBlur}>
{['personal', 'team'].map((p) => (
<button type="button" key={p} aria-pressed={props.value === p} onClick={() => props.onChange(p)}>{p}</button>
))}
</div>
)
}
// useWatch:この部品だけが再レンダーされる
function ItemCount({ control }: { control: Control<FormInput, unknown, FormOutput> }) {
const items = useWatch({ control, name: 'items' })
return <p id="count">備品 {items?.length ?? 0} 行</p>
}
async function fakeApi(data: FormOutput) {
await new Promise((r) => setTimeout(r, 100))
if (data.email === 'taken@example.com') return { ok: false as const, field: 'email' as const, message: 'このメールアドレスは登録済みです' }
return { ok: true as const }
}
export default function App() {
const [result, setResult] = useState('')
const { register, control, handleSubmit, setError, watch, formState: { errors, isSubmitting } } =
useForm<FormInput, unknown, FormOutput>({
resolver: zodResolver(schema),
mode: 'onTouched',
defaultValues: { name: '', email: '', age: '', password: '', confirm: '', plan: 'personal', items: [{ name: '', qty: '1' }] },
})
const { fields, append, remove } = useFieldArray({ control, name: 'items' })
const plan = watch('plan')
const onSubmit = async (data: FormOutput) => {
const res = await fakeApi(data)
if (!res.ok) {
setError(res.field, { type: 'server', message: res.message }, { shouldFocus: true })
setError('root.server', { type: '409', message: '登録できませんでした' })
return
}
setResult(JSON.stringify(data))
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input placeholder="名前" {...register('name')} />
<p id="err-name">{errors.name?.message}</p>
<input placeholder="メール" {...register('email')} />
<p id="err-email">{errors.email?.message}</p>
<input placeholder="年齢" {...register('age')} />
<p id="err-age">{errors.age?.message}</p>
<input placeholder="パスワード" type="password" {...register('password')} />
<p id="err-password">{errors.password?.message}</p>
<input placeholder="確認" type="password" {...register('confirm')} />
<p id="err-confirm">{errors.confirm?.message}</p>
<Controller control={control} name="plan"
render={({ field }) => <PlanSelect value={field.value} onChange={field.onChange} onBlur={field.onBlur} />} />
{plan === 'team' && (
<>
<input placeholder="チーム名" {...register('teamName')} />
<p id="err-team">{'teamName' in errors ? errors.teamName?.message : ''}</p>
</>
)}
{fields.map((f, i) => (
<div key={f.id}>
<input placeholder={`備品${i}`} {...register(`items.${i}.name`)} />
<input placeholder={`数量${i}`} {...register(`items.${i}.qty`)} />
<button type="button" onClick={() => remove(i)}>削除{i}</button>
<p id={`err-item-${i}`}>{errors.items?.[i]?.name?.message}{errors.items?.[i]?.qty?.message}</p>
</div>
))}
<p id="err-items">{errors.items?.root?.message ?? errors.items?.message}</p>
<button type="button" onClick={() => append({ name: '', qty: '1' })}>行を追加</button>
<ItemCount control={control} />
<p id="err-root">{errors.root?.server?.message}</p>
<button type="submit" disabled={isSubmitting}>送信</button>
<pre id="result">{result}</pre>
</form>
)
}
npx tsc -p . はエラー0件で通りました(strict: true)。
Playwright でブラウザ動作を確かめた結果
上のフォームを npx vite --port 7260 で起動し、Playwright 1.56.1(Chromium)で操作しました。テストの中で console.log した内容をそのまま載せます。
何も入力せずに送信
name => 名前を入力してください
email => メールアドレスの形式で入力してください
age => 18以上を入力してください
password => 8文字以上で入力してください
item0 => 備品名を入力してください
✓ 1 e2e/form.spec.ts:3:1 › 空のまま送信するとエラーが並ぶ (511ms)
空文字の age は z.coerce で 0 になるため、「数字で入力してください」ではなく「18以上」のエラーになります。 空欄を「未入力」として扱いたい場合は、後述の「入力型と出力型」の節の対処を使います。
正しく入力して送信(transform 後の値)
名前に " sato "、備品を2行入力して送信した結果です。
result => {"name":"SATO","email":"user@example.com","age":20,"password":"abcdefgh","confirm":"abcdefgh","items":[{"name":"ノートPC","qty":1},{"name":"モニター","qty":2}],"plan":"personal"}
trim と transform が効いて "SATO" に、age と qty は文字列から数値になっています。 onSubmit には zod の「出力」が届く、というのが React Hook Form + zod の大事な性質です。
useFieldArray で行を増やす・消す
| メソッド | 動き | 使いどころ |
|---|---|---|
append(obj) |
末尾に追加 | 「行を追加」ボタン |
prepend(obj) |
先頭に追加 | 新しい順に並べる |
insert(i, obj) |
i 番目に追加 | 途中に差し込む |
remove(i) |
i 番目を削除(引数なしで全削除) | 「削除」ボタン |
move(from, to) / swap(a, b) |
並べ替え | ドラッグで並べ替え |
update(i, obj) |
1行を置き換え | 行ごとの再入力 |
replace(arr) |
配列ごと置き換え | 読み込み直し |
key には配列の添字ではなく field.id を使います。 添字を key にすると、行を削除したときに入力値が隣の行にずれて見えることがあります。
1行目を削除して0行で送信した結果です。
items => 1行以上追加してください / 備品 0 行
配列そのものへのエラー(min(1) など)は errors.items.root または errors.items.message に入ります。 行ごとのエラーは errors.items[0].name です。表示先を2か所用意しておくと取りこぼしません。
条件付き検証:refine・superRefine・discriminatedUnion
| 方法 | 向いている場面 | エラーの出し先 |
|---|---|---|
.refine(fn, { path }) |
2項目の比較(パスワード確認、開始日と終了日) | path で指定した欄 |
.superRefine((v, ctx) => ctx.addIssue(...)) |
1回の判定で複数のエラーを出したい | issue ごとに path を指定 |
z.discriminatedUnion('plan', [...]) |
選択肢ごとに必須項目が変わる | 各スキーマの欄 |
.check()(v4 で追加) |
低レベルの検証。詳しい書き方は公式ドキュメントで確認 | issue の path |
plan で team を選び、パスワードを不一致にして送信した結果です。
team => チーム名を入力してください
confirm => パスワードが一致しません
✓ 2 e2e/form.spec.ts:11:1 › teamを選ぶとteamNameが必須になる・パスワード不一致 (535ms)
discriminatedUnion を使うと「team のときだけ teamName 必須」が型にも反映されます。 そのため errors.teamName は 'teamName' in errors で絞り込んでから読む形になります。
なお、Playwright で確認中に気づいた点として、mode: 'onTouched' では確認欄から送信ボタンへ移った瞬間に blur 検証が走り、エラー文が表示されて送信ボタンの位置が下がります。 自動テストでクリックが空振りしたため、テストでは「blur → エラー表示を待つ → 送信」の順にしています。画面でもエラー文の領域を最初から確保しておくと、ボタン位置が安定します。
transform と z.coerce:入力の型と出力の型
zod v4 の z.coerce.number() は入力の型が unknown になりました(公式の v4 変更点に記載)。React Hook Form では入力欄の型(input)と onSubmit の型(output)がずれるため、ここで型エラーが出やすくなります。
よくある型エラー(tsc の実際の出力)
const s1 = z.object({ age: z.coerce.number().min(18) })
const f = useForm<z.infer<typeof s1>>({ resolver: zodResolver(s1), defaultValues: { age: 20 } })
src/typecheck.tsx(7,43): error TS2322: Type 'Resolver<{ age: unknown; }, any, { age: number; }>' is not assignable to type 'Resolver<{ age: number; }, any, { age: number; }>'.
Types of parameters 'options' and 'options' are incompatible.
Type 'ResolverOptions<{ age: number; }>' is not assignable to type 'ResolverOptions<{ age: unknown; }>'.
Type '{ age: unknown; }' is not assignable to type '{ age: number; }'.
Types of property 'age' are incompatible.
Type 'unknown' is not assignable to type 'number'.
z.infer(=出力の型)を1つだけ渡しているのが原因です。 直し方は2通りあり、どちらも tsc でエラー0件を確認しました。
| 直し方 | コード | 向いている場面 |
|---|---|---|
| ① 型引数を省いて推論させる | useForm({ resolver: zodResolver(s2), defaultValues: { age: '' } }) |
小さなフォーム。onSubmit の d.age は number になる |
| ② 入力と出力を明示する | useForm<z.input<typeof s>, unknown, z.output<typeof s>>(...) |
部品に Control 型を渡すとき |
| ③ coerce に入力型を指定 | z.coerce.number<string>() |
入力欄の値を string と明示したい |
@hookform/resolvers v5 は zodResolver の戻り値に input と output の両方の型を持たせるため、①のように型引数を省くと正しく推論されます。完成コードでは②と③を組み合わせています。
空欄を「未入力」として扱う
空文字を 0 にしたくない場合は、数値変換の前に空文字を弾きます。
const age = z.string()
.min(1, { error: '年齢を入力してください' })
.pipe(z.coerce.number<string>().int().min(18, { error: '18以上を入力してください' }))
サーバーのエラーを setError で表示する
| 書き方 | 表示先 | 次の送信で |
|---|---|---|
setError('email', { type: 'server', message }) |
その欄 | 再検証で消える |
setError('root.server', { message }) |
フォーム全体 | 送信時に自動でクリア |
setError(name, err, { shouldFocus: true }) |
その欄+フォーカス移動 | 再検証で消える |
clearErrors('email') |
手動で消す | ― |
登録済みのメールアドレスで送信した結果です。
email => このメールアドレスは登録済みです / root => 登録できませんでした
focused => メール
shouldFocus: true でメール欄にフォーカスが移っていることも確認できました。 API が返すエラーの欄名(field)と register の名前をそろえておくと、setError(res.field, ...) の1行で済みます。
Controller で UI ライブラリの部品をつなぐ
register は ref で DOM を直接読むので、ref を受け取らない部品(独自のセレクト、日付ピッカー、トグルなど)は Controller でつなぎます。
| 方法 | 向いている部品 | 値の持ち方 |
|---|---|---|
register |
input / select / textarea、ref を転送する部品 | 非制御(DOM) |
Controller |
value と onChange だけを持つ部品 | 制御(React Hook Form が value を渡す) |
useController |
自作の入力部品をフック化したい | Controller と同じ |
完成コードの PlanSelect は、UI ライブラリ風に value と onChange だけを持つ部品です。field.onBlur もつなぐと、mode が onBlur / onTouched のときに検証が走ります。 実在の UI ライブラリ(MUI、Mantine など)での動作はこの記事では未実行のため、各ライブラリの公式ドキュメントで props 名(checked か value か等)を確認してください。
watch と useWatch の違い(再レンダー回数を計測)
watch は呼んだコンポーネント(ここではフォーム全体)を、useWatch は呼んだ部品だけを再レンダーします。 完成コードに描画回数のカウンターを足し、Playwright で1文字ずつ入力して数えました。
初期表示 App=2 ItemCount=2
名前を5文字入力 App=2 ItemCount=2
備品名を5文字入力 App=2 ItemCount=7
planをteamに App=3 ItemCount=8
| 操作 | App(watch('plan')) | ItemCount(useWatch items) |
|---|---|---|
| 名前を5文字入力 | 増えない | 増えない |
| 備品名を5文字入力 | 増えない | +5 |
| plan を切り替え | +1 | +1(親の再レンダーにつられて) |
名前を指定した watch は、その欄が変わったときだけ再レンダーします。 備品名の入力では App は再レンダーされず、useWatch を使った ItemCount だけが描き直されています。
| 比べる点 | watch | useWatch | getValues |
|---|---|---|---|
| 再レンダーする範囲 | 呼んだコンポーネント | 呼んだ部品だけ | しない |
| 使う場所 | フォームのルート | 子部品(control を渡す) | イベント処理の中 |
| 向いている用途 | 表示の切り替え(team のときだけ欄を出す) | 合計・件数などの表示 | 送信前に値を読むだけ |
mode の違い(検証のタイミング)
| mode | 最初の検証 | 2回目以降 | 向いているフォーム |
|---|---|---|---|
onSubmit(既定) |
送信時 | reValidateMode(既定 onChange) | 短いフォーム |
onBlur |
欄を離れたとき | 欄を離れたとき | 入力途中のエラーを出したくない |
onChange |
入力ごと | 入力ごと | 文字数カウントなど即時に知らせたい |
onTouched |
最初の blur | 以降は入力ごと | 登録フォームでバランスがよい |
all |
blur と入力ごと | blur と入力ごと | 細かく知らせたい |
onTouched の動きを確かめた結果です。
入力中(未blur) => ""
blur後 => メールアドレスの形式で入力してください
再入力 => ""
入力中はエラーを出さず、欄を離れて初めて表示し、直したらすぐ消える、という流れになっています。
zod v3 → v4 で出る型エラーと直し方(tsc 実行結果)
v3 の書き方を zod 4.6.5 で tsc にかけました。
// 直す前(v3 の書き方)
const s = z.object({
name: z.string({ required_error: '必須です', invalid_type_error: '文字列で入力' }),
email: z.string().email({ message: '形式が違います' }),
tags: z.record(z.string()),
})
const r = s.safeParse({})
if (!r.success) console.log(r.error.errors)
const pw = z.object({ a: z.string(), b: z.string() }).superRefine((v, ctx) => {
if (v.a !== v.b) ctx.addIssue({ code: z.ZodIssueCode.custom, message: '不一致', path: [...ctx.path, 'b'] })
})
mig/before.ts(4,20): error TS2769: No overload matches this call.
The last overload gave the following error.
Object literal may only specify known properties, and 'required_error' does not exist in type '$ZodStringParams'.
mig/before.ts(6,11): error TS2554: Expected 2-3 arguments, but got 1.
mig/before.ts(11,23): error TS2339: Property 'errors' does not exist on type 'ZodError<{ name: string; email: string; tags: Record<string, unknown>; }>'.
mig/before.ts(15,94): error TS2339: Property 'path' does not exist on type '$RefinementCtx<{ a: string; b: string; }>'.
型エラーになったのは4か所でした。 z.string().email({ message }) は非推奨ですが型エラーにはならず、そのまま動きます。
// 直した後(v4)
const s = z.object({
name: z.string({
error: (iss) => (iss.input === undefined ? '必須です' : '文字列で入力'),
}),
email: z.email({ error: '形式が違います' }),
tags: z.record(z.string(), z.string()),
})
const r = s.safeParse({})
if (!r.success) {
console.log(r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`))
console.log(JSON.stringify(z.flattenError(r.error).fieldErrors))
}
const pw = z.object({ a: z.string(), b: z.string() }).superRefine((v, ctx) => {
if (v.a !== v.b) ctx.addIssue({ code: 'custom', message: '不一致', path: ['b'] })
})
console.log(pw.safeParse({ a: 'x', b: 'y' }).error?.issues[0].path)
[
'name: 必須です',
'email: 形式が違います',
'tags: Invalid input: expected record, received undefined'
]
{"name":["必須です"],"email":["形式が違います"],"tags":["Invalid input: expected record, received undefined"]}
[ 'b' ]
| v3 | v4 | tsc の結果 |
|---|---|---|
required_error / invalid_type_error |
error: (iss) => ... で iss.input を見て分ける |
TS2769 |
z.record(値) |
z.record(キー, 値) |
TS2554 |
error.errors |
error.issues |
TS2339 |
ctx.path |
使えない。path: ['b'] を直接書く |
TS2339 |
{ message } |
{ error } |
非推奨(型エラーなし) |
z.string().email() |
z.email() |
非推奨(型エラーなし) |
error.flatten() / format() |
z.flattenError() / z.treeifyError() |
非推奨 |
.merge() |
.extend() |
公式の移行ガイドで確認 |
@hookform/resolvers は v5 で zod v4 に対応しています。 zod を v4 に上げるときは resolvers も v5 にそろえます。
よくあるつまずきと直し方
| 症状 | 原因 | 直し方 |
|---|---|---|
| 数値欄の値が文字列のまま届く | input の値は常に文字列 | z.coerce.number<string>() か valueAsNumber |
| 空欄が 0 として通る | coerce が "" を 0 にする |
z.string().min(1).pipe(z.coerce.number()) |
Resolver<...> is not assignable |
useForm<z.infer> で入力型と出力型が混ざる |
型引数を省く/input・output を明示 |
| 行を消すと値が隣にずれる | key に添字を使っている | key={field.id} |
| refine のエラーが出ない | path を付けていない | { path: ['confirm'] } |
| UI 部品の値が入らない | ref を受け取らない部品に register | Controller を使う |
| サーバーエラーが残り続ける | 欄のエラーは送信で消えない | clearErrors か root を使う |
よくある質問
Q. zod なしの React Hook Form だけでも足りますか? A. 項目が少なく、ルールが required や min 程度なら register のルールで十分です。項目間の比較や条件付き必須が出てきたら zod に移すと、ルールと型を1か所で管理できます。
Q. zodResolver の import 先はどこですか?
A. import { zodResolver } from '@hookform/resolvers/zod' です。@hookform/resolvers 5.9.1 で zod 4.6.5 のスキーマをそのまま渡せることを確認しました。
Q. onSubmit で受け取る値は入力欄の値と同じですか?
A. zod の出力(transform・coerce 後)です。 今回の実行結果でも " sato " が "SATO" に、"20" が 20 になって届きました。
Q. サーバー側でも同じ zod スキーマを使えますか?
A. 使えます。スキーマを別ファイルにしておけば、API 側の schema.safeParse(body) でも同じルールで検証できます。
まとめ
- register・handleSubmit・errors・defaultValues の4つで基本のフォームが作れる
- zodResolver でルールと型を1つのスキーマにまとめる(resolvers v5 は zod v4 対応)
- onSubmit に届くのは zod の出力。z.coerce・transform 後の値になる
- 型エラーは入力型と出力型のずれが原因。型引数を省くか input・output を明示する
- 条件付き必須は discriminatedUnion、2項目比較は refine+path
- サーバーのエラーは setError、フォーム全体は root、shouldFocus で欄に移動
- useWatch は使った部品だけ再レンダー。名前指定の watch はその欄の変化だけで再レンダー
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「React Hook Formの使い方|zod v4連携のコピペ例とエラー対処」
株式会社RJCでは、React Hook Form と zod を使ったフォームの組み立て方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。