Use Cases (更新: 2026/6/7)

ファイルアップロードの進捗バーが嘘をつく問題と、ドラッグ&ドロップ実装の勘所

fetchだと進捗が取れない理由、XHRで本物の進捗バーを出す方法、ドラッグ&ドロップ、失敗時リトライ、presigned URL直アップロードまで、コピペで動くコードで解説します。

ファイルアップロードの進捗バーが嘘をつく問題と、ドラッグ&ドロップ実装の勘所

「アップロード中…」のバーが100%になったのに、画面が固まったまま動かない。

リロードして気づきました。そのバー、setIntervalで0から100まで数を増やしていただけで、実際の転送とは一ミリも連動していなかったんです。ネットワークが速い社内では一瞬で終わるから誰も気づかない。客先の細い回線で初めてバレる。最悪のタイミングですよね。

ファイルアップロードの見た目は「選んで送るだけ」です。でも、進捗バーとドラッグ&ドロップを”それっぽく”ではなく”本物”で作ろうとすると、急に地雷が増えます。今日はその地雷を、僕が踏んだ順に潰していきます。基本的な検証や保存先の話はClaude Codeでファイルアップロードを安全に実装する側に寄せて、ここでは送信中のUIに全振りします。

この記事の要点

  • ブラウザ標準のfetchアップロードの進捗イベントを持っていない。本物の進捗バーが要るならXMLHttpRequestupload.onprogressを使う。
  • ドラッグ&ドロップはdragoverpreventDefault()を忘れるとブラウザがファイルを開いて画面遷移してしまう。ここが最頻出のつまずき。
  • クライアント検証(サイズ・拡張子・MIME)は親切なUIであって防壁ではない。サーバー側でも必ず見る。
  • 失敗時リトライは「指数バックオフ+同じFileを再送」で十分実用になる。
  • 100MBを超えるような大容量は、サーバーを経由せずpresigned URLでストレージへ直接送ると詰まらない。

なぜ進捗バーは嘘をつくのか

まず犯人をはっきりさせます。fetchです。

fetchは今どきの標準APIで、書き味も気持ちいい。小さいファイルをポンと送るだけなら、これで完璧です。でもfetchには弱点があって、送信中に「今どれだけ送れたか」を教えてくれるイベントがありません。ダウンロード側はresponse.bodyのストリームで進捗が取れるのですが、アップロード側はそうはいかない。2026年現在もこの状況は変わっていません(MDNのFetch APIを見ても、リクエストボディの進捗イベントは載っていません)。

だから「fetchで送りつつ、進捗は別途タイマーで演出」みたいなコードが生まれる。これが冒頭の”嘘つきバー”の正体です。

本物の進捗が欲しいなら、古株のXMLHttpRequest(XHR)を引っ張り出します。XHRにはxhr.uploadという送信専用オブジェクトがあって、ここにprogressイベントが飛んできます。event.loaded(送れたバイト数)とevent.total(全体)を割れば、ごまかしのない%が出ます。古い技術ですが、この一点においてはまだXHRが正解です。

やりたいことfetchXMLHttpRequest
普通に送る得意一応できる
アップロード進捗を出すできないupload.onprogressで取れる
中断(キャンセル)AbortControllerxhr.abort()
書き味モダンで簡潔古くて冗長

結論はシンプルです。進捗バーが要る画面だけXHR、それ以外はfetch。混ぜて使って構いません。

ドラッグ&ドロップで一番こける場所

次はドラッグ&ドロップ。ここで9割の人がハマるポイントを先に言います。

dragoverイベントでevent.preventDefault()を呼ばないと、ドロップが効きません。 それどころか、ブラウザが「お、ファイルが落ちてきたな」と気を利かせて、そのファイルをタブで開いてしまう。せっかく作ったアップロード画面が、ドロップした瞬間にPDFビューアに化けるわけです。初見だと原因が分からず小一時間溶かします(僕がそうでした)。

理由はこうです。ブラウザのデフォルト動作は「ドロップされたファイルを開く」。それを止める意思表示がpreventDefault()なんです。dragoverdropの両方で呼ぶ必要があります。

もう一つの細かい罠が、dragenterdragleaveの数です。子要素の上を通るたびにイベントが発火するので、単純にdragleaveでハイライトを消すと、要素の境目でチカチカ点滅します。カウンタで「今いくつの要素にまたがっているか」を数えると安定します。

最小のドロップゾーンはこれだけです。

const zone = document.querySelector("#drop-zone");
let depth = 0; // 何階層の要素にドラッグが重なっているか

