レビュー済み·難易度: 初級·更新: 2026-04-18

React でフォームフィールド近傍にインラインエラーメッセージを表示する

フォームの各フィールド直下にバリデーションエラーや API エラーをインライン表示するパターン。aria-describedby でアクセシブルに紐付け、エラー状態のスタイルも切り替える例。

nextjserror-handlingform

対応バージョン

nextjs 15react 19

前提環境

React の基本的なフォーム実装と useState を理解していること

概要

フォームの各フィールド直下にエラーメッセージを表示し、aria-describedby でスクリーンリーダーに紐付ける。クライアントバリデーション(フォーカスアウト時)と API エラー(サブミット後)の両方に対応したパターンを示す。

インストール

# 追加インストールは不要

実装

フィールドエラーコンポーネント

// components/FieldError.tsx
type Props = {
  id: string;
  message?: string;
};

export default function FieldError({ id, message }: Props) {
  if (!message) return null;

  return (
    <p id={id} role="alert" className="mt-1 text-xs text-red-600">
      {message}
    </p>
  );
}

フォームフィールドラッパー

// components/FormField.tsx
import type { ReactNode } from "react";
import FieldError from "./FieldError";

type Props = {
  label: string;
  htmlFor: string;
  error?: string;
  children: ReactNode;
};

export default function FormField({ label, htmlFor, error, children }: Props) {
  const errorId = `${htmlFor}-error`;

  return (
    <div className="space-y-1">
      <label htmlFor={htmlFor} className="block text-sm font-medium text-gray-700">
        {label}
      </label>
      {/* children に aria-describedby を渡すため cloneElement は使わず、呼び出し側で設定する */}
      {children}
      <FieldError id={errorId} message={error} />
    </div>
  );
}

インラインエラー付きフォーム

// components/SignUpForm.tsx
"use client";

import { useState } from "react";
import FormField from "./FormField";
import FieldError from "./FieldError";

type FormErrors = {
  email?: string;
  password?: string;
  confirmPassword?: string;
  form?: string;
};

function validateEmail(value: string): string | undefined {
  if (!value) return "メールアドレスを入力してください";
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "正しいメールアドレスを入力してください";
}

function validatePassword(value: string): string | undefined {
  if (!value) return "パスワードを入力してください";
  if (value.length < 8) return "パスワードは 8 文字以上で入力してください";
}

