Reactのフォームで、エラーを出すタイミングを決める方法
フォームのエラーは、入力の途中で出すと早すぎ、送信まで待つと遅すぎます。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 にまとめました。値を受け取り、欄ごとのエラーの文を返すだけの関数です。
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)の定義を省いています。
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 の中でその欄を検証します。
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」でやっと消えました。正しく入れている途中なのに、ずっとエラーが出ている状態です。次の出力は、何文字目に何を入れたときに、どのエラーが出ていたかの記録です。
email 1文字目「t」: メールアドレスの形で入力してください
(中略)
email 13文字目「.」: メールアドレスの形で入力してください
email 14文字目「c」: なし
欄を離れたときに検証するフォームでは、onBlur で検証します。React の onBlur は、要素からフォーカスが外れたときに呼ばれます。
function handleBlur(name: keyof Values) {
setErrors({ ...errors, [name]: validate(values)[name] });
}
「tama」と入れているあいだはエラーが出ず、Tab で次の欄に移った瞬間に出ました。ここまでは自然です。ところが、メールの欄に戻って tama@example.com に直しても、エラーは残ったままでした。消えたのは、もう一度欄を離れたときです。
「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 の主な部分です。
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」を入れた瞬間に、欄を離れなくても消えました。
「tama」と入れた直後: email=なし / password=なし
Tab でパスワードの欄へ: email=メールアドレスの形で入力してください / password=なし
(中略)
email 13文字目「.」: メールアドレスの形で入力してください
email 14文字目「c」: なし
パスワードを空のまま「登録する」を押すと、触れていないパスワードの欄にもエラーが出て、フォーカスがパスワードの欄に移りました。8文字目を入れた瞬間にエラーが消え、もう一度押すと送信できました。
パスワードが空のまま「登録する」を押した: email=なし / password=パスワードは8文字以上にしてください
フォーカスのある欄: #password
(中略)
password 7文字目「3」: パスワードは8文字以上にしてください
password 8文字目「4」: なし
もう一度「登録する」を押した: 送信しました:tama@example.com
この一連の操作を GIF にしました。右上には、出ているエラーの件数を表示しています。新しく出た文、変わった瞬間の件数、押した瞬間のボタンを、ピンクにしています。

空のまま通り過ぎた欄には、送信まで出さない
handleBlur の先頭では、値が空なら touched を立てずに戻っています。この行がなければ、欄をクリックして何も入れずに Tab で移っただけで touched が立ち、validate が返す「メールアドレスを入力してください」が表示されることになります。
MDN の aria-invalid の説明には、必須の欄が空のときは、送信しようとするまで aria-invalid="true" を付けないように、と書かれています。まだ入力している途中かもしれないからです。この書き方に合わせて、空の欄のエラーは送信のときに出すことにしました。検証環境でも、空のまま Tab で通り過ぎたときはエラーが出ず、そのあと送信したときに出て、フォーカスがメールの欄に移りました。
メールの欄から何も入れずに Tab: email=なし / password=なし
そのまま「登録する」を押した: email=メールアドレスを入力してください / password=パスワードは8文字以上にしてください
フォーカスのある欄: #email
エラーと入力欄を結びつける
エラーが出ているあいだ、メールの欄には aria-invalid="true" と aria-describedby="email-error" が付いていました。直したあとは、2つとも外れていました。aria-describedby は、説明している要素の id を並べて、入力欄とその説明を結びつける属性です。
このときのメールの欄: 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> で起き、送信されませんでした。フォーカスはボタンに移っていました。
#timing: ボタンの上端 192px → 220px、高さ 40px
#timing: click の対象 INPUT、click の対象 INPUT、click の対象 FORM
#timing: email=メールアドレスの形で入力してください / password=なし、フォーカス BUTTON
最初の2つの click は、入力欄をクリックしたときのものです。利用者からは、ボタンを押したのに、エラーが出ただけで送信されなかったように見えます。
エラーの文の場所を、最初から空けておく
ずれなければ、押せなくなることもありません。reserveSpace を指定すると、Field はエラーがないときも空の <p> を描きます。CSS で、この <p> に1行分の高さを持たせておきます。
.error { color: #b8325a; margin: 4px 0 0; min-height: 1.5em; line-height: 1.5; }
同じ操作をしたところ、ボタンの位置は 248px のまま変わらず、click はボタンで起き、submit イベントも起きました。送信の処理でメールの欄のエラーが見つかったため、送信はされず、フォーカスがメールの欄に移りました。
#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文字目から叱られることも、直したのにエラーが残ることもなく、送信のときには残りの欄のエラーとフォーカスの移動までそろいました。欄を離れた瞬間にエラーで画面がずれると、そのとき押そうとしたボタンが押せなくなるので、エラーの文の場所は最初から空けておきます。





