Reactで、ファイルのダウンロードの進み具合を表示する方法
React でファイルをダウンロードしながら「いま何%受け取ったか」を表示するには、fetch の応答の本文(response.body)を getReader() で少しずつ読み、受け取ったバイト数を state に入れて <progress> に渡します。全体の大きさは Content-Length ヘッダーから取ります。読み終えたら、ためておいたデータを Blob にしてファイルとして保存させます。ファイルのダウンロード中に、受け取った量に合わせて伸びるプログレスバーを出したいときに使えます。
この記事では、その処理をカスタムフック useDownload にまとめました。Content-Length がない応答と、gzip で圧縮された応答では、そのまま割り算すると表示がおかしくなります。その対処と、中止ボタン、コンポーネントを外した(アンマウントした)ときの後片付けもあわせて扱います。
検証環境は Ubuntu 24.04、Node.js 22.23.4 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し(開発用のビルド、StrictMode あり)、Chromium 153 を Playwright 1.63.0 で操作しました。サーバーは同じマシンの中で動かした Node.js の HTTP/1.1(暗号化なし)で、16 KiB ずつ間を空けて書き出し、約 1 MiB/秒 で返すようにしています。実際の回線、HTTPS や HTTP/2、Chromium 以外のブラウザ、数百 MB を超える大きなファイルは試していません。

