HTML フォームの送信

HTML フォームの送信2 分で読めます

ページを再読み込みせずに HTML フォームを送信する

fetch と FormData でフォームを送り、訪問者をページにとどめる。フィールドのエラー、読み込み状態、リトライ、スクリプト失敗時のフォールバック付き。

Preet SutharIkiform 創業者

フォームの submit イベントを監視して preventDefault() を呼び、new FormData(form) を fetch と Accept: application/json ヘッダーで送ります。すると Ikiform はリダイレクトではなく JSON で応答します。成功なら 201、そうでなければ各入力欄の横に表示するフィールドのエラーです。スクリプトが動かなくてもプレーンな方法で送信されるよう、フォームには本物の action と method を残しておきましょう。

ここでの例は、ドキュメントページの下部にある Was this page helpful?(このページは役に立ちましたか?)ボックスです。ページにとどまるべき典型例です。読者は作業の途中ですし、1 回のクリックを記録するためにサンクスページへ移動させるのはばかげています。同じパターンはどんなフォームにも使えます。

送信を自分で引き受けると何が変わるか

プレーンなフォーム送信では多くのことを無料でやってもらえます。ブラウザーが読み込み表示を出し、ダブルクリックをほぼ無害にし、戻るボタンを処理し、返ってきたページを表示します。preventDefault() を呼んだ瞬間、そのすべてが自分の仕事になります。fetch の呼び出し自体は 3 行で、このページの残りはブラウザーがやっていた仕事です。

ブラウザーに遷移させず、スクリプトが fetch でフォームを送信するときの処理submit イベントブラウザー自身のチェック後preventDefault()遷移なしfetchFormDataAccept:application/json201お礼の一文に差し替えフィールドにメッセージフォーカスを移し、ボタンを再び有効に422 かネットワークエラーブラウザーに遷移させず、スクリプトが fetch でフォームを送信するときの処理submit イベントブラウザー自身のチェック後preventDefault()遷移なしfetch FormDataAccept:application/json201お礼の一文に差し替えフィールドにメッセージフォーカスを移し、ボタンを再び有効に422 かネットワークエラー
  1. ブラウザーが最初に制約チェックを実行します。submit イベントはフォームが有効なときだけ発火します。
  2. リスナーが preventDefault を呼ぶので、タブは遷移しません。
  3. fetch でフォームのデータを送り、JSON を要求します。
  4. 201 なら、フォームをステータスメッセージに置き換え、そこにフォーカスを移します。
  5. 422 やネットワーク障害なら、フィールドの横にメッセージを表示してフォーカスし、再送信できるようにします。
ブラウザーのバリデーションは今もコードより先に実行されます。preventDefault 以降はすべて自分で処理します。
ブラウザーがしていたこと今はスクリプトがすること
タブに読み込み状態を表示表示し、スクリーンリーダーにも伝える
ダブルクリックをほぼ無害に送信中の 2 回目の submit を無視する
エラーページを表示error.fields を各入力欄に割り当てる
ネットワークエラーのページを表示失敗したリクエストとタイムアウトを捕捉する
新しいページにフォーカスを移動結果か最初の不正なフィールドにフォーカスを移す

フォーム

フィールドは 3 つです。必須の「はい/いいえ」、任意のコメント、スクリプトが入れるページのアドレスです。下の定義を試してみてください。これは Ikiform がサーバーで適用するものなので、2,000 文字を超えるコメントはここでも 422 になります。

プレビュー

Ikiform のバリデーターをブラウザーで実行します。何も送信されません。

