© TOOLS BOX — Next.js / React / TypeScript コードサンプル集

サンプルガイド
←サンプル一覧
nextjsform

Zustand Test のフォームエラーを aria-invalid と aria-describedby で読み上げ可能にする

RHF の errors.<field>.message を、aria-invalid と aria-describedby で input と紐付けてスクリーンリーダーに伝える書き方を解説する。

難易度: 初級·更新: 2026-05-07·
react-hook-formzod

対応バージョン

nextjs 15react 19react-hook-form 7zod 4

前提環境

useForm の register と errors の基本を理解していること

概要

Zustand Test のフォームでは、Zod が出すエラーメッセージを errors.<field>.message で取り出して表示します。 ここに aria 属性をひと組 足すだけで、スクリーンリーダーが「この入力欄はエラー状態で、メッセージはこれです」と読み上げてくれるようになります。

押さえるのは 3 属性だけです。

属性役割
aria-invalidinput がエラー状態であることを伝える
aria-describedbyinput と「説明テキスト」(エラー文) を紐付ける
role="alert"フォーム全体(root)エラーを「即読み上げ」させる

インストール

npm install react-hook-form @hookform/resolvers zod

input とエラー文を id で紐付ける

エラー表示用の <p> に 固定の id を付け、<input> の aria-describedby で参照します。aria-invalid はエラーがあるときだけ true にします。

// src/components/SettingsForm.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { formSchema, type FormData } from "@/schemas/formSchema";

export function SettingsForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>({ resolver: zodResolver(formSchema) });

  return (
    <form onSubmit={handleSubmit(/* ... */)} noValidate>
      <label htmlFor="name">名前</label>
      <input
        id="name"
        type="text"
        aria-invalid={!!errors.name}
        aria-describedby={errors.name ? "name-error" : undefined}
        {...register("name")}
      />
      {errors.name && (
        <p id="name-error" className="text-red-500">
          {errors.name.message}
        </p>
      )}
      {/* email / rate も同様 */}
    </form>
  );
}

ポイントは 3 つ。

  • <label htmlFor="name"> と <input id="name"> の id を必ず合わせる(基本の a11y)
  • aria-describedby の値はエラーがあるときのみ "name-error"、ないときは undefined にして属性ごと外す
  • noValidate を <form> に付けてブラウザの組み込み検証を切り、Zod の検証だけが UI に出る状態にする

エラーが出る・消えるタイミング

aria-invalid={!!errors.name} は errors.name が undefined から object に変わった瞬間に true になります。スクリーンリーダーは値の変化を拾うので、自動で「無効です」と読み上げ ます。

aria-describedby も同様で、エラー文の id が紐付いた瞬間に「説明テキストはこれ」と関連付きます。<p> の中身が変わったらそのまま新しいメッセージとして読み上げられます。

フォーム全体のエラー(root)

「保存に失敗しました」のような どのフィールドでもないエラー は errors.root に持たせます。表示要素には role="alert" を付けて、表示と同時に 即座に読み上げ させます。

import { useForm } from "react-hook-form";

const {
  setError,
  formState: { errors },
} = useForm<FormData>(/* ... */);

const onSubmit = async (data: FormData) => {
  try {
    await saveSettings(data);
  } catch (e) {
    setError("root", { message: "保存に失敗しました。時間をおいて再度お試しください。" });
  }
};

return (
  <form onSubmit={handleSubmit(onSubmit)}>
    {errors.root && (
      <p role="alert" className="rounded border border-red-500 px-3 py-2 text-red-700">
        {errors.root.message}
      </p>
    )}
    {/* ... */}
  </form>
);

role="alert" は aria-live="assertive" + aria-atomic="true" と同等です。出現と同時に読み上げが優先されます。 逆に「常時表示しているエラー領域」 には role="alert" を付けてはいけません。読み上げが頻発してしまいます。

共通化したいなら Field コンポーネント

同じ aria の組み合わせを毎回書くと記述量が増えます。1 つだけ Field コンポーネントを切り出すと、a11y と見た目の両方を一括で揃えられます。

