Reactのフォームで、エラーを出すタイミングを決める方法

​​React​Playwright,TypeScript,アクセシビリティ,フォーム

フォームのエラーは、入力の途中で出すと早すぎ、送信まで待つと遅すぎます。React では、エラーそのものを state に入れず、代わりに「その欄に触れたか」と「送信したか」を state に持つと、欄を離れたときに出して、直した瞬間に消す動きにできます。

筆者は検証環境で、エラーを出すタイミングを変えた4つのフォームを作り、Chromium で操作して、エラーが出たり消えたりする瞬間を記録しました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し(開発用のビルド)、Chromium(Chrome for Testing 153)を Playwright 1.64.1 で操作しました。操作のあいだ、ブラウザのコンソールにエラーや警告は出ませんでした。ほかのブラウザや、スクリーンリーダーでの読み上げは試していません。

例にするフォームと検証のルール

メールアドレスとパスワードを入れる登録フォームを例にします。検証のルールは、次の validate.ts にまとめました。値を受け取り、欄ごとのエラーの文を返すだけの関数です。

ts
export type Values = { email: string; password: string };
export type Errors = Partial<Record<keyof Values, string>>;

export function validate(values: Values): Errors {
  const errors: Errors = {};
  if (values.email === '') errors.email = 'メールアドレスを入力してください';
  else if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(values.email)) errors.email = 'メールアドレスの形で入力してください';
  if (values.password.length < 8) errors.password = 'パスワードは8文字以上にしてください';
  return errors;
}

入力欄とエラーの文は、Field というコンポーネントで描きます。エラーがあるときだけ、入力欄に aria-invalid と aria-describedby を付けます。どのフォームも <form noValidate> にして、ブラウザの検証は使っていません。次のコードでは、props の型(FieldProps)の定義を省いています。

tsx
export function Field({ name, label, type = 'text', value, error, reserveSpace, onChange, onBlur }: FieldProps) {
  const errorId = `${name}-error`;
  return (
    <div className="field">
      <label htmlFor={name}>{label}</label>
      <input
        id={name}
        name={name}
        type={type}
        value={value}
        onChange={onChange}
        onBlur={onBlur}
        aria-invalid={error ? true : undefined}
        aria-describedby={error ? errorId : undefined}
      />
      {(error || reserveSpace) && (
        <p id={errorId} className="error">
          {error}
        </p>
      )}
    </div>
  );
}

reserveSpace は、あとの節で使う、エラーの文の場所を空けておくための指定です。

入力のたび・欄を離れたとき・送信したときを比べる

まず、検証する瞬間を1つに決めたフォームを3つ作りました。どれも、検証の結果を errors という state に入れて表示します。

入力のたびに検証するフォームでは、onChange の中でその欄を検証します。

tsx
function handleChange(e: ChangeEvent<HTMLInputElement>) {
  const name = e.target.name as keyof Values;
  const next = { ...values, [name]: e.target.value };
  setValues(next);
  setErrors({ ...errors, [name]: validate(next)[name] });
}

メールアドレスの欄に tama@example.com を1文字ずつ入れると、1文字目の「t」の時点で「メールアドレスの形で入力してください」が出ました。そのまま13文字目の「.」まで出続け、14文字目の「c」でやっと消えました。正しく入れている途中なのに、ずっとエラーが出ている状態です。次の出力は、何文字目に何を入れたときに、どのエラーが出ていたかの記録です。

text
  email 1文字目「t」: メールアドレスの形で入力してください
  (中略)
  email 13文字目「.」: メールアドレスの形で入力してください
  email 14文字目「c」: なし

欄を離れたときに検証するフォームでは、onBlur で検証します。React の onBlur は、要素からフォーカスが外れたときに呼ばれます。

tsx
function handleBlur(name: keyof Values) {
  setErrors({ ...errors, [name]: validate(values)[name] });
}

「tama」と入れているあいだはエラーが出ず、Tab で次の欄に移った瞬間に出ました。ここまでは自然です。ところが、メールの欄に戻って tama@example.com に直しても、エラーは残ったままでした。消えたのは、もう一度欄を離れたときです。

text
  「tama」と入れた直後: email=なし / password=なし
  Tab でパスワードの欄へ: email=メールアドレスの形で入力してください / password=なし
  メールの欄に戻り「tama@example.com」に直した直後: email=メールアドレスの形で入力してください / password=パスワードは8文字以上にしてください
  Tab でもう一度パスワードの欄へ: email=なし / password=パスワードは8文字以上にしてください

3行目でパスワードのエラーも出ているのは、メールの欄に戻るときに、空のパスワードの欄からフォーカスが外れたためです。