このページは役に立ちましたか?
これをフォームの Fields タブに貼り付けます。フィールドを定義すると、Ikiform は知らないフィールドも拒否します。
feedback.html
<form id="page-feedback" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <fieldset>
    <legend>Was this page helpful?</legend>
    <label><input type="radio" name="helpful" value="yes" required /> Yes</label>
    <label><input type="radio" name="helpful" value="no" /> No</label>
  </fieldset>

  <label for="comment">What was missing?</label>
  <textarea id="comment" name="comment" rows="3" maxlength="2000" aria-describedby="comment-error"></textarea>
  <p id="comment-error" hidden></p>

  <input type="hidden" name="page" />
  <input type="hidden" name="_redirect" value="https://docs.example.com/feedback-sent" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send</button>
  <p id="feedback-status" role="status" tabindex="-1"></p>
</form>

<script type="module" src="/page-feedback.js"></script>

フォームはスクリプトなしでも完結しています。JavaScript がオフやブロックされていればプレーンな方法で送信され、Ikiform が 303 を返し、読者は _redirect のページに着きます。スクリプトはそれを改善するだけです。

スクリプト

page-feedback.js
const form = document.querySelector("#page-feedback");
const status = form.querySelector("#feedback-status");
const button = form.querySelector("button");
const pageInput = form.elements.namedItem("page");
const idempotencyKey = crypto.randomUUID();
let sending = false;

pageInput.value = location.href;

function showFieldErrors(fields) {
  for (const input of form.querySelectorAll("[aria-invalid]")) input.removeAttribute("aria-invalid");
  for (const hint of form.querySelectorAll("[id$='-error']")) hint.hidden = true;
  let first = null;
  for (const [name, messages] of Object.entries(fields)) {
    const control = form.elements.namedItem(name);
    const hint = form.querySelector(`#${name}-error`);
    if (control instanceof HTMLElement) control.setAttribute("aria-invalid", "true");
    if (hint) {
      hint.textContent = messages[0];
      hint.hidden = false;
    }
    first ??= control instanceof RadioNodeList ? control[0] : control;
  }
  first?.focus();
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  if (sending) return;
  const body = new FormData(form, event.submitter);
  body.set("_idempotency", idempotencyKey);
  sending = true;
  button.setAttribute("aria-disabled", "true");
  status.textContent = "Sending...";

  try {
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body,
      signal: AbortSignal.timeout(15000),
    });
    const result = await response.json();

    if (result.ok) {
      form.replaceChildren(status);
      status.textContent = "Thanks, that helps.";
      status.focus();
      return;
    }
    if (result.error.code === "challenge_required") {
      form.submit();
      return;
    }
    if (result.error.fields) {
      status.textContent = "";
      showFieldErrors(result.error.fields);
    } else if (response.status === 429 && response.headers.has("Retry-After")) {
      const wait = response.headers.get("Retry-After");
      status.textContent = `Too many tries. Wait ${wait} seconds and send again.`;
    } else {
      status.textContent = result.error.message;
    }
  } catch {
    status.textContent = "Couldn't reach the server. Check your connection and send again.";
  } finally {
    sending = false;
    button.removeAttribute("aria-disabled");
  }
});
  • new FormData(form, event.submitter) は名前付きのすべてのコントロールと、クリックされたボタンに name と value があればそれも読み取ります。フォームの中身を変える前に作ってください。
  • ボタンには disabled ではなく aria-disabled を付けます。実際にブロックするのは sending フラグで、ボタンはフォーカス可能なままなので、スクリーンリーダーのユーザーが位置を見失いません。
  • Content-Type ヘッダーは付けません。FormData のボディなら、ブラウザーが正しい境界付きで multipart/form-data を設定します。
  • Accept: application/json で Ikiform は JSON で応答します。fetch 呼び出しは Sec-Fetch-Mode: cors を送るのでいずれにせよ JSON が選ばれますが、ヘッダーで意図がはっきりします。
  • ページの読み込みごとに冪等性キーを 1 つ作り、_idempotency フィールドとして送ります。読者が送信し、ネットワークが応答を落とし、もう一度送信しても、Ikiform は 2 件目の送信データを保存せず、最初の応答を再送します。拒否された送信はキーを使わないので、修正しての再送信は通ります。Idempotency-Key ヘッダーでも同じことができますが、カスタムヘッダーだとブラウザーが先にプリフライトリクエストを送ります。正確なルールは JavaScript と fetch にあります。
  • AbortSignal.timeout(15000) により、応答のないリクエストは終わらない Sending... ではなく catch の分岐に入ります。
  • ステータス行には role="status" があるので、フォーカスを動かさずにテキストが読み上げられます。成功時は周りのフィールドがなくなるので、フォーカスをそこに移します。

