Use Cases (更新: 2026/6/7)

react-hook-form × zodでフォーム検証を二重化する実装手順

クライアントとサーバーで同じzodスキーマを使い回し、型安全・エラー表示・サーバーエラーの戻し方まで。問い合わせフォーム1つで動く実装を一人称で解説。

react-hook-form × zodでフォーム検証を二重化する実装手順

「メール欄、ちゃんとバリデーションかけといて」

そう頼まれて作ったフォーム。ブラウザでは赤文字が出る。完璧だと思っていました。ところが本番で、@すら入っていないメールアドレスが何件も届いたんです。

犯人はすぐ分かりました。攻撃者でも何でもない。JavaScriptを切っていた人、古いブラウザの人、そして—curlで直接APIを叩いたbotです。画面の検証は、画面を通った人にしか効きません。

これが、フォームバリデーションでいちばん最初にハマる落とし穴です。今日は react-hook-form と zod を使って、画面とサーバーの両方に同じ検証を効かせるやり方を、問い合わせフォーム1つで通しで書きます。型安全、エラー表示のUX、よくある罠まで、僕が実際に踏んだ順に並べます。

この記事の要点

  • フォーム検証はクライアントとサーバーの二重化が前提。画面の検証はUXのため、サーバーの検証は安全のため、と役割が違う。
  • zodスキーマを1本書けば、z.inferで型が自動で出る。スキーマがそのまま型の定義書になり、画面とAPIで共有できる。
  • react-hook-form の zodResolver でスキーマを画面につなぐ。mode の選び方(onBlur/onChange)でエラー表示の体感が大きく変わる。
  • サーバーが返したエラーは setError元のフィールドに戻す。ここを雑にすると「送信できないのに理由が分からない」フォームになる。
  • エラー文は日本語の文章ではなくメッセージのキーで返すと、多言語UIで翻訳が崩れない。

まず「正しい」の定義を1か所に集める

フォームを作るとき、初心者の僕はいきなり<input>を並べていました。見た目から作ると気持ちがいいんですよね。でも、これが遠回りでした。

先に決めるべきは、どの値を正しいとみなすかです。名前は何文字まで? メールは必須? 利用人数は数値? それを頭の中やコメントに散らすと、画面とサーバーで微妙にズレます。「画面は60文字までなのにAPIは100文字まで通る」みたいな事故が起きる。

そこで zod の出番です。zod は入力ルールを書くためのライブラリで、ひとことで言うとデータの契約書。「この形以外は受け取らない」と1か所に宣言しておく道具です(書ける検証の一覧はzod公式ドキュメントが早い)。

まずは契約書を1本書きます。問い合わせフォームを想定しました。

// src/features/contact/contactSchema.ts
import { z } from "zod";

export const contactSchema = z
  .object({
    name: z
      .string()
      .trim()
      .min(1, "validation.name.required")
      .max(60, "validation.name.tooLong"),
    email: z
      .string()
      .trim()
      .min(1, "validation.email.required")
      .email("validation.email.invalid"),
    plan: z.enum(["starter", "team", "enterprise"], {
      message: "validation.plan.invalid",
    }),
    seats: z
      .number({ message: "validation.seats.number" })
      .int("validation.seats.integer")
      .min(1, "validation.seats.min")
      .max(200, "validation.seats.max"),
    message: z
      .string()
      .trim()
      .min(20, "validation.message.tooShort")
      .max(1000, "validation.message.tooLong"),
    locale: z.enum(["ja", "en"], {
      message: "validation.locale.invalid",
    }),
    agreeToTerms: z
      .boolean()
      .refine((value) => value === true, "validation.terms.required"),
  })
  .strict();

// スキーマから型を取り出す。手で型を書き直さなくていい
export type ContactFormData = z.infer<typeof contactSchema>;

export const defaultContactValues: ContactFormData = {
  name: "",
  email: "",
  plan: "starter",
  seats: 1,
  message: "",
  locale: "ja",
  agreeToTerms: false,
};

ポイントは2つ。

ひとつは z.infer。これはスキーマからTypeScriptの型を自動で取り出す仕組みです。ContactFormDataを手で書く必要がない。スキーマを直せば型も勝手に追従するので、「型だけ直し忘れて実態とズレる」が起きません。

