概要
fetch API はアップロード進捗を取得できないため、XMLHttpRequest の upload.onprogress イベントを使って進捗率を取得しプログレスバーで表示する。キャンセル機能も合わせて実装する。
インストール
# 追加インストールは不要
実装
アップロード API(Route Handler)
// app/api/upload/route.ts
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const formData = await request.formData();
const file = formData.get("file");
if (!file || typeof file === "string") {
return NextResponse.json({ error: "ファイルが必要です" }, { status: 400 });
}
// 実際のアプリではここでストレージへ保存する
// await saveToStorage(file)
return NextResponse.json({ message: "アップロード完了" });
}
進捗付きアップロードコンポーネント
// components/UploadWithProgress.tsx
"use client";
import { useRef, useState } from "react";
type UploadState = "idle" | "uploading" | "done" | "error" | "cancelled";
export function UploadWithProgress() {
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState<UploadState>("idle");
const [message, setMessage] = useState<string | null>(null);
const xhrRef = useRef<XMLHttpRequest | null>(null);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
uploadFile(file);
}
function uploadFile(file: File) {
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
const formData = new FormData();
formData.append("file", file);
// 進捗イベント: loaded / total から % を計算
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
setProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onloadstart = () => {
setStatus("uploading");
setProgress(0);
setMessage(null);
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
setStatus("done");
setMessage("アップロードが完了しました");
} else {
setStatus("error");
setMessage("アップロードに失敗しました");
}
};
xhr.onerror = () => {
setStatus("error");
setMessage("ネットワークエラーが発生しました");
};
xhr.onabort = () => {
setStatus("cancelled");
setProgress(0);
setMessage("キャンセルしました");
};
xhr.open("POST", "/api/upload");
xhr.send(formData);
}
function handleCancel() {
xhrRef.current?.abort();
}
return (
<div className="space-y-4">
<input
type="file"
onChange={handleChange}
disabled={status === "uploading"}
className="block text-sm text-gray-600 file:mr-4 file:rounded file:border-0 file:bg-blue-50 file:px-4 file:py-2 file:text-sm file:font-medium file:text-blue-700 hover:file:bg-blue-100 disabled:opacity-50"
/>
{status === "uploading" && (
<div className="space-y-2">
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-200">
<div
className="h-full rounded-full bg-blue-500 transition-all duration-200"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center justify-between text-xs text-gray-500">
<span>{progress}%</span>
<button
type="button"
onClick={handleCancel}
className="rounded bg-gray-100 px-2 py-1 hover:bg-gray-200"
>
キャンセル
</button>
</div>
</div>
)}
{message && (
<p
className={`text-sm ${
status === "done" ? "text-green-600" : "text-red-500"
}`}
>
{message}
</p>
)}
</div>
);
}
ページへの組み込み
// app/page.tsx
import { UploadWithProgress } from "@/components/UploadWithProgress";
export default function Page() {
return (
<main className="mx-auto max-w-md p-8">
<h1 className="mb-6 text-2xl font-bold">ファイルアップロード</h1>
<UploadWithProgress />
</main>
);
}
ポイント
fetchはupload.onprogressに対応していないため進捗を取得できない。進捗表示が必要な場合はXMLHttpRequestを使うxhr.upload.onprogressイベントのe.loaded/e.totalから進捗率を計算する。e.lengthComputableがfalseの場合は合計サイズが不明なため計算をスキップするxhr.abort()でアップロードを中断できる。onabortイベントが発火するので UI を「キャンセル済み」に更新するuseRefで XHR インスタンスを保持する。useStateにすると状態更新のたびに再レンダリングが走るためrefが適切- プログレスバーは
width: progress + "%"をインラインスタイルで設定する。Tailwind の動的クラス(w-[N%])はビルド時にパージされる可能性があるためインラインスタイルが安全