// これが無いとブラウザがファイルを開いてしまう(最重要)
zone.addEventListener("dragover", (e) => e.preventDefault());

zone.addEventListener("dragenter", (e) => {
  e.preventDefault();
  depth += 1;
  zone.classList.add("is-dragging");
});

zone.addEventListener("dragleave", () => {
  depth -= 1;
  if (depth === 0) zone.classList.remove("is-dragging"); // 境目の点滅を防ぐ
});

zone.addEventListener("drop", (e) => {
  e.preventDefault();
  depth = 0;
  zone.classList.remove("is-dragging");
  const files = [...e.dataTransfer.files]; // FileListを配列に
  console.log(files.map((f) => f.name));
});

e.dataTransfer.filesが、落とされたファイルの中身です。詳しい仕様はMDNのHTML Drag and Drop APIにまとまっています。クリックでも選べるよう、隠した<input type="file">を併設しておくのが定番です(スマホやアクセシビリティのため、ドラッグだけにしないこと)。

コピペで動く:進捗バー付きReactアップローダ

ここまでを一つにまとめます。ドラッグ&ドロップ、クライアント検証、XHRによる本物の進捗バー、キャンセル、失敗時の自動リトライまで入ったFileUploaderコンポーネントです。そのまま貼って動きます。送信先の/api/uploadは、検証と保存をする側のAPIを想定しています。

import { useRef, useState } from "react";

const MAX_BYTES = 5 * 1024 * 1024; // 5MB
const ALLOWED = new Set(["image/png", "image/jpeg", "application/pdf"]);

type Phase = "idle" | "uploading" | "done" | "error";

// XHRで送る。進捗・キャンセル・エラーを Promise でまとめて扱う
function uploadWithProgress(
  file: File,
  onProgress: (percent: number) => void,
  signal: AbortSignal,
) {
  return new Promise<{ name: string }>((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("POST", "/api/upload");

    // fetch には無い「本物の進捗」。ここが主役
    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
    };

    xhr.onload = () => {
      const body = JSON.parse(xhr.responseText || "{}");
      if (xhr.status >= 200 && xhr.status < 300) resolve(body);
      else reject(new Error(body.error ?? `失敗 (HTTP ${xhr.status})`));
    };
    xhr.onerror = () => reject(new Error("ネットワークエラー"));
    xhr.onabort = () => reject(new DOMException("中断されました", "AbortError"));

    // キャンセルボタンと連動させる
    signal.addEventListener("abort", () => xhr.abort());

    const form = new FormData();
    form.append("file", file);
    // FormData のときは Content-Type を自分で付けない(境界文字列が壊れる)
    xhr.send(form);
  });
}

// 失敗したら指数バックオフで再送(同じ File をそのまま使い回せる)
async function uploadWithRetry(
  file: File,
  onProgress: (p: number) => void,
  signal: AbortSignal,
  maxRetry = 2,
) {
  for (let attempt = 0; ; attempt++) {
    try {
      return await uploadWithProgress(file, onProgress, signal);
    } catch (err) {
      // ユーザーが押した中断はリトライしない
      if (err instanceof DOMException && err.name === "AbortError") throw err;
      if (attempt >= maxRetry) throw err;
      await new Promise((r) => setTimeout(r, 500 * 2 ** attempt)); // 0.5s, 1s, ...
      onProgress(0);
    }
  }
}