もうひとつは、エラーメッセージに "validation.name.required" のようなキーを入れていること。日本語の文章を直接書いていないのは、後で英語UIにしたときに同じAPIレスポンスを使い回すためです。理由は後半で具体的に書きます。

クライアント検証はUX、サーバー検証は安全

ここが今日いちばん伝えたいところです。検証を二重にやる、と聞くと「二度手間では?」と思いますよね。僕も最初そう思いました。でも、ふたつは目的がまったく違うんです。

クライアント検証サーバー検証
目的入力中の体験を良くする不正なデータを弾く・安全
いつ走る入力・離脱・送信時リクエスト受信時
信用できるかできない(改変・無効化される)ここが最後の砦
省くとどうなるエラーが送信後まで分からず不親切botや壊れたJSONがDBに入る

冒頭の「@なしメールが届いた」事故は、サーバー検証を省いた典型です。ブラウザのJavaScriptは無効化も改変もできるし、APIはcurlからでも叩けます。だからサーバーでは入力を一度unknownとして疑い、zodを通るまで信用しないのが鉄則です。

逆に、サーバー検証だけにしてクライアント検証を省くと、ユーザーは「送信ボタンを押すまでミスに気づけない」。これはこれでストレスです。だから両方いる。同じスキーマを使えば、ルールは1本のまま二重化できます。

サーバー側:入力を疑ってから受け取る

先にAPI側を書きます。Next.jsのRoute Handlerを想定しましたが、考え方はどのフレームワークでも同じです。受け取ったbodyをunknown扱いし、safeParseを通します。

// src/app/api/contact/route.ts
import { z } from "zod";
import { contactSchema, type ContactFormData } from "@/features/contact/contactSchema";

type FieldPath = keyof ContactFormData | "root";

export type ApiFieldError = {
  path: FieldPath;
  message: string;
};

export type ContactApiResponse =
  | { ok: true; id: string }
  | { ok: false; errors: ApiFieldError[] };

// zodのエラーを「フィールド名+メッセージ」の配列にそろえる
function normalizeZodError(error: z.ZodError): ApiFieldError[] {
  return error.issues.map((issue) => {
    const firstPath = issue.path[0];
    return {
      path: typeof firstPath === "string" ? (firstPath as FieldPath) : "root",
      message: issue.message,
    };
  });
}

function jsonResponse(body: ContactApiResponse, status: number): Response {
  return Response.json(body, { status });
}

// 業務ルールの例:特定ドメインは受け付けない
async function isBlockedDomain(email: string): Promise<boolean> {
  return email.toLowerCase().endsWith("@example.invalid");
}

export async function POST(request: Request): Promise<Response> {
  let body: unknown;

  // 壊れたJSONが来てもクラッシュさせない
  try {
    body = await request.json();
  } catch {
    return jsonResponse(
      { ok: false, errors: [{ path: "root", message: "validation.json.invalid" }] },
      400,
    );
  }

  // ここを通るまで body は信用しない
  const parsed = contactSchema.safeParse(body);
  if (!parsed.success) {
    return jsonResponse({ ok: false, errors: normalizeZodError(parsed.error) }, 422);
  }

  if (await isBlockedDomain(parsed.data.email)) {
    return jsonResponse(
      { ok: false, errors: [{ path: "email", message: "validation.email.blocked" }] },
      409,
    );
  }

  // 本来はここでDB保存・CRM連携・メール通知などを行う
  const id = crypto.randomUUID();
  return jsonResponse({ ok: true, id }, 201);
}

このコードの肝は normalizeZodError です。「正規化」と書くと難しそうですが、やっていることは単純で、バラバラな失敗を1つの形にそろえるだけ。zodの検証エラーも、JSONの読み取り失敗も、ブロックドメインの業務エラーも、ぜんぶ { path, message } の配列にする。

なぜそろえるかというと、画面側を楽にするためです。失敗の形が毎回違うと、受け取る画面が条件分岐だらけになる。形が1つなら、画面は「エラー配列を回してフィールドに戻す」だけで済みます。

ちなみにステータスコードも使い分けています。壊れたJSONは400、検証エラーは422、業務ルール違反(ブロックドメイン)は409。ここは厳密でなくてもいいですが、ログを見たとき何が起きたか分かりやすくなります。

クライアント側:zodResolverでスキーマをつなぐ

