Old-School Front-End / 2026 ・ No.10

いにしえのフロントエンド入門

第10回 JavaScript ④ ── 非同期通信:fetch で、画面を切り替えずにサーバーと話す

公開

Bootstrap の申請フォーム。明細は3行で、1行目が成田エクスプレス 3070 円、2行目がホテル1泊 189.50 ドル、3行目が会議の参加費 100 ドル。合計は 46,851 円で、その下に「ドルは 1ドル = 151.23円 で換算しています(2026-10-17 のレート)」。いちばん下に緑の帯で「申請を受け付けました。申請番号は EXP-2026-0012 です。」
今回のゴール。サーバーからレートを取ってドルを円に換算し、申請を送って申請番号を受け取る(筆者のデモページを、練習用のサーバー経由でクラウド上の Chromium で撮影。Playwright で実際に入力・クリックしたところ。2026年10月17日。ブラウザの枠は説明のために描き足したもの)

AI に頼まず、一行ずつ手で書く「いにしえの」フロントエンド入門。第10回は非同期通信です。
第8回から「ドルの換算は第10回」、第9回では「送信のしくみは第10回」と、ずっと先送りにしてきました。
今回はその両方を片付けます。fetch でサーバーから為替レートを取り、申請を送って申請番号を受け取る。
通信は待たされる・失敗するものなので、その扱い方がこの回の本題です。

一

今回のゴール

新井先輩、「申請する」を押すと「送信のしくみは第10回で作ります」って言われます。ついに第10回です!

古井ああ。ドルの換算もずっと「第10回」と書いてきたからな。今日はツケを払う日だ。

新井AI に「フォームを送信して」と頼んだら、送ったとたんに画面が真っ白になりました。エラーのときは入力も全部消えて……。

古井ページごと切り替わる、昔ながらの送り方だな。今日は画面はそのままで、裏でサーバーと話す。

いまここ:第10回

1 準備 2 HTML の骨組み 3 フォーム 4 CSS の基本 5 レイアウト 6 Bootstrap 7 JS の基本 8 DOM・イベント 9 入力チェック 10 通信 11 ログイン 12 承認の流れ 13 集計と印刷 14 jQuery 15 仕上げ いまここ 準備 HTML CSS JavaScript 実戦 仕上げ
  • レートを取ってくる ── ページを開いたら、サーバーに「1ドル何円?」と聞き、ドルの行を円に換算して合計に入れる
  • 申請を送る ── 「申請する」で中身を領収書ごと送り、返ってきた申請番号を表示する
  • 二重に送らない ── 送っているあいだはボタンを押せなくする
  • 失敗しても消さない ── サーバーが故障していても、通信が切れても、入力した内容はそのまま

相手のサーバーは、この回のために用意した練習用の小さなものです(Node.js で約60行。中身は本題ではないので、行き先と返事の形だけ押さえれば十分です)。為替レート 151.23 も、このサーバーが返す架空の値です。

二

待つ・待たない

サーバーの返事は、すぐには来ません。今回の練習用サーバーで約0.6秒、遠いサーバーや混んでいるときなら数秒かかります。その間ずっと画面が固まっていたら困りますね。

電話で保留にされるか、注文票を渡して席に戻るか

待つ(想像図)  電話で保留 返事が来るまで画面が固まる A C B 待たない(fetch) 注文票を渡して席へ 待つあいだも、入力・スクロールできる A B C 0 ms 200 ms 400 ms 600 ms 800 ms A=fetch を呼ぶ前 B=fetch の次の行 C=返事が来たとき(then の中) fetch は頼むだけで、すぐ次の行へ進む(実測:B は 0 ms、C は 605 ms) だから「返事の後にしたいこと」は、then か await の後ろに書く

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 /api/rates 200 OK {"rate": 151.23, …} POST /api/expenses 本文:FormData(multipart) 201 Created {"id": "EXP-2026-0012"} レートを 申請を JSON で 番号を返す 頼み(リクエスト)=メソッド+行き先(+本文)。返事(レスポンス)=状態コード+本文 本文の形は JSON が定番。res.json() でオブジェクトに戻す

頼み(リクエスト)は、メソッドと行き先でできています。もらうだけなら GET、何かを渡すなら POST。返事(レスポンス)には、うまくいったかを表す3桁の状態コードが付いています。

コード意味この回で返すとき
200OKレートを返す
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 の結果の分かれ道