export function FileUploader() {
  const [phase, setPhase] = useState<Phase>("idle");
  const [percent, setPercent] = useState(0);
  const [message, setMessage] = useState("");
  const [dragging, setDragging] = useState(false);
  const depth = useRef(0);
  const controller = useRef<AbortController | null>(null);

  // クライアント側の検証(親切なUI。防壁ではない)
  function validate(file: File): string | null {
    if (!ALLOWED.has(file.type)) return "PNG・JPEG・PDFだけ送れます。";
    if (file.size === 0) return "中身が空のファイルです。";
    if (file.size > MAX_BYTES) return "5MBまでにしてください。";
    return null;
  }

  async function start(file: File) {
    const ng = validate(file);
    if (ng) {
      setPhase("error");
      setMessage(ng);
      return;
    }
    controller.current = new AbortController();
    setPhase("uploading");
    setPercent(0);
    setMessage(file.name);
    try {
      const res = await uploadWithRetry(file, setPercent, controller.current.signal);
      setPhase("done");
      setMessage(`保存しました: ${res.name}`);
    } catch (err) {
      setPhase("error");
      setMessage(err instanceof Error ? err.message : "失敗しました");
    }
  }

  return (
    <div>
      <div
        onDragOver={(e) => e.preventDefault()}
        onDragEnter={(e) => {
          e.preventDefault();
          depth.current += 1;
          setDragging(true);
        }}
        onDragLeave={() => {
          depth.current -= 1;
          if (depth.current === 0) setDragging(false);
        }}
        onDrop={(e) => {
          e.preventDefault();
          depth.current = 0;
          setDragging(false);
          const file = e.dataTransfer.files?.[0];
          if (file) start(file);
        }}
        style={{
          border: dragging ? "2px solid #2563eb" : "2px dashed #94a3b8",
          borderRadius: 12,
          padding: 32,
          textAlign: "center",
        }}
      >
        <p>ここにファイルをドロップ、または</p>
        <input
          type="file"
          accept="image/png,image/jpeg,application/pdf"
          onChange={(e) => {
            const file = e.target.files?.[0];
            if (file) start(file);
          }}
        />
      </div>

      {phase === "uploading" && (
        <div style={{ marginTop: 16 }}>
          <progress value={percent} max={100} />
          <span> {percent}%</span>
          <button onClick={() => controller.current?.abort()}>キャンセル</button>
        </div>
      )}

      {message && (
        <p role="status" style={{ color: phase === "error" ? "#dc2626" : "#16a34a" }}>
          {message}
        </p>
      )}
    </div>
  );
}

このコンポーネントの肝は3つです。uploadWithProgressが本物の進捗を握り、uploadWithRetryが失敗を吸収し、AbortControllerがキャンセルを担う。役割を関数で割っておくと、後からClaude Codeに「リトライ回数を設定で変えられるようにして」と頼んでも壊れにくくなります。

検証は3層、でも”最終防衛線”はサーバーだけ

クライアントのvalidate()は気持ちいいです。ファイルを選んだ瞬間に「5MBまでです」と出る。UXとしては正しい。

でも勘違いしてはいけないのが、<input accept="image/png">もクライアントのサイズチェックも、攻撃を止める壁ではないということ。acceptは選択ダイアログの絞り込み補助にすぎず、APIに直接POSTされたら素通りします。file.type(MIMEタイプ)だってブラウザが付けた自己申告なので、偽装できます。

なので検証は層で考えます。

  1. クライアント(UI層): 選んだ瞬間にサイズ・拡張子・MIMEを見て、即フィードバック。速くて親切。でも信用しない。
  2. サーバー(必須層): 同じ項目を再検証。さらに保存名をUUIDに付け替えて、元ファイル名は使わない。
  3. 中身(厳密層): 本気でやるなら、画像のマジックナンバー(先頭バイト)やデコード確認、PDFのヘッダ確認、ウイルススキャンまで。

.jpgという拡張子を付けただけの実行ファイルを弾くには、3層目が要ります。ここまでの検証・保存・S3移行の具体コードはClaude Code × AWS S3入門で詳しく書いたので、サーバー側を固めたい人はそちらへ。クライアント側の話に戻すと、検証で弾いたときの見せ方が大事で、エラーは入力欄の近くにrole="status"role="alert"で出すと、スクリーンリーダーにも届きます。アクセシブルな通知の作法はトースト通知の実装にまとめてあります。

大容量はサーバーを通すな:presigned URL直アップロード

5MBの画像なら、サーバーがFormDataを受けてストレージへ中継する形で何の問題もありません。むしろ検証も監査も楽です。

崩れるのは、動画や数百MBのデータを扱い始めたときです。アプリサーバーが全部のバイトを一度メモリに抱えるので、同時に何人かが大容量を投げると、サーバーがあっさり詰まります。タイムアウトやメモリ不足で落ちる。

ここで効くのが**presigned URL(署名付きURL)**です。仕組みはこうです。

  1. ブラウザはまずアプリサーバーに「このファイルを上げたい」と申請する(名前・サイズ・MIMEだけ送る)。
  2. サーバーは検証して、S3など”そのファイルを置く権限”だけを埋め込んだ一時URLを発行して返す。
  3. ブラウザはそのURLへ、ファイル本体を直接PUTする。アプリサーバーは本体を一切受け取らない。

サーバーがバイトの通り道から外れるので、何百MBでも詰まらない。フロント側は送信先が/api/uploadからこの一時URLに変わるだけで、進捗バーのコード(XHRのupload.onprogress)はそっくり再利用できます。発行APIのイメージはこんな感じです。

// app/api/upload-url/route.ts (発行だけ。本体は受け取らない)
import { S3Client } from "@aws-sdk/client-s3";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";

