はじめに (対象読者・この記事でわかること)

この記事は、フロントエンド開発者、特に JavaScript で DOM 操作や非同期処理に慣れている方 を対象としています。
blur イベントでサーバーサイドバリデーションや重い計算を走らせ、結果が返ってくるまでユーザーに送信を許可したくない といったシーンに直面したことがある方に最適です。
本記事を読むことで、以下が実現できます。

  • blur 発火時に非同期バリデーション(例: API 呼び出し)を開始する方法
  • バリデーション完了まで 送信ボタンを無効化 し、完了後に自動で有効化する実装パターン
  • フォーム全体の送信ハンドラで、未完了タスクが残っている場合に送信をキャンセルする安全策

これらをマスターすれば、ユーザー体験を損なわずに正確なデータ送信が可能になります。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。

  • HTML/CSS の基本的な知識(特に <form><input><button> の構造)
  • JavaScript(ES6 以降)の基本的な文法と async/await、Promise の概念
  • DOM イベント(blursubmit)の取り扱い方

blur と非同期バリデーションの概要

blur イベントは、要素からフォーカスが外れた瞬間に発火します。入力フィールドの 「離れたときに即座にサーバーへ検証」 という要件は、このイベントが最適です。
しかし、blur ハンドラ内で非同期処理(fetchaxios での API 呼び出し)を行うと、ユーザーはすぐに送信ボタンをクリックできてしまい、結果が返る前にフォームが送信されてしまう という問題が起きます。

この課題を解決するために、以下の二つのポイントを抑える必要があります。

  1. 非同期タスクの進行状況を UI に反映させる
    - バリデーション開始時にボタンを disabled にし、ローディングインジケータを表示
    - 完了時に disabled を解除し、インジケータを隠す

  2. 送信ハンドラでタスク完了をチェックする
    - 送信イベント (submit) が発火した際に、保留中の Promise が残っていれば送信を中止し、完了後に再送信させる

この二段階の防御策を組み合わせることで、「blur が開始した非同期処理が完了するまで送信は絶対に通さない」 という堅牢な実装が可能になります。

具体的な手順や実装方法

以下では、実際に動くサンプルコードを交えて、上記のポイントを順に実装していきます。
本稿のコードは Vanilla JavaScript(外部ライブラリなし)で書かれているため、どのフレームワークでも概念はそのまま流用できます。

ステップ1:HTML の用意

まずは最小限のフォーム構造を作ります。data-validation-promise 属性で、各入力が保有する非同期タスクへの参照を保持させるテクニックを紹介します。

Html
<form id="userForm"> <label> メールアドレス: <input type="email" id="email" name="email" required /> <span class="error-msg" id="emailError"></span> </label> <br /> <button type="submit" id="submitBtn" disabled>送信</button> <span id="loading" style="display:none;">🔄 検証中...</span> </form>
  • 初期状態で 送信ボタンは disabled にしています。ページロード後に全入力が「検証済み」になるまで有効化しません。
  • #loading は検証中に表示するインジケータです。

ステップ2:blur ハンドラで非同期バリデーションを走らせる

次に、blur 時にサーバー側 API へメールアドレスの重複チェックを行う例です。fetchasync/await でラップし、Promise を入力要素に保持します。

Js
const form = document.getElementById('userForm'); const emailInput = document.getElementById('email'); const submitBtn = document.getElementById('submitBtn'); const loading = document.getElementById('loading'); const emailError = document.getElementById('emailError'); // 入力要素が保有できるカスタム属性に Promise を保存 emailInput.dataset.validationPromise = null; // 非同期バリデーション関数(擬似 API) async function validateEmail(email) { // デモ用に 1.5 秒待機し、重複判定をランダムに返す await new Promise(r => setTimeout(r, 1500)); const isDuplicate = Math.random() < 0.3; // 30% の確率で重複とみなす if (isDuplicate) { throw new Error('このメールアドレスは既に使用されています'); } return true; } // blur 時に呼び出すハンドラ emailInput.addEventListener('blur', async (e) => { const value = e.target.value.trim(); // 空はすぐにエラー表示だけで終了 if (!value) { emailError.textContent = '必須項目です'; e.target.dataset.validationPromise = null; updateSubmitState(); return; } // UI フィードバック loading.style.display = 'inline'; submitBtn.disabled = true; // 検証中は送信不可 // Promise を生成して要素に保存 const validationPromise = (async () => { try { await validateEmail(value); emailError.textContent = ''; return true; } catch (err) { emailError.textContent = err.message; return false; } finally { loading.style.display = 'none'; updateSubmitState(); // 他のフィールドが全て OK ならボタン有効化 } })(); e.target.dataset.validationPromise = validationPromise; });

ポイント解説

  1. dataset.validationPromise に Promise を格納
    - これにより、後から「このフィールドの検証がまだ終わっていないか」を簡単に判定できます。
  2. UI の即時更新
    - loading 表示と submitBtn.disabled = true で、ユーザーが送信できないことを視覚的に示します。
  3. エラーメッセージの表示
    - バリデーション失敗時は <span class="error-msg"> にエラーを書き込み、次回の blur で再チェックします。

ステップ3:全体の送信可否判定ロジック

blur ハンドラだけでは不十分です。ユーザーが Enter キー で直接送信したり、JavaScript が無効化された状態で 自動送信 が走ったりするケースがあります。そこで submit イベントで保留中タスクを一括チェックします。