フォームに人間確認があるとき

フォームの Spam check が Ikiform に設定されていると、Turnstile のトークンなしの fetch 送信は 403 challenge_required になります。上のスクリプトは form.submit() を呼んでこれに対処します。このメソッドは submit リスナーを飛ばしてプレーンな方法で送信し、プレーンな送信に対して Ikiform は自前の短い確認ページを表示してから _redirect のページに進みます。読者がページを離れるのは、チェックがオンのときだけです。

その場合でもページにとどめるには、先に SDK の challenge(formId) でトークンを取得し、submit() に渡します。流れはスパム対策のドキュメントにあります。

JavaScript を使わない代替手段

fetch が登場する前は、非表示の iframe をターゲットにして応答をそこに読み込み、ページをそのままにするのが定番の手でした。

昔の手法
<iframe name="feedback-sink" hidden></iframe>
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post" target="feedback-sink">
  ...
</form>

今でも動きますが、iframe が保持するのは ikiform.com のページで、ブラウザーはスクリプトがクロスオリジンのフレームを読むことを許しません。成功とバリデーションエラーを区別できないので、読者にはどちらの場合もフィードバックがありません。スクリプトが本当に動かせない場合にだけ使ってください。

サーバーフレームワークなら、クライアントの fetch コードなしで再読み込みをなくせます。Next.js のサーバーアクション、SvelteKit の use:enhance、React Router の <Form> は、ハイドレーション前はプレーンな方法で送信し、その後は再読み込みなしで送信します。それぞれの例は概要にあります。

動作を確認する

  1. フォームの Allowed domains に自分のサイトのドメインを追加し、開発中は localhost も追加します。空のままにすると、どこからの送信も受け付けます。
  2. コメントなしで Yes を送信します。フォームが Thanks, that helps. に変わり、Network パネルに 201 が表示されます。
  3. コメントに 2,000 文字を超える文章を貼り付け、開発者ツールで maxlength を削除して送信します。コメントの下にメッセージが表示され、フォーカスがそこに移ります。
  4. Send をダブルクリックします。ダッシュボードの送信データは 2 件ではなく 1 件です。
  5. JavaScript をオフにして送信します。_redirect のページに移動します。
  6. スロットリングを Offline にして送信します。ステータス行にサーバーに接続できなかったと表示され、Send がまた使えるようになります。

ハマりどころ

問題原因と対処
送信データにフィールドが含まれていないFormData を作った時点で disabled だったためです。無効化されたコントロールは送信されません。先にデータを作るか、readonly で入力欄をロックしてください
400 invalid_body または空の送信データコードで Content-Type: multipart/form-data を手書きしていて、boundary がありません。ヘッダーを削除してください
それでもページが再読み込みされるリスナーが preventDefault() の前に例外を投げたか、スクリプトの読み込み前にクリックされました。最初に preventDefault() を呼んでください。早すぎるクリックは普通の方法で送信され、それでも正しく動きます
response.json() が例外を投げるリクエストが HTML を返す場所に送られています。たいていは相対パスの action です。コンソールで form.action を確認してください。Ikiform の URL になっている必要があります
403 challenge_requiredフォームの人間確認がオンになっています。上のように form.submit() にフォールバックするか、challenge() でトークンを取得してください
403 origin_not_allowed が返る、またはすべての送信がスパムになるページのドメインがフォームの Allowed domains にありません。ドメインと、ローカルテスト用の localhost を追加してください。登録したドメインのサブドメインは許可されます。

よくある質問

手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com