const EXT = new Map([
  ["image/png", ".png"],
  ["image/jpeg", ".jpg"],
  ["application/pdf", ".pdf"],
]);
const s3 = new S3Client({ region: process.env.AWS_REGION });

export async function POST(req: NextRequest) {
  const { contentType, size } = await req.json();

  // 本体は来ないので、申告値をここで検証する
  const ext = EXT.get(contentType);
  if (!ext) return NextResponse.json({ error: "許可外のMIMEです" }, { status: 400 });
  if (typeof size !== "number" || size <= 0 || size > 200 * 1024 * 1024) {
    return NextResponse.json({ error: "サイズが範囲外です" }, { status: 400 });
  }

  const key = `uploads/${new Date().toISOString().slice(0, 10)}/${randomUUID()}${ext}`;
  const url = await getSignedUrl(
    s3,
    new PutObjectCommand({
      Bucket: process.env.UPLOAD_BUCKET,
      Key: key,
      ContentType: contentType, // PUT時のContent-Typeを固定しておく
    }),
    { expiresIn: 60 }, // 60秒で失効。長くしない
  );

  return NextResponse.json({ url, key });
}

ブラウザ側は、さっきのuploadWithProgressxhr.open("POST", "/api/upload")xhr.open("PUT", presignedUrl)に変え、FormDataではなくFileをそのままxhr.send(file)するだけです。expiresInは短く(数十秒〜数分)。長い有効期限のURLが漏れると、誰でもそこへ書き込めてしまいます。

なお、これは早すぎる最適化に注意です。小〜中容量なら、サーバー中継のほうが検証も監査ログも素直に書けます。presigned URLは「大きい・多い・サーバーを通したくない」が揃ってから入れるのがちょうどいい塩梅です。

よくある質問

Q. fetchでも進捗バーは無理ですか?絶対にXHRですか? アップロード進捗に関しては、現状XHRが現実解です。fetchのリクエスト側にReadableStreamを渡して自前で進捗を測る実験的な手法もありますが、対応ブラウザやduplex指定の制約があり、本番で安定させるのは骨が折れます。進捗が要るならXHR、要らないならfetch、で割り切るのが一番ラクです。

Q. ドロップしたらブラウザでファイルが開いてしまいます。 dragover(とdrop)でevent.preventDefault()を呼んでいません。これがブラウザの「ファイルを開く」デフォルト動作を止める唯一のスイッチです。両方のイベントで呼んでください。

Q. 複数ファイルを同時に上げるときの進捗は? ファイルごとにXHRと進捗stateを持たせ、全体は「各%の平均」か「合計バイトのloaded/total」で出します。バイト合計のほうが、大小混在のときに体感とズレません。同時接続を増やしすぎるとブラウザの上限に当たるので、3〜4本ずつ流すと安定します。

Q. リトライは何回が適切ですか? 2〜3回で十分です。それ以上は、相手が落ちている可能性が高いので、回数を増やすよりエラーを正直に見せて手動再試行ボタンを出すほうが親切です。ユーザーが押したキャンセル(AbortError)はリトライ対象から外すのを忘れずに。

Q. presigned URLは小さいファイルでも使うべき? 基本は不要です。サーバー中継のほうが検証・監査が簡単で、バグも減ります。動画や大量アップロードでサーバーが詰まり始めたら、そのとき導入を検討すれば間に合います。

実際に試した結果

冒頭の”嘘つきバー”を直すとき、僕はまずfetchのまま進捗を出す方法を粘ってみました。ReadableStreamを使う手も試したのですが、ブラウザ差とエラー処理で消耗して、結局XHRに戻しました。「進捗が要る画面だけXHR」と割り切った瞬間に、コードがすっと短くなったのを覚えています。

ドラッグ&ドロップは、preventDefault()を入れるまでが本当に長かった。逆に言えば、あの一行とdragleaveのカウンタさえ押さえれば、見た目の派手さの割に実装は軽いです。

そしてリトライ。uploadWithProgressを関数として独立させておいたおかげで、後から指数バックオフを巻くのが数行で済みました。最初から全部入りで書くより、進捗・リトライ・キャンセルを別々の関数に切っておく。これが一番効いた設計判断でした。Claude Codeに頼むときも、「この3つを別関数に分けて」と最初に指定すると、レビューしやすいコードが返ってきます。

実装の全体像(検証・S3保存・署名付きURL・権限レビュー)を自分のリポジトリに合わせて整えたいときは、Claude Code研修・相談で扱えます。教材から試したい方は教材一覧もどうぞ。

#ファイルアップロード #進捗バー #ドラッグ&ドロップ #presigned URL #XMLHttpRequest #React
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。