Reactでファイルをアップロードし、進み具合を表示する方法
React でファイルをアップロードしながら「いま何%まで送ったか」を表示するには、XMLHttpRequest の upload に届く progress イベントで送った量を受け取り、state に入れて <progress> に渡します。中止ボタン、コンポーネントを外した(アンマウントした)ときの後片付け、送り終えてからサーバーが応答するまでの表示まで含めて、カスタムフック useUpload にまとめました。
筆者は検証環境で、受け取ったバイト数を数える小さな Node.js のサーバーと、アップロードの画面を作りました。Chromium を Playwright で操作し、送る速さを 1 MiB/秒 に絞って、画面の表示とサーバーが受けた量を確かめました。
検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し、Chromium 141 を Playwright 1.56.1 で操作しました。サーバーは同じマシンの中で動かした暗号化なしの HTTP/1.1 で、送る速さは Chrome DevTools Protocol のネットワークの制限で絞っています。実際の回線、HTTPS、ほかのブラウザ、複数ファイルの同時アップロードは試していません。

送った量は xhr.upload の progress で受け取る
フックの全体です。状態(status)、送ったバイト数(loaded)、合計(total)を state に持ち、アップロードを始める start と、止める cancel を返します。
import { useCallback, useEffect, useRef, useState } from 'react';
export type UploadStatus = 'idle' | 'uploading' | 'processing' | 'done' | 'error' | 'canceled';
export type UploadState = {
status: UploadStatus;
loaded: number; // 送り終えたバイト数
total: number; // 送るバイト数の合計
message?: string;
};
export function useUpload(url: string) {
const [state, setState] = useState<UploadState>({ status: 'idle', loaded: 0, total: 0 });
const xhrRef = useRef<XMLHttpRequest | null>(null);
const start = useCallback(
(file: File) => {
// 送っている途中なら、前のアップロードは止める
const prev = xhrRef.current;
xhrRef.current = null;
prev?.abort();
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
const isCurrent = () => xhrRef.current === xhr;
setState({ status: 'uploading', loaded: 0, total: file.size });
// 送る側の進み具合は xhr.upload に届く。リスナーは send() の前に付ける
xhr.upload.onprogress = (event) => {
if (!isCurrent() || !event.lengthComputable) return;
setState({ status: 'uploading', loaded: event.loaded, total: event.total });
};
// 送り終えた。ここからはサーバーの応答を待つ
xhr.upload.onload = () => {
if (isCurrent()) setState((s) => ({ ...s, status: 'processing' }));
};
// サーバーの応答が届いた
xhr.onload = () => {
if (!isCurrent()) return;
xhrRef.current = null;
if (xhr.status >= 200 && xhr.status < 300) {
setState((s) => ({ ...s, status: 'done' }));
} else {
setState((s) => ({ ...s, status: 'error', message: `サーバーがエラーを返しました(${xhr.status})` }));
}
};
xhr.onerror = () => {
if (!isCurrent()) return;
xhrRef.current = null;
setState((s) => ({ ...s, status: 'error', message: '通信のエラーで送れませんでした' }));
};
xhr.onabort = () => {
if (!isCurrent()) return;
xhrRef.current = null;
setState((s) => ({ ...s, status: 'canceled' }));
};
const body = new FormData();
body.append('file', file);
xhr.open('POST', url);
xhr.send(body);
},
[url],
);
const cancel = useCallback(() => {
xhrRef.current?.abort();
}, []);
// コンポーネントを外したら、送っている途中のアップロードも止める
useEffect(() => () => xhrRef.current?.abort(), []);
return { state, start, cancel };
}
MDN の XMLHttpRequest.upload のページにあるとおり、送る側の進み具合は xhr そのものではなく xhr.upload に届きます。MDN の、XMLHttpRequest で進み具合を見る方法の説明によると、xhr そのものに付けた progress は、応答を受け取る側の進み具合です。event.lengthComputable が false のときは、MDN の lengthComputable のページの説明では、そのとき total に意味のある値が入りません。そこで、そのときは進み具合を更新しない(バーは 0 のまま)ようにしています。
isCurrent() は、いま扱っている xhr からのイベントかを確かめるためのものです。続けて2回送ったとき、前のアップロードを止めたことで届く abort イベントが、新しいアップロードの表示を書き換えないようにしています。
使う側は、選んだファイルを start に渡し、loaded と total を <progress> に渡します。次は、検証に使った画面から、検証のための記録や切り替えの処理と、テスト用の data-testid を除いたものです。
import { useState } from 'react';
import { useUpload, type UploadStatus } from './useUpload';
const labels: Record<UploadStatus, string> = {
idle: '未送信',
uploading: '送信中',
processing: 'サーバーの処理待ち',
done: '完了',
error: 'エラー',
canceled: '中止しました',
};
export function UploadDemo({ url }: { url: string }) {
const [file, setFile] = useState<File | null>(null);
const { state, start, cancel } = useUpload(url);
const busy = state.status === 'uploading' || state.status === 'processing';
const percent = state.total > 0 ? Math.floor((state.loaded / state.total) * 100) : 0;
return (
<div>
<input type="file" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
<p>
<button disabled={!file || busy} onClick={() => file && start(file)}>
アップロード
</button>
<button disabled={state.status !== 'uploading'} onClick={cancel}>
中止
</button>
</p>
<progress value={state.loaded} max={state.total || 1} />
<p>
{percent}%({(state.loaded / 1024 / 1024).toFixed(1)} / {(state.total / 1024 / 1024).toFixed(1)} MB)
</p>
<p>{labels[state.status]}</p>
{state.message && <p>{state.message}</p>}
</div>
);
}
max を state.total || 1 にしているのは、MDN の <progress> のページに、max は 0 より大きい値でなければならないとあるためです。送る前の合計は 0 なので、そのときは 1 にしています。
1 MiB/秒 に絞って送ると、%が少しずつ伸びた
5 MiB(5,242,880 バイト)のファイルを、送る速さを 1 MiB/秒 に絞って送りました。サーバーは、受け取り終えてから1秒待って応答します。保存などの処理にかかる時間のつもりです。
選んだファイルの大きさ (file.size): 5242880
押してから約1000ms: 18%(0.9 / 5.0 MB)、送信中
押してから約2500ms: 48%(2.4 / 5.0 MB)、送信中
送り終えた直後: 100%(5.0 / 5.0 MB)、サーバーの処理待ち、サーバーの記録 {"id":1,"contentLength":5243079,"received":5243079,"ended":true,"aborted":false}
応答が届いたあと: 100%(5.0 / 5.0 MB)、完了
画面の%は、送った量に合わせて伸びました。
progress がどのくらいの間隔で届くかは、React を通さずに、素の XMLHttpRequest で同じく 1 MiB/秒 に絞って 2 MiB を送り、イベントの時刻を記録して確かめました。
send() の前に付けた progress の回数 20、間隔 最小 97ms、最大 101ms、中央値 101ms
約2秒のあいだに progress が20回、約0.1秒おきに届きました。XMLHttpRequest の仕様では、送る側の progress は、前回から「おおよそ50ミリ秒」たっていなければ出さないことになっています。0.1秒という間隔が Chromium そのものの決まりなのか、DevTools Protocol で速さを絞ったことによるものなのかは、切り分けていません。
React の画面のほうで state が変わるたびに残した記録も、約0.1秒おきでした。ただし、React 18 の発表にある自動バッチ処理の説明のとおり、React の外で呼んだ setState もまとめて1回の描画にされることがあります。このあと紹介する、サーバーが 500 を返す検証では、状態の記録に「サーバーの処理待ち」が残りませんでした。そのため、この記録の数はイベントの数と同じとは限りません。
total は、ファイルの大きさ(5,242,880)ではなく 5,243,079 でした。この数は、サーバーが受けた Content-Length と同じです。FormData で送ると、ファイルの前後に区切りの行などが付くので、その分だけ大きくなります。%を出すときは、file.size ではなく event.total で割ると、最後にちょうど 100% になります。
速さを絞らずに、同じマシンの中のサーバーへ同じファイルを送ったときは、状態の記録は「送信中(0)」のすぐ次が「サーバーの処理待ち」で、途中の数字は残りませんでした。手元のサーバーではすぐに送り終わってしまうので、進み具合の表示を確かめるときは、送る速さを絞ります。
100%になっても、まだ終わりではない
xhr.upload の load は、送り終えたときに届きます。サーバーが受け取ったファイルを保存し、応答を返すのは、そのあとです。先ほどの 5 MiB の送信で残した状態の記録のうち、最後の3件です。
最後の3件: [{"t":5087,"status":"uploading","loaded":5160960,"total":5243079},{"t":5189,"status":"processing","loaded":5243079,"total":5243079},{"t":6175,"status":"done","loaded":5243079,"total":5243079}]
押してから送り終えるまで 5034ms、送り終えてから応答まで 986ms
送り終えてから応答が届くまで、約1秒のあいだ、画面は 100% のままでした。このあいだを「完了」と表示すると、まだ保存が終わっていないのに終わったように見えます。そこで、xhr.upload.onload では「サーバーの処理待ち」にし、xhr.onload で応答の状態を見てから「完了」にしています。
サーバーがエラーを返す場合も、送るところまでは進みます。サーバーが 500 を返すようにして送った結果です。
結果: 100%(5.0 / 5.0 MB)、エラー、サーバーがエラーを返しました(500)
バーは 100% まで伸びたうえで、エラーになりました。xhr.onload はサーバーが応答を返せば届くので、成功したかどうかは xhr.status で分けます。
送っている途中で、サーバーが接続を切った場合も試しました。2 MiB を超えたところで、サーバーが接続を閉じるようにしています。
結果: 40%(2.0 / 5.0 MB)、エラー、通信のエラーで送れませんでした
このときは xhr.onload ではなく xhr.onerror が呼ばれ、バーは 40% で止まりました。サーバーの記録を見ると、同じ大きさのリクエストが2件届いていて、どちらも約2 MB のところで切られていました。
サーバーが受けたリクエスト 2 件: [{"id":6,"contentLength":5243079,"received":2113536,"ended":false,"aborted":true,"cut":true},{"id":7,"contentLength":5243079,"received":2113536,"ended":false,"aborted":true,"cut":true}]
1回目が切られたあと、フックのコードは何もしていないのに、ブラウザが同じ内容をもう一度送っていました。2回目も切られてから、エラーになっています。ブラウザがどんな条件で送り直すのかは、調べていません。アップロードを受けるサーバーでは、途中で切れた同じリクエストが、続けて届くこともあると考えておきます。
中止ボタンは abort() を呼ぶ
中止ボタンは xhr.abort() を呼ぶだけです。MDN の abort() のページにあるとおり、送っている途中で止めると abort イベントが届くので、そこで「中止しました」にしています。押してから2秒たったところで中止した結果です。
中止を押した直後: 40%(2.0 / 5.0 MB)、中止しました
1秒後のサーバーの記録: {"id":2,"contentLength":5243079,"received":2129920,"ended":false,"aborted":true}
中止のあとのボタン: アップロード disabled=false、中止 disabled=true
サーバーは約2 MB を受けたところで、リクエストが途中で切れた(aborted)と記録しました。中止したあとは、アップロードのボタンがまた押せるようになっています。
コンポーネントを外したら(アンマウントしたら)、アップロードも止める
フックの最後の useEffect は、コンポーネントを外したときに abort() を呼ぶ後片付けです。React 公式ドキュメントにある、エフェクトの後片付けの説明にあるように、外したときに止めたいものは後片付けで止めます。比べるために、この useEffect だけを取り除いたフックも作りました。送り始めてから1.5秒後にコンポーネントを外し、6秒待ってからサーバーの記録を見た結果です。出力の「記事のフック」が後片付けのある useUpload、「外したときに止めない書き方」が後片付けを取り除いたフックです。
記事のフック: 外した時点でサーバーが受けたバイト数 約1589248、6秒後のサーバーの記録 {"id":3,"contentLength":5243079,"received":1605632,"ended":false,"aborted":true}
外したときに止めない書き方: 外した時点でサーバーが受けたバイト数 約1589248、6秒後のサーバーの記録 {"id":4,"contentLength":5243079,"received":5243079,"ended":true,"aborted":false}
後片付けのある useUpload では、コンポーネントを外した時点でアップロードが止まりました。後片付けを取り除いたフックは、コンポーネントが画面から消えたあとも送り続け、サーバーは最後まで受け取りました。コンポーネントを外したらアップロードも取りやめたいときは、後片付けで止めておきます。逆に、画面を移っても送り続けたいときは、アップロードの処理をページを移っても残るところ(アプリ全体で使う状態など)に置きます。この記事では、その形は試していません。
リスナーは send() の前に付ける
MDN の XMLHttpRequest.upload のページには、upload のリスナーは send() の前に付けないと、イベントが届かないと書かれています。先ほどの素の XMLHttpRequest の検証で、リスナーを send() のあとに付けた場合も試しました。
send() の前に付けた progress の回数 20、間隔 最小 97ms、最大 101ms、中央値 101ms
send() のあとに付けた progress の回数 0
send() のあとに付けたリスナーには、progress が1回も届きませんでした。フックでも、xhr.upload.onprogress などはすべて send() より前で設定しています。同じページには、別のオリジンに送るときは、upload にリスナーを付けると CORS のプリフライトのリクエストが出るようになる、とも書かれています。
fetch には送った量を知らせるオプションがない
MDN の RequestInit のページに並んでいる fetch() のオプションには、送った量を知らせるイベントや関数を渡す項目がありません。そのため、この記事では XMLHttpRequest を使っています。
本文を ReadableStream にして少しずつ渡せば、送った量を数えられるのではないかと考え、試しました。同じページにあるとおり、本文が ReadableStream のときは duplex: 'half' を付けます。
[console.error] Failed to load resource: net::ERR_ALPN_NEGOTIATION_FAILED
ReadableStream を本文にした fetch: TypeError: Failed to fetch(ページの通信は http/1.1)、サーバーに届いたリクエスト 0 件
検証のサーバー(暗号化なしの HTTP/1.1)では、fetch が失敗し、サーバーにはリクエストが届きませんでした。HTTPS や HTTP/2 のサーバーでは試していないので、そこで送れるかどうかは確かめていません。
progress を受け取り、状態を分けて表示する
送った量は xhr.upload の progress で受け取り、event.loaded と event.total を <progress> に渡します。リスナーは send() の前に付けます。100% になったあとはサーバーの処理待ちとして表示し、応答の status を見てから完了にします。中止ボタンと、コンポーネントを外したときの後片付けでは abort() を呼びます。