// src/components/form/Field.tsx
import type { ReactNode } from "react";

type FieldProps = {
  id:        string;
  label:     string;
  error?:    string;
  children:  (ariaProps: { id: string; "aria-invalid": boolean; "aria-describedby"?: string }) => ReactNode;
};

export function Field({ id, label, error, children }: FieldProps) {
  const errorId = error ? `${id}-error` : undefined;
  return (
    <div>
      <label htmlFor={id}>{label}</label>
      {children({
        id,
        "aria-invalid":     !!error,
        "aria-describedby": errorId,
      })}
      {error && <p id={errorId} className="text-red-500">{error}</p>}
    </div>
  );
}
<Field id="name" label="名前" error={errors.name?.message}>
  {(aria) => <input type="text" {...aria} {...register("name")} />}
</Field>

<input> の種類(text / number / select)を変えても aria は同じ形で当たります。

多言語化を考えるなら

エラーメッセージはスキーマに直書きしているため、多言語化が必要なら メッセージキー を返す形に切り替えます。

// src/schemas/formSchema.ts
export const formSchema = z.object({
  name: z.string().min(1, "name.required"),
  // ...
});

UI 側で t(errors.name.message) のように i18n に通します。スキーマがメッセージのキーを source of truth として持つ 構造は変わりません。

ポイント

  • <label htmlFor> と <input id> を必ず合わせる。a11y の最初の 1 歩
  • aria-invalid を boolean で渡す。aria-describedby は エラーがあるときだけ id、なければ undefined で属性を外す
  • フォーム全体のエラー(root)には role="alert" を付けて 即読み上げ。常時表示には付けない
  • <form noValidate> でブラウザ検証を切り、Zod の検証だけが UI に反映されるようにする
  • 同じ aria の組み合わせを繰り返すなら Field コンポーネント で 1 か所に集約する

関連記事

セットで読むと文脈がつながる関連記事は次のとおりです。

  • Zod 編 — エラーメッセージをスキーマに集約する書き方
  • RHF 編 — errors を formState から取り出す
  • TabBar a11y 編 — タブの a11y 対応
  • Zustand Test (Build) — 完成例と外部デモ

注意点

Playground 追加記事。エラー表示の見た目だけでなく、input とエラーメッセージを aria 属性で紐付けて読み上げ可能にする手順をまとめる。

リポジトリを見る

関連サンプル

同じテーマや技術スタックを使った実装例

  • Zustand Test の React Hook Form を watch・handleSubmit・formState で読み解く

    useForm の watch でリアルタイム同期し、handleSubmit で確定値を扱い、formState で UI を切り替える Playground の RHF 実装を1ステップずつ解説する。

  • Zustand Test の TabBar を role=tablist とキーボード操作で完成させる

    uiStore の activeTab を起点に、role=tablist / aria-selected / 矢印キー操作 / フォーカス管理を満たした TabBar を組み立てる手順を解説する。

  • Zustand Test の Zod / RHF watch / patchFormData と setFormData を読み解く

    RHF の watch でリアルタイム同期しつつ、保存時に Zod parse で確定値を書き戻す Playground のフォーム実装を1ステップずつ解説する。

  • React でフォームフィールドにヘルプテキストとエラーメッセージを表示する

    入力フィールドの下にヘルプテキスト(説明)とバリデーションエラーメッセージを表示し、スクリーンリーダー対応の aria-describedby で関連付けるアクセシブルなフォームパターン。

  • React でフォームラベルに必須・任意バッジを表示する

    フォームラベルの横に「必須」「任意」バッジを表示し、aria-required と組み合わせてアクセシブルなフォーム入力欄を実装するパターン。

関連仕様

このサンプルを理解するのに役立つ仕様や概念

  • FrameworkNext.jsReact ベースのフルスタックフレームワーク。SSR・SSG・App Router・API Routes を提供する。
  • LibraryZodTypeScript ファーストのスキーマバリデーションライブラリ。型推論と実行時バリデーションを同一スキーマで行える。
←サンプル一覧に戻る