fetch(…) 頼む 届いた? 届かない・時間切れ catch 送れなかった 返事あり res.ok ? true 2xx 成功 false 400 直してもらう 404・500 サーバーの問題 throw して catch へ fetch がエラーになるのは「返事がない」ときだけ(実測:404 も 500 もならない) だから res.ok を見て、だめなら自分で throw する

新井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回だけ割る

ドル → 円は、整数にしてから掛ける $189.50 ドル 18950 セント 151.23 1ドル何円 15123 1/100 円 18950 × 15123 ÷ 10000 → 28658 小数のまま掛けると(実測) 100 * 151.23 → 15122.999999999998 Math.floor(…) → 15122 ← 1円足りない 第7回の 0.1 + 0.2 と同じ罠。お金は整数で数える
// ---- 第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回で「画面に触らない関数」にしておいたので、サーバーでもそのまま使えました(本物のサーバーは別の言語で書くことも多いので、そのときは同じルールを書き写します)。開発者ツールのつもりで画面のチェックを消し、件名を空のまま送ってみると――

フォームの上に赤い帯のまとめ「入力内容を確認してください(1件)」と「件名を入力してください。」のリンク。その下の件名の欄は空で、赤い枠が付いている
画面のチェックを JavaScript ごと消して送っても、サーバーが同じ validate.js で止め、その返事で赤い枠が出た(筆者のデモページを、練習用のサーバー経由でクラウド上の Chromium で撮影。Playwright で実際に入力・クリックしたところ。2026年10月17日。ブラウザの枠は説明のために描き足したもの)

サーバーは 400 と、第9回と同じ形の { errors: [{ field, message }] } を返しました。返事の形をそろえておいたので、画面は showErrors をそのまま呼ぶだけです。

八

送信中・成功・失敗を見せる

送信ボタンの一生

申請する 押せる 送信中… disabled(2つとも) 申請する finally で戻す 送信ボタンの一生 ダブルクリックすると(実測) ボタンを止めない版 POST POST 送信中は disabled にする版 POST 成功しても失敗しても戻すので、戻す処理は finally に1回だけ書く

送信中にボタンを押せたままだと、待ちきれない人が2回押して、同じ申請が2件届きます。実際、ボタンを止めない版でダブルクリックすると、サーバーに2回届きました。setSending は送信中、2つのボタンを disabled にして、押したボタンの文字を「送信中…」に変えます。

元に戻す処理は finally に書きます。成功しても、失敗しても、400 で途中で return しても、finally は必ず動くので、戻し忘れがありません。

3つの場面。1 送信中は「下書き保存」も「送信中…」も押せない。2 返事が来て、緑の帯で申請番号が表示された。3 サーバーが 500 を返したときは赤い帯で「送信できませんでした」。領収書の欄にはどれも receipt.pdf が残っている
①送信中、②受け付けられた、③サーバーの故障。失敗しても入力は消えない(筆者のデモページを、練習用のサーバー経由でクラウド上の Chromium で撮影。Playwright で実際に入力・クリックしたところ。2026年10月17日。ブラウザの枠は説明のために描き足したもの)

失敗したときの文言は「どうすればいいか」と「入力は消えていない」の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 を返しても、件名も明細も領収書も残ったまま
POINT

通信は、遅れる・失敗する・二度押されるものとして書きます。うまくいく道は一本ですが、うまくいかない道はいくつもあります。どの道を通っても、利用者が「いま何が起きていて、次に何をすればいいか」分かること。それが画面の側の仕事です。

結 — 一枚まとめと次回予告

  1. fetch は待たない。返すのは引換券(Promise)で、続きは await の後ろに書く。
  2. 404 や 500 では fetch はエラーにならない。res.ok を見て、自分で throw。時間切れは AbortSignal.timeout で決める。
  3. お金は整数で。ドルはセント、レートは 1/100 円にしてから掛け、最後に1回だけ割る。
  4. 送るのは FormData(form, button)。行き先は getAttribute('action')、Content-Type は書かない。
  5. 送信中はボタンを止め、finally で戻す。失敗しても入力は消さない。サーバーでも同じルールでチェックする。

新井申請番号が出ました! ……でも先輩、この申請、だれが出したことになってるんですか?

古井いいところに気づいた。今は「新井 太郎」と画面に書いてあるだけだ。次回はログインと画面の切り替え。だれが使っているのかを確かめて、申請一覧・承認の画面へ行き来できるようにするぞ。

参考

ページの写真は、筆者が作ったデモページを、練習用のサーバー(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

← 記事一覧へ