HTML フォームの送信2 分で読めます
ページを再読み込みせずに HTML フォームを送信する
fetch と FormData でフォームを送り、訪問者をページにとどめる。フィールドのエラー、読み込み状態、リトライ、スクリプト失敗時のフォールバック付き。
フォームの submit イベントを監視して preventDefault() を呼び、new FormData(form) を fetch と Accept: application/json ヘッダーで送ります。すると Ikiform はリダイレクトではなく JSON で応答します。成功なら 201、そうでなければ各入力欄の横に表示するフィールドのエラーです。スクリプトが動かなくてもプレーンな方法で送信されるよう、フォームには本物の action と method を残しておきましょう。
ここでの例は、ドキュメントページの下部にある Was this page helpful?(このページは役に立ちましたか?)ボックスです。ページにとどまるべき典型例です。読者は作業の途中ですし、1 回のクリックを記録するためにサンクスページへ移動させるのはばかげています。同じパターンはどんなフォームにも使えます。
送信を自分で引き受けると何が変わるか
プレーンなフォーム送信では多くのことを無料でやってもらえます。ブラウザーが読み込み表示を出し、ダブルクリックをほぼ無害にし、戻るボタンを処理し、返ってきたページを表示します。preventDefault() を呼んだ瞬間、そのすべてが自分の仕事になります。fetch の呼び出し自体は 3 行で、このページの残りはブラウザーがやっていた仕事です。
- ブラウザーが最初に制約チェックを実行します。submit イベントはフォームが有効なときだけ発火します。
- リスナーが preventDefault を呼ぶので、タブは遷移しません。
- fetch でフォームのデータを送り、JSON を要求します。
- 201 なら、フォームをステータスメッセージに置き換え、そこにフォーカスを移します。
- 422 やネットワーク障害なら、フィールドの横にメッセージを表示してフォーカスし、再送信できるようにします。
| ブラウザーがしていたこと | 今はスクリプトがすること |
|---|---|
| タブに読み込み状態を表示 | 表示し、スクリーンリーダーにも伝える |
| ダブルクリックをほぼ無害に | 送信中の 2 回目の submit を無視する |
| エラーページを表示 | error.fields を各入力欄に割り当てる |
| ネットワークエラーのページを表示 | 失敗したリクエストとタイムアウトを捕捉する |
| 新しいページにフォーカスを移動 | 結果か最初の不正なフィールドにフォーカスを移す |
フォーム
フィールドは 3 つです。必須の「はい/いいえ」、任意のコメント、スクリプトが入れるページのアドレスです。下の定義を試してみてください。これは Ikiform がサーバーで適用するものなので、2,000 文字を超えるコメントはここでも 422 になります。
プレビュー
Ikiform のバリデーターをブラウザーで実行します。何も送信されません。
<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 のページに着きます。スクリプトはそれを改善するだけです。
スクリプト
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 のページで、ブラウザーはスクリプトがクロスオリジンのフレームを読むことを許しません。成功とバリデーションエラーを区別できないので、読者にはどちらの場合もフィードバックがありません。スクリプトが本当に動かせない場合にだけ使ってください。
use:enhance、React Router の <Form> は、ハイドレーション前はプレーンな方法で送信し、その後は再読み込みなしで送信します。それぞれの例は概要にあります。動作を確認する
- フォームの Allowed domains に自分のサイトのドメインを追加し、開発中は
localhostも追加します。空のままにすると、どこからの送信も受け付けます。 - コメントなしで Yes を送信します。フォームが Thanks, that helps. に変わり、Network パネルに
201が表示されます。 - コメントに 2,000 文字を超える文章を貼り付け、開発者ツールで
maxlengthを削除して送信します。コメントの下にメッセージが表示され、フォーカスがそこに移ります。 - Send をダブルクリックします。ダッシュボードの送信データは 2 件ではなく 1 件です。
- JavaScript をオフにして送信します。
_redirectのページに移動します。 - スロットリングを 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 を追加してください。登録したドメインのサブドメインは許可されます。 |
よくある質問
submit リスナーを追加して最初の行で event.preventDefault() を呼び、fetch で自分でデータを送ります。Enter キーにも対応できるよう、ボタンの click ではなくフォームの submit イベントを監視してください。
いいえ。設定すべきではありません。ブラウザーが boundary 文字列付きで multipart/form-data を設定します。自分で書いたヘッダーには boundary がなく、サーバーがボディを解析できません。
はい。submit イベントを監視していれば検証されます。このイベントはブラウザーの制約チェックを通過したあとにだけ発生します。コードから form.submit() を呼ぶとチェックもイベントも飛ばされますが、form.requestSubmit() なら両方が実行されます。
リクエストの処理中は送信を無視し、ページの読み込みごとに冪等性キーを 1 つ、_idempotency フィールドか Idempotency-Key ヘッダーで送ります。それでもリトライが Ikiform に届いた場合は、送信データを再度保存せず、最初の応答を返します。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com