送信したときに検証するフォームでは、onSubmit ですべての欄を検証します。送信するまでエラーは1つも出ず、送信した瞬間に2つとも出ました。そのあと2つの欄を正しく直しても、送信しないうちは、エラーが残ったままでした。

3つの結果をまとめると、次のとおりです。

検証する瞬間 エラーが出るとき 直したあと、エラーが消えるとき
入力のたび 1文字目から(早すぎる) 正しくなった瞬間
欄を離れたとき 欄を離れたとき もう一度欄を離れたとき(遅い)
送信したとき 送信したとき(遅い) 直しただけでは消えない(遅い)

出すのは「欄を離れたとき」がちょうどよく、消すのは「入力のたび」がちょうどよい、という結果です。

エラーの代わりに、「触れたか」と「送信したか」を state に持つ

出すときと消すときで、タイミングを分けます。そのために、エラーを state に入れるのをやめ、描画のたびに今の値から求めます。入力の値のほかに state に持つのは、欄ごとの「触れたか」(touched)と、「送信したか」(submitted)だけです(sent は、送信できたことを画面に出すためのものです)。次のコードは TimingForm.tsx の主な部分です。

tsx
export function TimingForm({ reserveSpace = false }: { reserveSpace?: boolean }) {
  const [values, setValues] = useState<Values>({ email: '', password: '' });
  const [touched, setTouched] = useState<Touched>({});
  const [submitted, setSubmitted] = useState(false);
  const [sent, setSent] = useState<string | null>(null);
  const formRef = useRef<HTMLFormElement>(null);

  // エラーは state に入れず、今の値から毎回求める
  const errors = validate(values);
  const shown = (name: keyof Values) => (touched[name] || submitted ? errors[name] : undefined);

  function handleChange(e: ChangeEvent<HTMLInputElement>) {
    setValues({ ...values, [e.target.name]: e.target.value });
  }
  function handleBlur(name: keyof Values) {
    // 空のまま通り過ぎただけなら、まだエラーを出さない(出すのは送信のとき)
    if (values[name] === '') return;
    setTouched({ ...touched, [name]: true });
  }
  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setSubmitted(true);
    const first = (Object.keys(values) as (keyof Values)[]).find((name) => errors[name]);
    if (first) {
      // 最初にエラーのある欄へフォーカスを移す
      const input = formRef.current?.elements.namedItem(first);
      if (input instanceof HTMLInputElement) input.focus();
      return;
    }
    setSent(values.email);
  }
  // (JSX は省略。Field に error={shown('email')} などを渡す)
}

エラーを出すかどうかを決めるのは shown です。欄に触れたか、一度でも送信していれば、今の値のエラーを返します。エラーは毎回 validate(values) から求めるので、値が正しくなった次の描画で、何もしなくても消えます。

動きを確かめた結果

検証環境で操作したところ、「tama」と入れているあいだはエラーが出ず、Tab で欄を離れた瞬間に出ました。欄に戻って続きを入れると、14文字目の「c」を入れた瞬間に、欄を離れなくても消えました。

text
  「tama」と入れた直後: email=なし / password=なし
  Tab でパスワードの欄へ: email=メールアドレスの形で入力してください / password=なし
  (中略)
  email 13文字目「.」: メールアドレスの形で入力してください
  email 14文字目「c」: なし

パスワードを空のまま「登録する」を押すと、触れていないパスワードの欄にもエラーが出て、フォーカスがパスワードの欄に移りました。8文字目を入れた瞬間にエラーが消え、もう一度押すと送信できました。

text
  パスワードが空のまま「登録する」を押した: email=なし / password=パスワードは8文字以上にしてください
  フォーカスのある欄: #password
  (中略)
  password 7文字目「3」: パスワードは8文字以上にしてください
  password 8文字目「4」: なし
  もう一度「登録する」を押した: 送信しました:tama@example.com

この一連の操作を GIF にしました。右上には、出ているエラーの件数を表示しています。新しく出た文、変わった瞬間の件数、押した瞬間のボタンを、ピンクにしています。

登録フォームを6回操作した画面。1回目にメールの欄へtamaと入れてもエラーは出ない。2回目に欄の外を押すと、メールの欄の下に「メールアドレスの形で入力してください」がピンクで出る。3回目にメールの続きを入れていくと、途中でエラーが消えて件数が0件になる。4回目に「登録する」を押すと、パスワードの欄の下にエラーが出て、フォーカスがパスワードの欄に移る。5回目にパスワードを8文字入れるとエラーが消え、6回目に「登録する」を押すと「送信しました」が出る
欄を離れたときにエラーが出て、直した瞬間に消え、送信で残りの欄にも出る動き