本文を getReader() で読みながら、受け取った量を数える
フックの全体です。状態(status)、受け取ったバイト数(loaded)、全体のバイト数(total。分からないときは null)を state に持ち、ダウンロードを始める start と、止める cancel を返します。
import { useCallback, useEffect, useRef, useState } from 'react';
export type DownloadStatus = 'idle' | 'downloading' | 'done' | 'error' | 'canceled';
export type DownloadState = {
status: DownloadStatus;
loaded: number; // 受け取ったバイト数
total: number | null; // 全体のバイト数。分からないときは null
message?: string;
};
// Content-Length から全体の大きさを読む。読めないときは null
function totalFrom(res: Response): number | null {
// 圧縮して送られたとき、Content-Length は圧縮後の大きさで、読み取る量(展開後)と合わない
if (res.headers.get('Content-Encoding')) return null;
const n = Number(res.headers.get('Content-Length'));
return Number.isFinite(n) && n > 0 ? n : null;
}
// Blob をファイルとして保存させる
function save(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
export function useDownload() {
const [state, setState] = useState<DownloadState>({ status: 'idle', loaded: 0, total: null });
const controllerRef = useRef<AbortController | null>(null);
const start = useCallback(async (url: string, fileName: string) => {
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
setState({ status: 'downloading', loaded: 0, total: null });
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`サーバーがエラーを返しました(${res.status})`);
if (!res.body) throw new Error('本文を読み取れませんでした');
let total = totalFrom(res);
const reader = res.body.getReader();
const chunks: Uint8Array<ArrayBuffer>[] = [];
let loaded = 0;
setState({ status: 'downloading', loaded, total });
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.byteLength;
// 全体より多く届いたら、全体の大きさは当てにならないので「不明」に切り替える
if (total !== null && loaded > total) total = null;
setState({ status: 'downloading', loaded, total });
}
const type = res.headers.get('Content-Type') ?? 'application/octet-stream';
save(new Blob(chunks, { type }), fileName);
setState({ status: 'done', loaded, total: total ?? loaded });
} catch (e) {
if (controller.signal.aborted) {
// 中止したとき。新しいダウンロードに切り替えたときは表示を書き換えない
if (controllerRef.current === controller) setState((s) => ({ ...s, status: 'canceled' }));
return;
}
const message =
e instanceof TypeError
? `通信のエラーで受け取れませんでした(${e.message})`
: e instanceof Error
? e.message
: String(e);
setState((s) => ({ ...s, status: 'error', message }));
} finally {
if (controllerRef.current === controller) controllerRef.current = null;
}
}, []);
const cancel = useCallback(() => {
controllerRef.current?.abort();
}, []);
// コンポーネントを外したら、ダウンロードも止める
useEffect(() => () => controllerRef.current?.abort(), []);
return { state, start, cancel };
}
MDN の、fetch の応答の扱い方の説明にあるとおり、fetch() の Promise は、ステータスとヘッダーが届いた時点で解決します。本文はまだ届いていないことがあります。MDN の Response.body のページにあるとおり、本文は ReadableStream です。getReader() で取ったリーダーの read() を繰り返すと、届いた分が Uint8Array で少しずつ返ってきます。MDN の read() のページの説明では、最後まで読むと done が true になり、途中で失敗すると read() の Promise が拒否されます。そこで、value.byteLength を足していった値を loaded にしています。
読み終えたら、ためておいた chunks から Blob を作り、URL.createObjectURL() で URL を作ります。その URL を href に入れ、download 属性を付けた a 要素をクリックして、保存させます。MDN の a 要素のページによると、download 属性が効くのは、同じオリジンの URL と、同じオリジンの blob:、data: の URL です。保存させたあとは、MDN の URL.revokeObjectURL() のページにある revokeObjectURL() で URL を手放しています。この方法では、ファイルの中身をすべてページのメモリにためてから保存します。
使う側は、loaded と total を <progress> に渡します。次は、検証に使った画面から、テスト用の data-testid を除いたものです。
import { useDownload, type DownloadStatus } from './useDownload';
const labels: Record<DownloadStatus, string> = {
idle: '未ダウンロード',
downloading: 'ダウンロード中',
done: '完了',
error: 'エラー',
canceled: '中止しました',
};
const mb = (n: number) => (n / 1024 / 1024).toFixed(1);
export function DownloadDemo({ url, fileName }: { url: string; fileName: string }) {
const { state, start, cancel } = useDownload();
const busy = state.status === 'downloading';
const { loaded, total } = state;
return (
<div>
<p>
<button disabled={busy} onClick={() => start(url, fileName)}>
ダウンロード
</button>
<button disabled={!busy} onClick={cancel}>
中止
</button>
</p>
{/* 全体が分からないときは value を渡さず、動き続ける表示にする */}
{total ? <progress value={loaded} max={total} /> : <progress />}
<p>
{total
? `${Math.floor((loaded / total) * 100)}%(${mb(loaded)} / ${mb(total)} MB)`
: `${mb(loaded)} MB 受信(全体の大きさは不明)`}
</p>
<p>{labels[state.status]}</p>
{state.message && <p>{state.message}</p>}
</div>
);
}
5 MiB を受け取ると、%が少しずつ伸びて保存された
Content-Length が付いた 5 MiB(5,242,880 バイト)のファイルを、サーバーから約 1 MiB/秒 で返して受け取りました。
応答のヘッダー: content-length=5242880 content-encoding=(なし) transfer-encoding=(なし)
押してから約1000ms: 20%(1.0 / 5.0 MB)、ダウンロード中、value=1097728 max=5242880 position=0.209
押してから約2500ms: 52%(2.6 / 5.0 MB)、ダウンロード中
完了後: 100%(5.0 / 5.0 MB)、完了
保存されたファイル: 名前 sample.bin、5242880 バイト、sha256 58c55e678c799cf1(サーバーのデータ 58c55e678c799cf1)
%は受け取った量に合わせて伸び、保存されたファイルは大きさも中身(SHA-256 の先頭)もサーバーのデータと同じでした。URL.revokeObjectURL() を a.click() のすぐあとに呼んでも、Chromium では最後まで保存されています。
getReader() で読む場合、1回の read() で受け取る量はコードでは指定しません。この検証では、1 MiB を受け取るのに read() が64回、1回あたり 16,384 バイトで、サーバーが書き出した 16 KiB と同じ大きさでした。
1 MiB を受け取った read() の回数 64、1回の量 最小 16384、最大 16384、合計 1048576
setState は read() のたびに呼ばれますが、%の文字が書き換わったのは、5 MiB の受け取りで101回、約 0.05 秒おきでした。
Content-Length がないときは、受け取った量だけを出す
サーバーが全体の大きさを決めずに少しずつ送る場合(HTTP/1.1 の Transfer-Encoding: chunked)は、Content-Length ヘッダーが付きません。MDN の Content-Length のページにも、HTTP/1.1 では、大きさを計算しながら少しずつ送る応答で chunked に置き換えられる、とあります。MDN の Headers.get() のページにあるとおり、応答にないヘッダーを get() すると null が返ります。MDN の Number のページにあるとおり、Number(null) は 0 になるので、フックの totalFrom() は null(不明)を返します。
total が null のあいだ、画面では <progress> に value を渡しません。MDN の <progress> のページによると、value 属性がない <progress> は、どれだけかかるか分からない作業を表す、動き続ける表示(indeterminate)になります。3 MiB を chunked で受け取った結果です。
応答のヘッダー: content-length=(なし) transfer-encoding=chunked
押してから約1500ms: 1.5 MB 受信(全体の大きさは不明)、ダウンロード中、value=null max=null position=-1.000
完了後: 100%(3.0 / 3.0 MB)、完了、value=3145728 max=3145728 position=1.000
受け取っているあいだは value 属性がなく、position は -1 でした。MDN の HTMLProgressElement.position のページにあるとおり、-1 は indeterminate の状態を表します。受け取り終えたら、total を受け取った量にして 100% を表示しています。
gzip で圧縮された応答は、Content-Length で割ると1,000%を超えた
サーバーが本文を gzip で圧縮して返すと、応答に Content-Encoding: gzip が付きます。MDN の Content-Encoding のページによると、このとき Content-Length などは、元のデータではなく圧縮したあとの大きさを表します。一方で、read() で読めるのは、ブラウザが展開したあとのデータです。
圧縮しやすい 3 MiB のデータを gzip で返し、React を使わずに Content-Length で割ってみました。
Content-Length 243172、Content-Encoding gzip、読み取った合計 3145728 バイト、loaded / Content-Length = 1293%
Content-Length は 243,172 バイトで、読み取った合計は 3,145,728 バイトでした。そのまま割ると 1,293% になります。そこでフックの totalFrom() は、Content-Encoding が付いていたら全体の大きさを null(不明)にしています。同じ応答をフックで受け取ると、表示は最初から「0.1 MB 受信(全体の大きさは不明)」のような形になり、保存されたファイルは 3,145,728 バイトでした。
フックで完了後: 100%(3.0 / 3.0 MB)、保存されたファイル 3145728 バイト
フックの表示の移り変わり(最初の3件と最後): 0.1 MB 受信(全体の大きさは不明) → 0.3 MB 受信(全体の大きさは不明) → 0.5 MB 受信(全体の大きさは不明) → … → 100%(3.0 / 3.0 MB)
別のオリジンからは Content-Encoding が読めない
ダウンロードするファイルが別のオリジン(スキーム・ホスト・ポートのどれかが違うサーバー)にあると、Content-Encoding を確かめるやり方が使えなくなります。MDN の CORS で読めるレスポンスヘッダーの説明にあるとおり、別のオリジンへのリクエストでページから読めるヘッダーは、最初は Content-Length や Content-Type など7つだけです。Content-Encoding は入っていません。
ポート番号を変えた別のサーバーから、Access-Control-Allow-Origin: * を付けて gzip の応答を返した結果です。
[cors=1] 届いたヘッダー content-encoding=gzip、ページから読めた値 Content-Length=243172 Content-Encoding=null
[cors=1] フックの表示の移り変わり(最初の3件と最後): 0%(0.0 / 0.2 MB) → 41%(0.1 / 0.2 MB) → 0.3 MB 受信(全体の大きさは不明) → … → 100%(3.0 / 3.0 MB)
ブラウザには Content-Encoding: gzip が届いていますが、ページの res.headers.get('Content-Encoding') は null でした。そのため、フックは最初、圧縮後の 243,172 バイトを全体として数え、41% と表示しました。その次に表示が変わったときには、ループの中の確認(loaded > total なら null にする)が効いて、「全体の大きさは不明」に切り替わっています。この確認を入れていない、React を使わない先ほどの例では、最後に 1,293% になっていました。
サーバーの側で Access-Control-Expose-Headers に Content-Encoding を足すと、ページから読めるようになりました。
[cors=1&expose=1] 届いたヘッダー content-encoding=gzip、ページから読めた値 Content-Length=243172 Content-Encoding=gzip
[cors=1&expose=1] フックの表示の移り変わり(最初の3件と最後): 0.1 MB 受信(全体の大きさは不明) → 0.3 MB 受信(全体の大きさは不明) → 0.5 MB 受信(全体の大きさは不明) → … → 100%(3.0 / 3.0 MB)
この記事のフックでは、圧縮された応答は受け取った量だけを表示する、としています。
中止ボタンは AbortController の abort() を呼ぶ
fetch に AbortController の signal を渡しておくと、abort() で止められます。MDN の、fetch のリクエストを中止する方法の説明によると、fetch() が解決したあと、本文を読む前に中止した場合も、本文の読み取りが AbortError で拒否されます。検証では、本文を読んでいる途中で中止しても、フックの catch に入りました。フックでは、catch で controller.signal.aborted を見て、中止なら「中止しました」にしています。押してから2秒たったところで中止した結果です。
中止を押した直後: 41%(2.1 / 5.0 MB)、中止しました
1秒後: 41%(2.1 / 5.0 MB)、サーバーの記録 {"id":6,"mode":"length","size":5242880,"sent":2211840,"finished":false,"closedByClient":true,"bodyBytes":5242880}、保存の回数 0
中止のあとのボタン: ダウンロード disabled=false、中止 disabled=true
サーバーは約 2.1 MB を送ったところで、ブラウザ側から接続を閉じられた(closedByClient)と記録しました。途中までのデータは保存されず(保存の回数 0)、ダウンロードのボタンはまた押せるようになっています。
コンポーネントを外したら、ダウンロードも止める
フックの最後の useEffect は、コンポーネントを外したときに abort() を呼ぶ後片付けです。React 公式ドキュメントにある、エフェクトの後片付けの説明のとおり、外したときに止めたい処理は後片付けで止めます。受け取り始めてから1.5秒後にコンポーネントを外し、外してから0.2秒後と、さらに4秒後に、サーバーの記録を見ました。
外した時点でサーバーが送ったバイト数 約1687552、4秒後のサーバーの記録 {"id":7,"mode":"length","size":5242880,"sent":1687552,"finished":false,"closedByClient":true,"bodyBytes":5242880}、保存の回数 0
外した0.2秒後と4秒後で、サーバーが送った量は変わらず、接続はブラウザ側から閉じられていました。画面が消えたあとに保存の操作が走ることもありませんでした。
途中で接続が切れたときと、サーバーがエラーを返したとき
2 MiB を送ったところで、サーバーが接続を切るようにして受け取りました。
[console.error] Failed to load resource: net::ERR_CONTENT_LENGTH_MISMATCH
結果: 40%(2.0 / 5.0 MB)、エラー、通信のエラーで受け取れませんでした(network error)
read() が TypeError(メッセージは network error)で拒否され、バーは 40% で止まりました。fetch は、ヘッダーを受け取った時点で成功しているので、本文の途中で切れたことは read() のエラーで知ることになります。
MDN の、応答のステータスを確かめる方法の説明にあるとおり、サーバーが 404 を返したときは、fetch() そのものは失敗しません。res.ok が false になるので、本文を読む前にエラーにしています。
結果: 0.0 MB 受信(全体の大きさは不明)、エラー、サーバーがエラーを返しました(404)
ダウンロードの進み具合は、XMLHttpRequest の progress イベントで受け取ることもできます(MDN の、XMLHttpRequest で進み具合を見る方法の説明)。この記事では、fetch で書いた処理のまま進み具合を出せるように、getReader() で読む形にしました。