次は画面です。react-hook-form は React でフォームを扱うライブラリで、入力値をいちいち state で持たずに register で要素を登録し、handleSubmit で送信を扱います。

スキーマと画面をつなぐのが zodResolver。これはreact-hook-form と zod の翻訳係です。これを resolver に渡すだけで、さっき書いた契約書がそのまま画面の検証ルールになります。zodResolver公式のResolversパッケージから入れます(@hookform/resolvers)。

// src/features/contact/ContactForm.tsx
"use client";

import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import {
  contactSchema,
  defaultContactValues,
  type ContactFormData,
} from "./contactSchema";
import type { ApiFieldError, ContactApiResponse } from "@/app/api/contact/route";

// メッセージのキーを、表示する文章に変換する辞書
const messages = {
  ja: {
    "validation.name.required": "お名前を入力してください。",
    "validation.name.tooLong": "お名前は60文字以内で入力してください。",
    "validation.email.required": "メールアドレスを入力してください。",
    "validation.email.invalid": "有効なメールアドレスを入力してください。",
    "validation.email.blocked": "このメールドメインでは申し込めません。",
    "validation.plan.invalid": "プランを選択してください。",
    "validation.seats.number": "利用人数は数値で入力してください。",
    "validation.seats.integer": "利用人数は整数で入力してください。",
    "validation.seats.min": "利用人数は1人以上にしてください。",
    "validation.seats.max": "利用人数は200人以下にしてください。",
    "validation.message.tooShort": "相談内容は20文字以上で入力してください。",
    "validation.message.tooLong": "相談内容は1000文字以内で入力してください。",
    "validation.locale.invalid": "言語設定が不正です。",
    "validation.terms.required": "利用規約への同意が必要です。",
    "validation.json.invalid": "送信内容を読み取れませんでした。",
    "form.submitError": "送信に失敗しました。時間をおいて再度お試しください。",
  },
  en: {
    "validation.name.required": "Enter your name.",
    "validation.name.tooLong": "Name must be 60 characters or fewer.",
    "validation.email.required": "Enter your email address.",
    "validation.email.invalid": "Enter a valid email address.",
    "validation.email.blocked": "This email domain is not allowed.",
    "validation.plan.invalid": "Choose a plan.",
    "validation.seats.number": "Seats must be a number.",
    "validation.seats.integer": "Seats must be an integer.",
    "validation.seats.min": "Seats must be at least 1.",
    "validation.seats.max": "Seats must be 200 or fewer.",
    "validation.message.tooShort": "Message must be at least 20 characters.",
    "validation.message.tooLong": "Message must be 1000 characters or fewer.",
    "validation.locale.invalid": "Locale is invalid.",
    "validation.terms.required": "You must agree to the terms.",
    "validation.json.invalid": "The submitted body could not be read.",
    "form.submitError": "Submit failed. Please try again later.",
  },
} as const;

type Locale = keyof typeof messages;
const formFields = ["name", "email", "plan", "seats", "message", "locale", "agreeToTerms"] as const;
type FormField = (typeof formFields)[number];

function t(locale: Locale, key: string): string {
  const table = messages[locale] as Record<string, string>;
  return table[key] ?? key;
}

// サーバーが返したpathが、画面のフィールド名かどうかを判定
function isFormField(path: ApiFieldError["path"]): path is FormField {
  return formFields.includes(path as FormField);
}