空のまま通り過ぎた欄には、送信まで出さない

handleBlur の先頭では、値が空なら touched を立てずに戻っています。この行がなければ、欄をクリックして何も入れずに Tab で移っただけで touched が立ち、validate が返す「メールアドレスを入力してください」が表示されることになります。

MDN の aria-invalid の説明には、必須の欄が空のときは、送信しようとするまで aria-invalid="true" を付けないように、と書かれています。まだ入力している途中かもしれないからです。この書き方に合わせて、空の欄のエラーは送信のときに出すことにしました。検証環境でも、空のまま Tab で通り過ぎたときはエラーが出ず、そのあと送信したときに出て、フォーカスがメールの欄に移りました。

text
  メールの欄から何も入れずに Tab: email=なし / password=なし
  そのまま「登録する」を押した: email=メールアドレスを入力してください / password=パスワードは8文字以上にしてください
  フォーカスのある欄: #email

エラーと入力欄を結びつける

エラーが出ているあいだ、メールの欄には aria-invalid="true" と aria-describedby="email-error" が付いていました。直したあとは、2つとも外れていました。aria-describedby は、説明している要素の id を並べて、入力欄とその説明を結びつける属性です。

text
  このときのメールの欄: aria-invalid=true aria-describedby=email-error
  直したあとのメールの欄: aria-invalid=null aria-describedby=null

送信のときに最初にエラーのある欄へフォーカスを移す処理では、form.elements の namedItem() で、name から欄を探しています。

欄を離れた瞬間にボタンがずれ、押せなくなる

欄を離れたときにエラーを出すと、気をつけることが1つあります。メールの欄に「tama」と入れたまま、すぐに「登録する」をクリックした場合です。

マウスのボタンを押し下げた時点で、メールの欄からフォーカスが外れ、エラーの文が出ます。文の高さの分だけ「登録する」が下にずれ、ボタンを離す位置にはもうボタンがありません。MDN の click イベントの説明のとおり、押した要素と離した要素が違うときは、両方を含むいちばん内側の祖先の要素で click が起きます。

検証環境で、Playwright でボタンの中央を押し下げてから離したところ、ボタンの上端が 192px から 220px に下がりました。click は <form> で起き、送信されませんでした。フォーカスはボタンに移っていました。

text
  #timing: ボタンの上端 192px → 220px、高さ 40px
  #timing: click の対象 INPUT、click の対象 INPUT、click の対象 FORM
  #timing: email=メールアドレスの形で入力してください / password=なし、フォーカス BUTTON

最初の2つの click は、入力欄をクリックしたときのものです。利用者からは、ボタンを押したのに、エラーが出ただけで送信されなかったように見えます。

エラーの文の場所を、最初から空けておく

ずれなければ、押せなくなることもありません。reserveSpace を指定すると、Field はエラーがないときも空の <p> を描きます。CSS で、この <p> に1行分の高さを持たせておきます。

css
.error { color: #b8325a; margin: 4px 0 0; min-height: 1.5em; line-height: 1.5; }

同じ操作をしたところ、ボタンの位置は 248px のまま変わらず、click はボタンで起き、submit イベントも起きました。送信の処理でメールの欄のエラーが見つかったため、送信はされず、フォーカスがメールの欄に移りました。

text
  #reserve: ボタンの上端 248px → 248px、高さ 40px
  #reserve: click の対象 INPUT、click の対象 INPUT、click の対象 BUTTON、submit が起きた
  #reserve: email=メールアドレスの形で入力してください / password=なし、フォーカス INPUT#email

aria-describedby は、エラーがあるときだけ付けています。空の <p> を、入力欄の説明として結びつけないためです。上の GIF も、この場所を空けた書き方で撮っています。

ポイント

エラーは state に入れず、毎回 validate(values) で求めます。エラーの代わりに、「触れたか」と「送信したか」を state に持ち、どちらかが立った欄にだけ表示します。エラーの文の場所は最初から空けておき、欄を離れた瞬間にボタンがずれないようにします。

エラーを出すタイミングの決め方

エラーを出すのは、欄を離れたとき(空の欄は送信したとき)にします。消すのは、値が正しくなった瞬間です。React で書くときは、エラーを state に持たずに描画のたびに求めると、出すときと消すときを別々に決められます。検証では、1文字目から叱られることも、直したのにエラーが残ることもなく、送信のときには残りの欄のエラーとフォーカスの移動までそろいました。欄を離れた瞬間にエラーで画面がずれると、そのとき押そうとしたボタンが押せなくなるので、エラーの文の場所は最初から空けておきます。

​Posted by 86