Old-School Front-End / 2026 ・ No.10
いにしえのフロントエンド入門
第10回 JavaScript ④ ── 非同期通信:fetch で、画面を切り替えずにサーバーと話す
公開
AI に頼まず、一行ずつ手で書く「いにしえの」フロントエンド入門。第10回は非同期通信です。
第8回から「ドルの換算は第10回」、第9回では「送信のしくみは第10回」と、ずっと先送りにしてきました。
今回はその両方を片付けます。fetch でサーバーから為替レートを取り、申請を送って申請番号を受け取る。
通信は待たされる・失敗するものなので、その扱い方がこの回の本題です。
今回のゴール
新井先輩、「申請する」を押すと「送信のしくみは第10回で作ります」って言われます。ついに第10回です!
古井ああ。ドルの換算もずっと「第10回」と書いてきたからな。今日はツケを払う日だ。
新井AI に「フォームを送信して」と頼んだら、送ったとたんに画面が真っ白になりました。エラーのときは入力も全部消えて……。
古井ページごと切り替わる、昔ながらの送り方だな。今日は画面はそのままで、裏でサーバーと話す。
いまここ:第10回
- レートを取ってくる ── ページを開いたら、サーバーに「1ドル何円?」と聞き、ドルの行を円に換算して合計に入れる
- 申請を送る ── 「申請する」で中身を領収書ごと送り、返ってきた申請番号を表示する
- 二重に送らない ── 送っているあいだはボタンを押せなくする
- 失敗しても消さない ── サーバーが故障していても、通信が切れても、入力した内容はそのまま
相手のサーバーは、この回のために用意した練習用の小さなものです(Node.js で約60行。中身は本題ではないので、行き先と返事の形だけ押さえれば十分です)。為替レート 151.23 も、このサーバーが返す架空の値です。
待つ・待たない
サーバーの返事は、すぐには来ません。今回の練習用サーバーで約0.6秒、遠いサーバーや混んでいるときなら数秒かかります。その間ずっと画面が固まっていたら困りますね。
電話で保留にされるか、注文票を渡して席に戻るか
JavaScript の fetch は後者です。頼みを出したらすぐ次の行へ進み、返事が来たときに「続き」を動かします。本当にそうなるか、3行で試しました。
console.log('A');
fetch('/api/rates').then(() => console.log('C'));
console.log('B');
# 実行された順番と、A からの時間(Chromium で実測)
A: fetch を呼ぶ前 0 ms
B: fetch の次の行 0 ms
C: 返事が来た(then の中) 605 ms
書いた順は A・C・B なのに、動いた順は A・B・C。fetch がすぐに返すのは、返事そのものではなく「返事が来たら渡します」という引換券(Promise)です。続きは then に書くか、async を付けた関数の中で await を使って「ここで引換券を待つ」と書きます。この回のコードは、読みやすい await で書いています。
新井「非同期」って、要するに「待たない」ってことなんですね。
古井そう。待っているあいだも画面は動く。そのかわり「返事を使う処理」を書く場所を間違えると、まだ空っぽの値を使ってしまう。
頼みと返事:HTTP と JSON
ブラウザとサーバーのやりとり
頼み(リクエスト)は、メソッドと行き先でできています。もらうだけなら GET、何かを渡すなら POST。返事(レスポンス)には、うまくいったかを表す3桁の状態コードが付いています。
| コード | 意味 | この回で返すとき |
|---|---|---|
| 200 | OK | レートを返す |
| 201 | 作りました | 申請を受け付けて、番号を返す |
| 400 | 頼み方がおかしい | サーバーのチェックに引っかかった |
| 404 | そんな行き先はない | URL の書き間違い |
| 500 | サーバーの中で故障 | (サーバーの都合。利用者は悪くない) |
返事の本文は、たいてい JSON です。JavaScript のオブジェクトによく似た書き方の「文字」で、練習用サーバーのレートはこう返ってきます。
{"base":"USD","quote":"JPY","rate":151.23,"date":"2026-10-17"}
届くのはあくまで文字なので、res.json() でオブジェクトに戻してから data.rate のように使います。
為替レートを取ってくる
form.js に足した loadRate です。ページの最後で一度だけ呼びます。
/** 為替レートをサーバーから取ってくる */
async function loadRate() {
rateNote.textContent = '為替レートを取得中…';
try {
const res = await fetch('/api/rates', { signal: AbortSignal.timeout(10000) }); // 10秒で諦める
if (!res.ok) throw new Error(`HTTP ${res.status}`); // 404 や 500 では、fetch はエラーにならない
const data = await res.json(); // 本文の JSON を、オブジェクトに
rate = data.rate;
rateNote.textContent = `ドルは 1ドル = ${rate}円 で換算しています(${data.date} のレート)`;
} catch (error) {
rateNote.textContent = '為替レートを取得できませんでした。ドルの行は合計に入れていません。';
}
updateTotal();
}
- async と await ── await は「引換券が返事に変わるまで、この関数の続きを待つ」。待つのはこの関数だけで、画面は止まらない
- 最初は null ── レートが届くまで rate は null。そのあいだのドルの行は合計に入れず、「取得中…」と知らせる
- 呼ぶ側は待たない ── 最後の loadRate(); には await を付けない。返事を待たずに、最初の1行の表示まで先に済ませる
- 時間切れを決める ── fetch には、はじめから時間切れがない。AbortSignal.timeout(10000) で「10秒で諦める」と決めておく
これまでの回のように form.html をダブルクリックで開く(アドレスが file:// で始まる)と、レートは取れません。Chromium のコンソールには「Cross origin requests are only supported for protocol schemes: … http, https …」と出て、画面には「為替レートを取得できませんでした」。fetch を試すときは、サーバーを立てて http:// で開くのが約束です。
fetch がエラーにしないもの
上のコードに if (!res.ok) throw … という行がありました。これがないとどうなるか、いろいろな相手に fetch してみました。
# fetch の結果(Chromium で実測) 相手 エラーになる? 中身 ある場所(/api/rates) ならない ok: true / 200 ない場所(/api/nothing) ならない ok: false / 404 ← ok を見ないと「成功」扱い サーバーの故障(500) ならない ok: false / 500 ← ok を見ないと「成功」扱い サーバーが止まっている なる TypeError: Failed to fetch 返事が遅い(1秒で打ち切り) なる TimeoutError: signal timed out
行き先がない(404)も、サーバーの故障(500)も、fetch にとっては「返事はちゃんと届いた」ので成功です。エラー(catch へ飛ぶ)になるのは、サーバーにたどり着けなかったときと、時間切れのときだけ。だから res.ok(状態コードが 200 番台なら true)を自分で確かめ、だめなら throw で catch へ回します。
fetch の結果の分かれ道
新井AI が書いた fetch、res.ok なんて見ていませんでした……。
古井うまくいく日にしか試さないと、気づかないんだ。サーバーが止まる日は、必ず来る。
ドルを円に:お金は整数で
レートが取れたら換算です。ホテル代 189.50 ドルのように、ドルにはセントがあります。素直に「ドル × レート」としたくなりますが、試すと……。
# $100.00 を 1ドル = 151.23円 で換算(Chromium で実測) 100 * 151.23 → 15122.999999999998 // 小数のまま掛ける Math.floor(100 * 151.23) → 15122 ← 1円足りない centsToYen(10000, 151.23) → 15123 // セントと 1/100 円の整数で(centsToYen)
100 ドルが 1 円少なくなりました。第7回の 0.1 + 0.2 と同じで、コンピューターは 151.23 のような小数をぴったりには持てません。そこで ドルはセント、レートは 1/100 円の単位の整数にしてから掛けます。
整数にしてから掛けて、最後に1回だけ割る
// ---- 第10回:ドルの換算 ----
/**
* ドルの入力欄の文字を、セント(1/100 ドル)の整数に変える。'189.5' → 18950
* 空欄なら null、読めなければ NaN を返す(toAmount と同じ約束)
*/
function toCents(text) {
const s = String(text).normalize('NFKC').replaceAll(',', '').trim();
if (s === '') return null;
if (!/^[0-9]+(\.[0-9]{1,2})?$/.test(s)) return NaN; // 小数点以下は2桁まで
const [dollars, cents = ''] = s.split('.');
return Number(dollars) * 100 + Number(cents.padEnd(2, '0')); // '.5' は 50 セント
}
/**
* セントを円に換算する(1円未満は切り捨て)
* rate は「1ドル = 151.23円」の 151.23。小数のまま掛けると誤差が出るので、整数にしてから掛ける
*/
function centsToYen(cents, rate) {
const sen = Math.round(rate * 100); // 151.23 → 15123(1/100 円の単位の整数)
return Math.floor(cents * sen / 10000); // 整数どうしの掛け算なので、誤差が出ない
}
toCents の正規表現は、第9回の /^[0-9]+$/ の続きです。( … ) はひとまとまり、? は「あってもなくてもいい」、{1,2} は「1〜2回」。つまり「数字のあとに、小数点と1〜2桁の数字が付いてもいい」。1円未満の切り捨ては、社内の規程で決まる話なので、実際の仕事では経理に確かめてください。
合計の計算(第8回の updateTotal)は、ドルの行をこう扱うように変えました。
for (const row of tbody.rows) {
const text = row.querySelector('[name="amount"]').value;
const usd = row.querySelector('[name="currency"]').value === 'USD';
const amount = usd ? toCents(text) : toAmount(text);
if (amount === null) continue; // 空欄は数えない
if (Number.isNaN(amount)) { unreadable++; continue; } // 金額として読めない
if (!usd) items.push({ amount });
else if (rate === null) waiting++; // レートがまだ無い
else items.push({ amount: centsToYen(amount, rate) }); // ドルは円に換算
}
第9回の validateClaim にも、レートを3つめの引数で渡すようにして、ドルの行を合計(3万円の領収書ルール)に入れています。手作りのテストは4件増えて18件。
✓ 正しい申請は合格
✓ 件名が全角スペースだけ
✓ 明細がすべて空
✓ 空の行は数えない
✓ 金額「約1万」
✓ 金額「0」
✓ 明日の日付
✓ 今日の日付は OK
✓ 合計 29,999 円・領収書なし
✓ 合計 30,000 円・領収書なし
✓ 合計 30,000 円・領収書あり
✓ ドルの行も換算して合計(3万円以上で領収書)
✓ 規程の確認なし
✓ 2行目だけ内容が空
✓ レートがないとドルの行は申請できない
✓ toCents('189.5') は 18950
✓ toCents('1.234') は読めない
✓ centsToYen(10000, 151.23) は 15123
入力チェックと換算のテスト: 18 件中 18 件 合格
申請を送る:POST と FormData
送る中身は、new FormData(form, button) で作ります。フォームの欄を name ごとに集め、領収書のファイルまでまとめて詰めてくれます。第3回で form に書いた enctype="multipart/form-data" と同じ形です。
/** 送信中は、送信ボタンを押せなくする(二重送信を防ぐ) */
function setSending(sending, button) {
button.dataset.label ??= button.textContent;
for (const b of form.querySelectorAll('button[type="submit"]')) b.disabled = sending;
button.textContent = sending ? '送信中…' : button.dataset.label;
form.setAttribute('aria-busy', String(sending));
}
/** 結果を表示する(kind は 'success' か 'danger') */
function showResult(kind, message) {
result.className = `alert alert-${kind}`;
result.textContent = message;
result.hidden = false;
}
/** 申請をサーバーへ送る */
async function send(data, button) {
setSending(true, button);
try {
const url = form.getAttribute('action'); // form.action だと name="action" のボタンが返ってくる(実測)
const res = await fetch(url, { method: 'POST', body: data, signal: AbortSignal.timeout(15000) }); // 15秒で諦める
if (res.status === 400) { // サーバーのチェックに引っかかった(第9回と同じ形で返ってくる)
const body = await res.json();
showErrors(body.errors);
summary.focus();
return; // return しても finally は動く
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
showResult('success', body.draft ? `下書きを保存しました(${body.id})。` : `申請を受け付けました。申請番号は ${body.id} です。`);
} catch (error) {
showResult('danger', '送信できませんでした。通信の状態を確かめて、もう一度押してください。入力した内容は消えていません。');
} finally {
setSending(false, button); // うまくいっても、失敗しても、ボタンを元に戻す
}
}
form.addEventListener('submit', async (event) => {
event.preventDefault(); // ページの切り替えは止めて、fetch で送る
result.hidden = true;
const button = event.submitter;
if (!button.formNoValidate) { // 「下書き保存」はチェックしない(第9回)
const errors = validateClaim(readClaim(), localDate(), rate);
showErrors(errors);
if (errors.length > 0) {
summary.focus();
return;
}
}
await send(new FormData(form, button), button); // フォームの中身を、ファイルごと詰める(押したボタンも入れる)
});
古井白状すると、最初は fetch(form.action, …) と書いて、送った先が「/[object RadioNodeList]」になった。
新井なんですかそれ。
古井第3回で、ボタンに name="action" を付けただろう。フォームは name の付いた部品を自分の性質として持つから、form.action が行き先ではなくボタン2つを指してしまったんだ。
ほかにも、フォームを fetch で送るときの落とし穴を確かめました。
# フォームを fetch で送るときの落とし穴(Chromium で実測) form.action → [object RadioNodeList](name="action" のボタン2つ) form.getAttribute('action') → '/api/expenses' new FormData(form).get('action') → null(押したボタンが入らない) new FormData(form, button).get('action') → 'submit' 添付なしの receipt → 名前 ''・0 バイトのファイルが1つ届く headers に 'Content-Type' を自分で書く → サーバーは 400(boundary が消えて読めない)
- 行き先は getAttribute で ── form.action は、name="action" の部品に負ける。MDN にも同じ注意がある
- 押したボタンも渡す ── FormData の2つめに submitter を渡すと、ふつうの送信と同じく action=submit が入る。サーバーはこれで下書きか申請かを見分ける
- 空のファイル欄 ── 何も添付しなくても「名前なし・0バイト」のファイルが届く。サーバーは数ではなく中身の大きさで数える
- Content-Type は書かない ── FormData のときはブラウザが区切りの印(boundary)付きで書いてくれる。自分で書くと印が消えて、サーバーが読めない
そしてサーバー側では、画面と同じ validate.js でもう一度チェックしています。第9回で「画面に触らない関数」にしておいたので、サーバーでもそのまま使えました(本物のサーバーは別の言語で書くことも多いので、そのときは同じルールを書き写します)。開発者ツールのつもりで画面のチェックを消し、件名を空のまま送ってみると――
サーバーは 400 と、第9回と同じ形の { errors: [{ field, message }] } を返しました。返事の形をそろえておいたので、画面は showErrors をそのまま呼ぶだけです。
送信中・成功・失敗を見せる
送信ボタンの一生
送信中にボタンを押せたままだと、待ちきれない人が2回押して、同じ申請が2件届きます。実際、ボタンを止めない版でダブルクリックすると、サーバーに2回届きました。setSending は送信中、2つのボタンを disabled にして、押したボタンの文字を「送信中…」に変えます。
元に戻す処理は finally に書きます。成功しても、失敗しても、400 で途中で return しても、finally は必ず動くので、戻し忘れがありません。
失敗したときの文言は「どうすればいいか」と「入力は消えていない」の2つを伝えます。ページごと切り替える昔ながらの送り方では、ここで入力が消えてしまうことがありました。fetch なら画面はそのままなので、もう一度押すだけです。
動かしてみる
練習用サーバーを立て、Playwright で実際に入力・クリックしました。レートの返事は約0.6秒、申請の返事は約1.2秒かかるようにしてあります。ブラウザの時計は 2026年10月17日 10:00(東京)に固定しています。
# 実際に操作して記録した値(Chromium。サーバーは練習用) 操作 合計 ボタン 表示 開いた直後 0 円 下書き保存 申請する 為替レートを取得中… 約0.6秒後(レートが届く) 0 円 下書き保存 申請する ドルは 1ドル = 151.23円 で換算しています(2026-10-17 のレート) 3行を入力(うちドル2行) 46,851 円 下書き保存 申請する ― 「申請する」 46,851 円 下書き保存 申請する 合計が3万円以上なので、領収書を添付してください。 領収書を付けて「申請する」 46,851 円 [×]下書き保存 [×]送信中… ― 約1.2秒後(201) 46,851 円 下書き保存 申請する 申請を受け付けました。申請番号は EXP-2026-0012 です。 サーバーが 500 のときに「申請する」 46,851 円 下書き保存 申請する 送信できませんでした。通信の状態を確かめて、もう一度押してください。入力した内容は消えていません。 画面のチェックを消し、件名を空で送る(400) 46,851 円 下書き保存 申請する 件名を入力してください。
- 開いた直後は「取得中…」。約0.6秒後にレートが届き、表示が変わった
- ドル2行を換算して、合計は 3,070 + 28,658 + 15,123 = 46,851 円。3万円を超えたので、第9回のルールで領収書を求められた
- 送信中は2つのボタンが押せず、約1.2秒後に申請番号が表示された
- サーバーが 500 を返しても、件名も明細も領収書も残ったまま
通信は、遅れる・失敗する・二度押されるものとして書きます。うまくいく道は一本ですが、うまくいかない道はいくつもあります。どの道を通っても、利用者が「いま何が起きていて、次に何をすればいいか」分かること。それが画面の側の仕事です。
結 — 一枚まとめと次回予告
- fetch は待たない。返すのは引換券(Promise)で、続きは await の後ろに書く。
- 404 や 500 では fetch はエラーにならない。res.ok を見て、自分で throw。時間切れは AbortSignal.timeout で決める。
- お金は整数で。ドルはセント、レートは 1/100 円にしてから掛け、最後に1回だけ割る。
- 送るのは FormData(form, button)。行き先は getAttribute('action')、Content-Type は書かない。
- 送信中はボタンを止め、finally で戻す。失敗しても入力は消さない。サーバーでも同じルールでチェックする。
新井申請番号が出ました! ……でも先輩、この申請、だれが出したことになってるんですか?
古井いいところに気づいた。今は「新井 太郎」と画面に書いてあるだけだ。次回はログインと画面の切り替え。だれが使っているのかを確かめて、申請一覧・承認の画面へ行き来できるようにするぞ。
参考
- MDN — Using the Fetch API
- MDN — Response: ok property
- MDN — async function
- MDN — try...catch
- MDN — AbortSignal: timeout() static method
- MDN — FormData: FormData() constructor
- MDN — HTMLFormElement (named controls and native properties)
- MDN — HTTP response status codes
- MDN — JSON
ページの写真は、筆者が作ったデモページを、練習用のサーバー(Node.js)経由で2026年10月17日にクラウド上の Chromium(Playwright)で撮影したものです。撮影環境には Hiragino Sans がないため、日本語は Noto Sans CJK JP で表示されています。為替レート 151.23 と申請番号は、練習用サーバーが返す架空の値です。実行された順番とかかった時間、fetch の結果、小数の計算結果、FormData とフォームの落とし穴、二重送信の回数、file:// で開いたときのメッセージ、画面の表示の移り変わり、手作りのテスト18件は、同じ Chromium を Playwright で操作・実行して記録した実際の値です。form.html は Nu Html Checker(vnu)でエラーがないことを確かめています。登場人物は架空の人物です。
いにしえのフロントエンド入門 第10回 — JavaScript ④:非同期通信 / 2026.10