export function ContactForm({ locale = "ja" }: { locale?: Locale }) {
  const [serverMessage, setServerMessage] = useState<string | null>(null);
  const {
    register,
    handleSubmit,
    setError,
    reset,
    formState: { errors, isSubmitting },
  } = useForm<ContactFormData>({
    resolver: zodResolver(contactSchema),
    defaultValues: { ...defaultContactValues, locale },
    mode: "onBlur", // 入力欄から離れたタイミングで検証する
  });

  async function onValidSubmit(values: ContactFormData) {
    setServerMessage(null);

    const response = await fetch("/api/contact", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(values),
    });

    const result = (await response.json()) as ContactApiResponse;
    if (!response.ok || !result.ok) {
      const apiErrors = result.ok ? [] : result.errors;
      // サーバーのエラーを、元のフィールドに戻す
      for (const error of apiErrors) {
        if (isFormField(error.path)) {
          setError(error.path, { type: "server", message: t(locale, error.message) });
        } else {
          setServerMessage(t(locale, error.message));
        }
      }
      if (apiErrors.length === 0) setServerMessage(t(locale, "form.submitError"));
      return;
    }

    reset({ ...defaultContactValues, locale });
  }

  return (
    <form onSubmit={handleSubmit(onValidSubmit)} noValidate>
      {serverMessage ? (
        <p role="alert" aria-live="assertive">
          {serverMessage}
        </p>
      ) : null}

      <div>
        <label htmlFor="contact-name">お名前</label>
        <input
          id="contact-name"
          autoComplete="name"
          aria-invalid={Boolean(errors.name)}
          aria-describedby={errors.name ? "contact-name-error" : undefined}
          {...register("name")}
        />
        {errors.name?.message ? (
          <p id="contact-name-error" role="alert">
            {t(locale, errors.name.message)}
          </p>
        ) : null}
      </div>

      <div>
        <label htmlFor="contact-email">メールアドレス</label>
        <input
          id="contact-email"
          type="email"
          autoComplete="email"
          aria-invalid={Boolean(errors.email)}
          aria-describedby={errors.email ? "contact-email-error" : undefined}
          {...register("email")}
        />
        {errors.email?.message ? (
          <p id="contact-email-error" role="alert">
            {t(locale, errors.email.message)}
          </p>
        ) : null}
      </div>

      <div>
        <label htmlFor="contact-plan">プラン</label>
        <select id="contact-plan" {...register("plan")}>
          <option value="starter">Starter</option>
          <option value="team">Team</option>
          <option value="enterprise">Enterprise</option>
        </select>
      </div>

      <div>
        <label htmlFor="contact-seats">利用人数</label>
        <input
          id="contact-seats"
          type="number"
          min={1}
          max={200}
          aria-invalid={Boolean(errors.seats)}
          aria-describedby={errors.seats ? "contact-seats-error" : undefined}
          {...register("seats", { valueAsNumber: true })}
        />
        {errors.seats?.message ? (
          <p id="contact-seats-error" role="alert">
            {t(locale, errors.seats.message)}
          </p>
        ) : null}
      </div>

      <div>
        <label htmlFor="contact-message">相談内容</label>
        <textarea
          id="contact-message"
          rows={6}
          aria-invalid={Boolean(errors.message)}
          aria-describedby={errors.message ? "contact-message-error" : undefined}
          {...register("message")}
        />
        {errors.message?.message ? (
          <p id="contact-message-error" role="alert">
            {t(locale, errors.message.message)}
          </p>
        ) : null}
      </div>

      <label>
        <input type="checkbox" {...register("agreeToTerms")} />
        利用規約に同意します
      </label>
      {errors.agreeToTerms?.message ? (
        <p role="alert">{t(locale, errors.agreeToTerms.message)}</p>
      ) : null}

      <button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>
        {isSubmitting ? "送信中..." : "送信する"}
      </button>
    </form>
  );
}

長く見えますが、覚えるのは3か所だけです。

resolver: zodResolver(contactSchema) で、契約書が画面の検証になる。isSubmitting でボタンを無効にして、二重送信を止める。そして onValidSubmit の中で、サーバーが返したエラーを setError で元のフィールドに戻している。最後のが地味に大事で、これがないと「サーバーがメールを弾いたのに、メール欄は何事もなかった顔をしている」状態になります。

エラー表示のUXはmodeで決まる

react-hook-form には mode という設定があって、いつ検証を走らせるかを決めます(取れる値はuseFormの公式ドキュメントに一覧があります)。ここの選択で、フォームの体感がガラッと変わります。実際、僕はここを何度か変えて落ち着きました。

mode検証のタイミング向いている場面
onSubmit(既定)送信ボタンを押したとき項目が少ない・短いフォーム
onBlur入力欄から離れたとき大半のフォームのおすすめ。うるさすぎない
onChange1文字打つたびリアルタイム表示。ただし重く・うるさくなりがち
onTouched一度触れた欄を以後リアルタイム検証入力中は静かに、修正中は親切に

最初、僕は気を利かせたつもりで onChange にしました。結果、メールを1文字打つたびに「有効なメールアドレスを入力してください」が赤く点滅して、めちゃくちゃうるさい。打ち終わる前から怒られる体験は最悪です。