Js
function updateSubmitState() { // すべての必須入力が空でなく、かつ validationPromise が null または fulfilled(true) の場合に有効化 const emailPromise = emailInput.dataset.validationPromise; const isEmailReady = emailPromise === null || (emailPromise instanceof Promise && emailPromise._resolved); // _resolved は後述するカスタムフラグ if (emailInput.value && isEmailReady) { submitBtn.disabled = false; } else { submitBtn.disabled = true; } } // Promise の完了時にカスタムフラグを付与(IE など古い環境は不可ですが、実務では問題なし) function attachResolvedFlag(p) { p.then(() => { p._resolved = true; }).catch(() => { p._resolved = true; }); return p; } // blur ハンドラ内で生成した Promise にフラグ付与 emailInput.addEventListener('blur', async (e) => { // ... 前述のコード省略 ... const validationPromise = (async () => { /* ... */ })(); attachResolvedFlag(validationPromise); e.target.dataset.validationPromise = validationPromise; });

実装上の注意
JavaScript の標準 Promise には「完了状態を外部から取得」する手段がありません。ここでは カスタムフラグ (_resolved) を付与 するテクニックを使っています。プロダクションコードでは Promise.racePromise.allSettled を活用しても構いませんが、可読性の観点から上記のように明示的にフラグを残す手法がシンプルです。

ステップ4:submit イベントで保留タスクを待つ

Js
form.addEventListener('submit', async (e) => { // すべての入力が持つ validationPromise があるかチェック const pendingPromises = []; // email の場合だけ例示(他フィールドが増えたら配列で回す) const emailPromise = emailInput.dataset.validationPromise; if (emailPromise && !(emailPromise._resolved)) { pendingPromises.push(emailPromise); } if (pendingPromises.length) { // まだ検証が走っている → 送信を一時停止 e.preventDefault(); // ブラウザ側のデフォルト送信を止める submitBtn.disabled = true; loading.style.display = 'inline'; try { // すべての検証が終わるまで待機 const results = await Promise.all(pendingPromises); // すべて true なら手動で送信 if (results.every(r => r === true)) { // 失敗していないので再度 submit を発火 form.submit(); // 再帰的に呼び出すと無限ループになるので、一度だけ } else { // バリデーションエラーがあったら UI は既に表示済み console.warn('バリデーションエラーが残っています'); } } finally { loading.style.display = 'none'; updateSubmitState(); } } // pendingPromises が空なら通常通り送信される });

コードの流れ

  1. 保留中の Promise があるか調査
    - dataset.validationPromise が存在し、かつ _resolved が付いていなければ「まだ走っている」判定。
  2. 送信をキャンセル (e.preventDefault())
    - ユーザーがクリックした瞬間でも、サーバーにデータが届く前に止められます。
  3. Promise.all で全タスク完了まで待機
    - ここでエラーがあれば catch で UI が既に更新済みなので、再送は行わない。
  4. 全タスクが成功したら手動で form.submit()
    - 1 回だけ手動送信することで、再度 submit ハンドラに入らないように注意(form.submit() はイベントを発火させません)。

ハマった点やエラー解決

発生した問題 原因 解決策
submit 直後に blur がまだ走っていると、validationPromisenull になる blur ハンドラが非同期で走るため、submit 時点でまだ dataset に格納されていない blur ハンドラの開始時点で 空の PromisePromise.resolve())を保存し、後で上書きする
form.submit() が再度 submit ハンドラを呼び出す form.submit() はイベントを発火させないはずだが、ブラウザ実装差異があった form.dispatchEvent(new Event('submit', {cancelable:true})) を使わず、単に form.submit() に任せる。IE での互換性は問題なし
Promise の完了状態が外部から取得できない 標準 API に isFulfilled が無い カスタムフラグ _resolvedthen/catch 内で設定するヘルパー関数 attachResolvedFlag を作成
UI がロックされたまま解除されない 例外が catch で握られず、finally が実行されなかった try…catch…finally を必ず使用し、例外が起きても loadingdisabled をリセット

解決策まとめ

  1. 空の Promise を先に格納し、blur が終わったら上書きする
  2. Promise に完了フラグ_resolved)を付与し、submit 時に「まだ走っているか」を判定
  3. submit ハンドラで保留タスクを待機し、完了後に手動送信
  4. エラーハンドリングは必ず try…catch…finally で包むことで、ローディング状態が残らないようにする

まとめ

本記事では、blur イベントで開始した非同期バリデーションが完了するまでフォーム送信を防止する実装パターン を段階的に解説しました。

  • blur 時に Promise を生成し、要素の dataset に保持することで、各フィールドの検証状態を外部から参照できるようにした。
  • 送信ボタンの無効化とローディングインジケータでユーザーに処理中であることを明示し、誤送信を防止した。
  • submit ハンドラで保留中の Promise を集約し await Promise.allで完了を待ち、全てが成功したら手動で form.submit() を実行した。

これにより、ユーザーはバリデーションが完了するまでボタンをクリックできず、バックエンド側で不正データが流入するリスクを大幅に低減できます。

次回は、複数フィールドに対する同時検証React/Vue と組み合わせた実装例、さらに Web Workers を活用した重い処理のオフロードについても取り上げる予定です。

参考資料