export default function SignUpForm() {
  const [values, setValues] = useState({ email: "", password: "", confirmPassword: "" });
  const [errors, setErrors] = useState<FormErrors>({});
  const [touched, setTouched] = useState<Record<string, boolean>>({});
  const [loading, setLoading] = useState(false);

  function handleBlur(field: string) {
    setTouched((prev) => ({ ...prev, [field]: true }));

    // フォーカスアウト時にバリデーション
    const newErrors = { ...errors };
    if (field === "email") {
      newErrors.email = validateEmail(values.email);
    }
    if (field === "password") {
      newErrors.password = validatePassword(values.password);
    }
    if (field === "confirmPassword") {
      newErrors.confirmPassword =
        values.password !== values.confirmPassword ? "パスワードが一致しません" : undefined;
    }
    setErrors(newErrors);
  }

  function handleChange(field: string, value: string) {
    setValues((prev) => ({ ...prev, [field]: value }));
    // 入力中はエラーをクリア
    if (errors[field as keyof FormErrors]) {
      setErrors((prev) => ({ ...prev, [field]: undefined }));
    }
  }

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    const newErrors: FormErrors = {
      email: validateEmail(values.email),
      password: validatePassword(values.password),
      confirmPassword:
        values.password !== values.confirmPassword ? "パスワードが一致しません" : undefined,
    };

    if (Object.values(newErrors).some(Boolean)) {
      setErrors(newErrors);
      setTouched({ email: true, password: true, confirmPassword: true });
      return;
    }

    setLoading(true);
    try {
      const res = await fetch("/api/signup", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email: values.email, password: values.password }),
      });

      if (!res.ok) {
        const data = await res.json() as { error?: string; field?: string };
        if (data.field) {
          setErrors({ [data.field]: data.error });
        } else {
          setErrors({ form: data.error ?? "登録に失敗しました" });
        }
      }
    } catch {
      setErrors({ form: "通信エラーが発生しました" });
    } finally {
      setLoading(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} className="w-80 space-y-4 rounded-lg border p-8" noValidate>
      <h1 className="text-xl font-bold">アカウント登録</h1>

      {/* フォーム全体のエラー */}
      {errors.form && (
        <p role="alert" className="rounded bg-red-50 px-3 py-2 text-sm text-red-600">
          {errors.form}
        </p>
      )}

      <FormField label="メールアドレス" htmlFor="email" error={touched.email ? errors.email : undefined}>
        <input
          id="email"
          type="email"
          value={values.email}
          onChange={(e) => handleChange("email", e.target.value)}
          onBlur={() => handleBlur("email")}
          aria-describedby={errors.email && touched.email ? "email-error" : undefined}
          aria-invalid={!!(errors.email && touched.email)}
          className={`block w-full rounded border px-3 py-2 text-sm ${
            errors.email && touched.email ? "border-red-500 focus:ring-red-200" : "border-gray-300"
          } focus:outline-none focus:ring-2`}
        />
      </FormField>

      <FormField label="パスワード" htmlFor="password" error={touched.password ? errors.password : undefined}>
        <input
          id="password"
          type="password"
          value={values.password}
          onChange={(e) => handleChange("password", e.target.value)}
          onBlur={() => handleBlur("password")}
          aria-describedby={errors.password && touched.password ? "password-error" : undefined}
          aria-invalid={!!(errors.password && touched.password)}
          className={`block w-full rounded border px-3 py-2 text-sm ${
            errors.password && touched.password ? "border-red-500" : "border-gray-300"
          } focus:outline-none focus:ring-2`}
        />
      </FormField>

      <FormField label="パスワード(確認)" htmlFor="confirmPassword" error={touched.confirmPassword ? errors.confirmPassword : undefined}>
        <input
          id="confirmPassword"
          type="password"
          value={values.confirmPassword}
          onChange={(e) => handleChange("confirmPassword", e.target.value)}
          onBlur={() => handleBlur("confirmPassword")}
          aria-describedby={errors.confirmPassword && touched.confirmPassword ? "confirmPassword-error" : undefined}
          aria-invalid={!!(errors.confirmPassword && touched.confirmPassword)}
          className={`block w-full rounded border px-3 py-2 text-sm ${
            errors.confirmPassword && touched.confirmPassword ? "border-red-500" : "border-gray-300"
          } focus:outline-none focus:ring-2`}
        />
      </FormField>

      <button
        type="submit"
        disabled={loading}
        className="w-full rounded bg-blue-600 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
      >
        {loading ? "登録中…" : "登録する"}
      </button>
    </form>
  );
}

ポイント

  • aria-describedby でフィールドとエラーメッセージを紐付ける。スクリーンリーダーはフィールドにフォーカスしたときにエラーメッセージを読み上げる。エラー ID は ${fieldId}-error のように一意なパターンで生成する
  • aria-invalid={true} をエラー時のフィールドに付けることで、スクリーンリーダーが「このフィールドに問題がある」と認識できる
  • touched 状態を持ち、フォーカスアウトまたはサブミット後にのみエラーを表示する。初期表示でエラーを出さないことでユーザーへのプレッシャーを減らす
  • 入力中(onChange)にエラーをクリアし、フォーカスアウト(onBlur)で再バリデーションすることで、修正中のストレスを軽減しながら確認タイミングを保てる
  • API エラーはフィールド単位(field プロパティ)とフォーム全体(form)を使い分ける。例えばメールアドレスが重複登録の場合は field: "email" でフィールド近傍に表示し、サーバーエラーはフォーム上部に表示する

注意点

react-toast-error-pattern は画面端への Toast 通知。react-error-retry-boundary は ErrorBoundary によるツリー全体の捕捉。これはフィールド近傍への inline error 表示に特化。Toast や Boundary とは「画面を置き換えずフィールド単位で表示する」切り口で区別する。aria-describedby による a11y 紐付けも示す。

関連サンプル