そこで onBlur に変えたら、ちょうどよくなりました。入力中は黙っていて、欄を離れた瞬間にチェックする。打ち終わってから優しく指摘してくれる感じです。迷ったら onBlur、もう少し親切にしたいなら onTouched、これが僕の結論です。

よくある罠を先に潰しておく

ここからは、僕が実際にハマった順に罠を並べます。先に知っておくと2時間くらい得します。

罠1:数値が文字列で届く。 <input type="number"> は見た目が数字でも、何もしないと文字列で渡ってきます。seatsz.number() にしているのに "3" が来て検証に落ちる。対策は画面側で register("seats", { valueAsNumber: true }) を付けること。API専用には z.coerce.number()(文字列を数値に変換するzodの機能)という手もあります。

罠2:サーバーエラーが画面から消える。 さっきの setError を入れ忘れると、サーバーが弾いた理由がどこにも出ません。ユーザーは「押しても進まない」とだけ感じて離脱します。path がフィールド名ならそのフィールドに、root なら全体メッセージに、と振り分けるのを忘れずに。

罠3:翻訳キーが画面にそのまま出る。 辞書に登録し忘れると、ユーザーの画面に validation.email.blocked という呪文が表示されます。t() 関数で「キーが見つからなければキーをそのまま返す」フォールバックを入れているのはこのためで、開発中は気づきやすくなります。

罠4:二重送信で予約や請求がダブる。 isSubmitting でボタンを止めても、それは画面の話。決済や予約のように「2回実行されたら困る」処理は、サーバー側にも冪等性(同じリクエストを2回受けても1回分の結果になる性質)が要ります。リクエストIDやDBの一意制約で守ります。

罠5:非同期検証を毎キー打つたびに走らせる。 「このメール、もう登録済みですか?」のようにサーバーへ問い合わせる検証は重い。onChangeで走らせるとAPIを叩きまくります。送信時かonBlurに寄せて、回数を減らすのが基本です。

罠6:赤文字だけで済ませる。 色だけのエラーは、スクリーンリーダーや色覚特性のある人に届きません。aria-invalid(この欄はエラー、と伝える)、aria-describedby(エラー文の場所を指す)、role="alert"(出たことを読み上げる)をセットにします。上のコードに全部入れてあります。

テストで「壊れたら困る境界」だけ守る

テストは数を競うものではありません。壊れると本当に困るところだけ押さえます。このフォームなら、スキーマ・サーバーエラーの戻し・未入力時に送信しないこと、の3つです。

// src/features/contact/ContactForm.test.tsx
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ContactForm } from "./ContactForm";
import { contactSchema } from "./contactSchema";

const validInput = {
  name: "Masa",
  email: "[email protected]",
  plan: "team",
  seats: 3,
  message: "react-hook-formとzodでフォームの検証を改善したいです。",
  locale: "ja",
  agreeToTerms: true,
} as const;

describe("contactSchema", () => {
  it("正しい入力は通る", () => {
    expect(contactSchema.safeParse(validInput).success).toBe(true);
  });

  it("数値であるべきseatsに文字列が来たら落とす", () => {
    const result = contactSchema.safeParse({ ...validInput, seats: "3" });
    expect(result.success).toBe(false);
  });
});

describe("ContactForm", () => {
  beforeEach(() => {
    vi.restoreAllMocks();
  });

  it("未入力ならエラーを出し、送信はしない", async () => {
    const fetchMock = vi.spyOn(globalThis, "fetch");
    render(<ContactForm locale="ja" />);

    await userEvent.click(screen.getByRole("button", { name: "送信する" }));

    expect(await screen.findByText("お名前を入力してください。")).toBeInTheDocument();
    expect(fetchMock).not.toHaveBeenCalled();
  });

  it("サーバーのフィールドエラーをメール欄に戻す", async () => {
    vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
      new Response(
        JSON.stringify({
          ok: false,
          errors: [{ path: "email", message: "validation.email.blocked" }],
        }),
        { status: 409, headers: { "Content-Type": "application/json" } },
      ),
    );

    render(<ContactForm locale="ja" />);
    await userEvent.type(screen.getByLabelText("お名前"), "Masa");
    await userEvent.type(screen.getByLabelText("メールアドレス"), "[email protected]");
    await userEvent.clear(screen.getByLabelText("利用人数"));
    await userEvent.type(screen.getByLabelText("利用人数"), "3");
    await userEvent.type(screen.getByLabelText("相談内容"), "フォームバリデーションについて相談したいです。");
    await userEvent.click(screen.getByRole("checkbox"));
    await userEvent.click(screen.getByRole("button", { name: "送信する" }));

    expect(await screen.findByText("このメールドメインでは申し込めません。")).toBeInTheDocument();
  });
});

