Claude CodeでReact Hook FormとZodのフォームを作る手順
Zodのスキーマを先に決め、React Hook Formの検証・エラー表示・テストを同じスキーマから作らせる手順を、CLAUDE.mdとrulesの書き方、つまずきと合わせてまとめます。
React Hook FormとZodのフォームをClaude Codeに作らせるときは、画面より先にZodのスキーマを1枚書かせます。検証ルール、エラー文言、フォームの型、テストの入力例を、すべてそのスキーマから導く形にすると、Claudeが画面側とテスト側で別々のルールを作ってしまう事故を避けられます。
この記事では、スキーマ先行の規約をCLAUDE.mdと.claude/rules/に置き、フォーム部品とテストを生成させ、最後にClaudeへ検証コマンドを走らせる流れを、フォームの題材としてサインアップ画面を使って示します。
スキーマを正にすると、何が1か所にまとまるか
フォームの検証は、放っておくと3か所に散ります。入力欄のrequiredのような属性、送信時のif文、テストの期待値です。Claudeに「サインアップフォームを作って」とだけ頼むと、この3つが少しずつ食い違った状態で返ってきがちです。
スキーマを正にすると、役割が次のように分かれます。
スキーマ1枚から導くもの
検証ルールとエラー文言
Zodのスキーマに書きます。文言は
errorパラメータで、フィールドごとに指定できます。フォームの型
z.inputとz.outputで、入力途中の型と検証後の型を取り出します。画面の配線
React Hook Formの
resolverにスキーマを渡し、検証をスキーマへ委ねます。テスト
スキーマ単体を
safeParseで検証し、画面のテストはそのエラーが表示されるかだけを見ます。
React Hook Formのresolverは、スキーマをフォーム全体の値に対して実行し、型付きの値か検証エラーを返す仕組みです。対応ライブラリにはZodのほか、Yup、Valibot、Standard Schemaを実装した任意のライブラリが挙がっています。この記事はZodで進めますが、Claudeに渡す規約の骨格は他のスキーマ系でも変わりません。
CLAUDE.mdとrulesに「スキーマ先行」の規約を置く
Claudeは毎回のセッションでCLAUDE.mdを読みますが、これは強制される設定ではなく文脈として渡されます。守らせたければ、検証できる具体さで書くのが基本です。「2スペースでインデントする」は効き、「きれいに整形する」は効きにくい、というのがmemoryページの書き方の説明です。
フォームの規約は、どのセッションでも要る内容ではありません。フォームを触るときだけ読ませたいので、paths付きのrulesに分けるのが向きます。パスを指定したrulesは、Claudeが一致するファイルをRead、Write、Editしたときに読み込まれます。
---
paths:
- "src/features/**/*.tsx"
- "src/features/**/schema.ts"
---
# フォーム実装ルール
- 検証ルールとエラー文言は、機能ごとの schema.ts のZodスキーマにだけ書く
- コンポーネントの型は schema.ts から z.input / z.output で取り出す。手書きしない
- useForm には zodResolver(schema) を渡し、defaultValues を全フィールド分書く
- エラー表示は formState.errors.<name>?.message をそのまま出す。文言を画面側で作らない
- スキーマを変えたら schema.test.ts も更新し、`pnpm vitest run` の出力を貼るこの.claude/rules/forms.mdの置き場所は、testing.mdやapi-design.mdのようにトピック別のファイル名で並べる構成がmemoryページの例にあります。CLAUDE.md本体には、全セッションで要る事実だけを残します。
## コマンド
- 型チェック: pnpm tsc --noEmit
- テスト全体: pnpm vitest run
- 1ファイルだけ: pnpm vitest run src/features/signup/schema.test.tsベストプラクティスのページにも、リポジトリで作業の完了を確かめるテスト・型チェック・lintのコマンドと、1ファイルだけ走らせる方法を書く例が載っています。Claudeが検証コマンドを自分で選べるようになるので、後の「テストが通るまで直す」指示が短く済みます。
新しく作るファイルでrulesが読まれたかは、Claudeのバージョンで挙動が分かれた経緯があります。確認方法はClaude Code rulesがWriteの新規ファイルで読まれない問題と現状にまとめています。
手順 — スキーマ、画面、テストの順に生成させる
順序を固定すると、後ろの工程が前の成果物を土台にできます。1回の依頼で全部作らせず、スキーマを確認してから先へ進みます。
フォーム1つを作る流れ
- 1
スキーマだけを作らせる
画面にもテストにも触れないよう指示し、
schema.tsだけを出させます。 - 2
スキーマのテストを書かせて走らせる
正常系と、エラーが出るべきパスを持つ異常系を、
safeParseで確認します。 - 3
フォーム部品を作らせる
スキーマを
zodResolverに渡す配線と、エラー表示だけを作らせます。 - 4
画面のテストを足し、全体を走らせる
型チェックとテストの出力を貼らせ、緑であることを確かめます。
1. スキーマを先に書く
最初のプロンプトは、題材とフィールドの条件だけを渡し、実装範囲を絞ります。
src/features/signup/schema.ts だけを作ってください。画面とテストはまだ触りません。
フィールドは email、password(8文字以上)、confirm の3つです。
confirm が password と違うときは、エラーを confirm に出してください。
エラー文言は日本語で、フィールドごとに指定してください。Zodのドキュメントに沿うと、できあがるスキーマは次のような形になります。あくまで書き方の一例です。
import * as z from "zod";
export const signupSchema = z
.object({
email: z.email("メールアドレスの形式で入力してください"),
password: z.string().min(8, "8文字以上で入力してください"),
confirm: z.string(),
})
.refine((v) => v.password === v.confirm, {
error: "パスワードが一致しません",
path: ["confirm"],
});
export type SignupInput = z.input<typeof signupSchema>;
export type SignupValues = z.output<typeof signupSchema>;ここで見るべき点は2つあります。refineにpath: ["confirm"]を付けているので、不一致のエラーは確認欄の下に出ます。付けなければ、オブジェクト全体のエラーとして扱われ、欄ごとの表示から漏れます。もう1つは、型を手書きせずz.inputとz.outputから取っている点で、数値や日付の変換を入れたときも、入力途中の型と検証後の型を別々に保てます。
2. スキーマ単体のテストを走らせる
画面を作る前にスキーマを固めると、あとで画面側のテストが失敗したとき、原因の切り分けが楽になります。
import { describe, it, expect } from "vitest";
import { signupSchema } from "./schema";
const valid = { email: "a@example.com", password: "12345678", confirm: "12345678" };
describe("signupSchema", () => {
it("正しい入力を通す", () => {
expect(signupSchema.safeParse(valid).success).toBe(true);
});
it("確認欄の不一致は confirm のエラーになる", () => {
const result = signupSchema.safeParse({ ...valid, confirm: "x" });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toEqual(["confirm"]);
}
});
});safeParseは失敗しても例外を投げず、successとerrorを持つ結果を返します。error.issuesの各要素にはメッセージとpathが入るので、「どの欄にどの文言が出るか」を画面なしで確かめられます。Claudeには、テストを走らせたコマンドと出力を貼るよう頼みます。ドキュメントでも、完了の合図に「全部通りました」の一言ではなく、実行したコマンドとその出力を求めるよう勧めています。
3. フォーム部品はスキーマを渡すだけにする
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { signupSchema, type SignupValues } from "./schema";
export function SignupForm({ onSubmit }: { onSubmit: (v: SignupValues) => void }) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(signupSchema),
defaultValues: { email: "", password: "", confirm: "" },
mode: "onTouched",
});
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input type="email" {...register("email")} aria-label="メールアドレス" />
<p role="alert">{errors.email?.message}</p>
<input type="password" {...register("password")} aria-label="パスワード" />
<p role="alert">{errors.password?.message}</p>
<input type="password" {...register("confirm")} aria-label="パスワード確認" />
<p role="alert">{errors.confirm?.message}</p>
<button type="submit">登録</button>
</form>
);
}modeは送信前の検証タイミングで、既定はonSubmitです。onTouchedは最初のblurで検証を始め、以後は変更のたびに検証します。onChangeはキー入力ごとに再描画が走るため、useFormのリファレンスが性能への影響を警告しています。入力途中に赤字を出したくないフォームではonTouchedが無難です。
defaultValuesはフォーム全体の既定値で、リファレンスでは個々の入力のdefaultValueよりdefaultValuesが推奨されています。Claudeがフィールドを省かないよう、規約に「全フィールド分書く」と入れておきます。
4. 画面のテストでは、スキーマの文言を再掲しない
画面のテストが確かめるのは「エラーが表示される配線」です。文言をテストにも手書きすると、スキーマと二重管理に戻ります。スキーマから文言を取り出して比べます。
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, it, vi } from "vitest";
import { SignupForm } from "./SignupForm";
it("確認欄が違うと confirm の下にエラーが出て、送信されない", async () => {
const onSubmit = vi.fn();
render(<SignupForm onSubmit={onSubmit} />);
const user = userEvent.setup();
await user.type(screen.getByLabelText("メールアドレス"), "a@example.com");
await user.type(screen.getByLabelText("パスワード"), "12345678");
await user.type(screen.getByLabelText("パスワード確認"), "x");
await user.click(screen.getByRole("button", { name: "登録" }));
expect(await screen.findByText("パスワードが一致しません")).toBeTruthy();
expect(onSubmit).not.toHaveBeenCalled();
});この例は文言を直書きしていますが、規約に「文言はschema.tsから読む」と加えれば、定数化したメッセージをimportする形に直させられます。どちらにするかはチームの好みなので、CLAUDE.mdかrulesに一文で決めておくと、Claudeの出力が揺れません。
毎回走らせたい確認はhookに寄せる
rulesやCLAUDE.mdは、あくまでClaudeが読む文脈です。トラブルシュートの節にも、編集のたび・コミットの前のように必ず実行したいことはhookで書くよう案内しています。hookはライフサイクルの決まった時点でシェルコマンドとして実行され、Claudeの判断に左右されません。
hooksガイドの例は、PostToolUseイベントにEdit|Writeのmatcherを付け、編集したファイルにPrettierをかける構成です。フォームの題材なら、同じ形で型チェックを走らせる設定を置けます。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "pnpm tsc --noEmit" }
]
}
]
}
}テストを自動で走らせる配置の選び方は、Claude Code hooksでテストを自動実行するで比べています。「テストが通るまで止めない」まで寄せたいときは、agent hookで検証させる方法が参考になります。
よくあるつまずき
落とし穴
@hookform/resolversとzodのバージョンが合わず、モジュールが見つからない
Cannot find module '@hookform/resolvers/zod'やzod/v4/coreのエラーは、バージョンの組み合わせが原因のことが多いと、React Hook Formの手順に注記があります。Zod v4を使うには@hookform/resolversが3.10以上で、zodが3.25以上かv4である必要があります。さらにtsconfig.jsonのmoduleResolutionをbundler(またはnode16/nodenext)にして、パッケージのサブパスを解決できるようにします。Claudeがインストールしたバージョンをpackage.jsonで確認させてから、規約に固定バージョンを書いておくと再発しません。
落とし穴
パスワード確認のエラーが、他の欄を直すまで出ない
refineは、オブジェクトの他のフィールドにエラーがあると実行されません。Zodのドキュメントの例では、無関係なanotherFieldの型エラーがあるだけで、パスワード一致の確認が走りませんでした。どうしても先に出したいときはwhenで実行条件を指定します。テストにも「他の欄が不正でも不一致が検出される」ケースを足すかどうか、先に決めておきます。
数値や空文字を扱うとき
入力欄の値は文字列で届くため、数値のフィールドは変換が要ります。React Hook Formではregister("age", { valueAsNumber: true })の形が使えます。Zod側でz.coerce.number()を使う方法もありますが、coerceの入力型はunknownになります。z.coerce.number<number>()と書けば入力型をnumberに絞れます。どちらで変換するかは1つに決め、規約に書きます。両方で変換すると、型は通るのに値が期待と違う不具合を作りやすくなります。
Claudeが規約どおりに作らないとき
CLAUDE.mdの内容はシステムプロンプトではなくユーザーメッセージとして渡されるため、厳密な遵守は保証されません。切り分けの順は次のとおりです。
/contextのMemory filesで、CLAUDE.mdが読み込まれているか確かめる- 指示を具体的にする
- 矛盾する指示がないか、複数のCLAUDE.mdを見比べる
ファイルが長すぎても効きが落ちます。memoryページは1ファイル200行未満を目安にしており、フォーム専用の長い規約はrulesへ逃がす構成と相性が合います。
まとめ
スキーマを先に書かせるだけで、フォームの検証ルール、エラー表示、テストの3つが同じ根拠を持ちます。CLAUDE.mdには検証コマンドを、paths付きのrulesにはフォームの作法を置き、必ず走らせたい型チェックはhookへ寄せると、役割がぶつかりません。
React Hook Formで迷いやすいのは、検証の配置よりも変換の置き場所です。valueAsNumberとz.coerceのどちらに寄せるかを、最初の規約で1行決めてから生成を始めてください。ブラウザ上のartifactでReact入りの画面を作る場合は、web-artifacts-builderが別の手順になります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。