この3本があれば、「数字が文字列で通る」「サーバーエラーが画面に出ない」「未入力でも送信される」という、僕が全部踏んだ3つの事故を回帰として止められます。テスト名を日本語にしておくと、半年後の自分が読んで何を守っているか一目で分かります。フォーム以外のテスト全体の組み方はClaude Codeでのテスト戦略も参考にしてください。

なぜエラー文を「キー」で返すのか

最後に、ずっと引っ張っていた「メッセージのキー」の話を回収します。

スキーマでもAPIでも、エラーに "validation.email.invalid" というキーを入れて、日本語の文章は入れていませんでした。最初は「二度手間では」と感じるはずです。直接「有効なメールアドレスを入力してください」と書けば済むのに、と。

でも、英語UIを足した瞬間に効いてきます。サーバーは言語を知りません。jaからのリクエストかenからかで返す文章を変えるのは面倒だし、サーバーに翻訳辞書を持たせるのも筋が悪い。サーバーはキーだけ返し、表示する言語は画面が決める。これなら同じAPIレスポンスを日本語UIでも英語UIでも使い回せます。

実際、僕はこの設計に変えてから「英語版だけエラー文が崩れる」事故がゼロになりました。翻訳の抜けも、辞書を1か所見れば分かる。zodと型安全の組み合わせをもっと深く知りたい人はZodの入力バリデーション実践、react-hook-formの基礎はReact Hook Form実践ガイドに分けて書いています。Next.jsでAPIまで通しで組む流れはNext.jsフルスタック実装が近いです。

よくある質問

Q. クライアント検証だけではダメですか? ダメです。ブラウザのJavaScriptは無効化も改変もできて、APIはcurlからでも叩けます。クライアント検証はあくまで体験のため。最後の砦は必ずサーバーに置いてください。

Q. modeは何にすればいいですか? 迷ったらonBlurです。入力中は黙っていて、欄を離れた瞬間に検証するので、うるさすぎず親切です。もう少し丁寧にしたいなら、一度触れた欄だけリアルタイム検証するonTouchedが好みです。onChangeは重く・うるさくなりがちなので慎重に。

Q. input type="number"の値が文字列になります。 register("seats", { valueAsNumber: true })を付けてください。API側で受けるならz.coerce.number()で文字列を数値に変換する手もあります。どちらにせよ型ずれを放置しないことです。

Q. サーバーのエラーを画面のフィールドに出すには? APIのエラーを{ path, message }の形にそろえて返し、画面側でsetError(path, ...)に渡します。pathがフィールド名ならその欄に、rootなら全体メッセージに振り分けます。

Q. zodスキーマから型を毎回手で書くのが面倒です。 書かなくていいです。type ContactFormData = z.infer<typeof contactSchema>と書けば、スキーマから型が自動で出ます。スキーマを直せば型も追従するので、ズレません。

実際に試した結果

この構成に寄せてから、僕の手元では2つの事故が消えました。ひとつはseatsが文字列でAPIに届く型ずれ。valueAsNumberを付けただけで止まりました。もうひとつは、ブロックしたメールのエラーが画面に出ない問題。setErrorでフィールドに戻すようにしたら、ユーザーがちゃんと理由を見られるようになった。

いちばん効いたのは、やっぱりエラー文をキーで返す設計です。日本語UIと英語UIで同じAPIレスポンスをそのまま使えるので、翻訳の抜けを探す時間がほぼゼロになりました。

フォームは小さく見えて、ユーザーの入力がシステムに入る最初の境界です。見た目から作りたくなる気持ちはよく分かりますが、先に「正しいの定義」を1本のスキーマに集めて、画面とサーバーで二重に効かせる。この順番に変えるだけで、公開後の問い合わせ品質がはっきり変わります。手を動かす土台ができたら、研修・相談で自分のコードベースに合わせて詰めるのもおすすめです。

#react-hook-form #zod #フォームバリデーション #TypeScript #サーバー検証
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。