Back to Blog

React File Drag-and-Drop Upload: Progress Tracking, Multi-File Queuing, and Validation

Build a production file drag-and-drop upload component in React. Covers drag-over states, multi-file queue management, upload progress with XMLHttpRequest, file type and size validation, presigned S3 URLs, and accessible keyboard file input.

Viprasol Tech Team
May 4, 2027
13 min read

File upload components are deceptively complex. A polished implementation handles drag-over states, multi-file queues with per-file progress, file type and size validation before upload starts, graceful error recovery for individual files, and accessibility for keyboard users who can't drag. Skip any of these and the experience feels unfinished.

This guide builds the complete upload component from scratch โ€” no third-party upload library needed.

Types

// types/upload.ts
export type UploadStatus =
  | "pending"    // Queued, not yet started
  | "uploading"  // In progress
  | "complete"   // Successfully uploaded
  | "error";     // Failed

export interface UploadFile {
  id:       string;
  file:     File;
  status:   UploadStatus;
  progress: number;        // 0โ€“100
  error:    string | null;
  url:      string | null; // Public URL after upload
}

export interface UploadConfig {
  maxFileSizeBytes:  number;
  maxFiles:          number;
  acceptedTypes:     string[];   // MIME types: ["image/jpeg", "image/png", "application/pdf"]
  destination:       string;     // S3 folder prefix
}

useFileUpload Hook

// hooks/use-file-upload.ts
import { useState, useCallback, useRef } from "react";
import { nanoid } from "nanoid";
import type { UploadFile, UploadConfig } from "@/types/upload";

const DEFAULT_CONFIG: UploadConfig = {
  maxFileSizeBytes:  10 * 1024 * 1024, // 10MB
  maxFiles:          10,
  acceptedTypes:     ["image/jpeg", "image/png", "image/webp", "application/pdf"],
  destination:       "uploads",
};

export function useFileUpload(config: Partial<UploadConfig> = {}) {
  const cfg = { ...DEFAULT_CONFIG, ...config };
  const [files, setFiles] = useState<UploadFile[]>([]);
  const abortControllers = useRef<Map<string, AbortController>>(new Map());

  function updateFile(id: string, updates: Partial<UploadFile>) {
    setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, ...updates } : f)));
  }

  function validateFile(file: File): string | null {
    if (!cfg.acceptedTypes.includes(file.type)) {
      const allowed = cfg.acceptedTypes.map((t) => t.split("/")[1].toUpperCase()).join(", ");
      return `File type not allowed. Accepted: ${allowed}`;
    }
    if (file.size > cfg.maxFileSizeBytes) {
      const maxMB = cfg.maxFileSizeBytes / 1024 / 1024;
      return `File too large. Maximum size: ${maxMB}MB`;
    }
    return null;
  }

  async function uploadFile(uploadFile: UploadFile): Promise<void> {
    const controller = new AbortController();
    abortControllers.current.set(uploadFile.id, controller);

    updateFile(uploadFile.id, { status: "uploading", progress: 0 });

    try {
      // 1. Get presigned URL from server
      const metaRes = await fetch("/api/uploads/presign", {
        method:  "POST",
        headers: { "Content-Type": "application/json" },
        body:    JSON.stringify({
          filename:    uploadFile.file.name,
          contentType: uploadFile.file.type,
          size:        uploadFile.file.size,
          destination: cfg.destination,
        }),
        signal: controller.signal,
      });

      if (!metaRes.ok) {
        const { error } = await metaRes.json();
        throw new Error(error ?? "Failed to get upload URL");
      }

      const { presignedUrl, publicUrl } = await metaRes.json();

      // 2. Upload to S3 with progress tracking
      // fetch() doesn't support upload progress โ€” use XMLHttpRequest
      await new Promise<void>((resolve, reject) => {
        const xhr = new XMLHttpRequest();

        xhr.upload.onprogress = (event) => {
          if (event.lengthComputable) {
            const progress = Math.round((event.loaded / event.total) * 100);
            updateFile(uploadFile.id, { progress });
          }
        };

        xhr.onload = () => {
          if (xhr.status >= 200 && xhr.status < 300) {
            resolve();
          } else {
            reject(new Error(`Upload failed: ${xhr.statusText}`));
          }
        };

        xhr.onerror = () => reject(new Error("Network error during upload"));
        xhr.onabort = () => reject(new Error("Upload cancelled"));

        controller.signal.addEventListener("abort", () => xhr.abort());

        xhr.open("PUT", presignedUrl);
        xhr.setRequestHeader("Content-Type", uploadFile.file.type);
        xhr.send(uploadFile.file);
      });

      updateFile(uploadFile.id, { status: "complete", progress: 100, url: publicUrl });

    } catch (err) {
      if ((err as Error).message === "Upload cancelled") {
        setFiles((prev) => prev.filter((f) => f.id !== uploadFile.id));
      } else {
        updateFile(uploadFile.id, {
          status: "error",
          error:  err instanceof Error ? err.message : "Upload failed",
        });
      }
    } finally {
      abortControllers.current.delete(uploadFile.id);
    }
  }

  const addFiles = useCallback(
    (newFiles: FileList | File[]) => {
      const fileArray = Array.from(newFiles);
      const remaining = cfg.maxFiles - files.length;

      if (remaining <= 0) return;

      const toAdd: UploadFile[] = fileArray.slice(0, remaining).map((file) => {
        const validationError = validateFile(file);
        return {
          id:       nanoid(),
          file,
          status:   validationError ? "error" : "pending",
          progress: 0,
          error:    validationError,
          url:      null,
        };
      });

      setFiles((prev) => [...prev, ...toAdd]);

      // Start uploading valid files immediately
      toAdd
        .filter((f) => f.status === "pending")
        .forEach((f) => uploadFile(f));
    },
    [files.length, cfg.maxFiles]
  );

  function removeFile(id: string) {
    // Cancel in-progress upload
    abortControllers.current.get(id)?.abort();
    setFiles((prev) => prev.filter((f) => f.id !== id));
  }

  function retryFile(id: string) {
    const file = files.find((f) => f.id === id);
    if (!file) return;
    updateFile(id, { status: "pending", error: null, progress: 0 });
    uploadFile(file);
  }

  const successfulUrls = files
    .filter((f) => f.status === "complete" && f.url)
    .map((f) => f.url!);

  return {
    files,
    addFiles,
    removeFile,
    retryFile,
    successfulUrls,
    isUploading: files.some((f) => f.status === "uploading"),
    config: cfg,
  };
}

๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Dropzone Component

// components/upload/dropzone.tsx
"use client";

import { useCallback, useState, useRef } from "react";
import { Upload, File as FileIcon, X, RefreshCw, CheckCircle, AlertCircle } from "lucide-react";
import { useFileUpload } from "@/hooks/use-file-upload";
import type { UploadFile, UploadConfig } from "@/types/upload";

interface DropzoneProps {
  config?: Partial<UploadConfig>;
  onUploadComplete?: (urls: string[]) => void;
  label?: string;
}

export function Dropzone({ config, onUploadComplete, label }: DropzoneProps) {
  const { files, addFiles, removeFile, retryFile, successfulUrls, config: cfg } = useFileUpload(config);
  const [isDragging, setIsDragging] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  const handleDragOver = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragging(true);
  }, []);

  const handleDragLeave = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    // Only clear dragging if leaving the dropzone entirely (not entering a child)
    if (!e.currentTarget.contains(e.relatedTarget as Node)) {
      setIsDragging(false);
    }
  }, []);

  const handleDrop = useCallback(
    (e: React.DragEvent) => {
      e.preventDefault();
      setIsDragging(false);
      if (e.dataTransfer.files.length > 0) {
        addFiles(e.dataTransfer.files);
        onUploadComplete?.(successfulUrls);
      }
    },
    [addFiles, successfulUrls, onUploadComplete]
  );

  const acceptString = cfg.acceptedTypes.join(",");
  const maxMB = Math.round(cfg.maxFileSizeBytes / 1024 / 1024);

  return (
    <div className="space-y-3">
      {/* Drop zone */}
      <div
        onDragOver={handleDragOver}
        onDragLeave={handleDragLeave}
        onDrop={handleDrop}
        onClick={() => inputRef.current?.click()}
        role="button"
        tabIndex={0}
        aria-label={label ?? "Upload files"}
        onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") inputRef.current?.click(); }}
        className={`
          relative flex flex-col items-center justify-center gap-3
          p-8 border-2 border-dashed rounded-xl cursor-pointer
          transition-colors duration-150 select-none
          focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2
          ${isDragging
            ? "border-blue-500 bg-blue-50"
            : "border-gray-300 bg-gray-50 hover:border-blue-400 hover:bg-blue-50/50"
          }
        `}
      >
        <input
          ref={inputRef}
          type="file"
          multiple={cfg.maxFiles > 1}
          accept={acceptString}
          className="hidden"
          onChange={(e) => { if (e.target.files) addFiles(e.target.files); }}
          aria-hidden="true"
        />

        <div className={`p-3 rounded-full ${isDragging ? "bg-blue-100" : "bg-gray-100"}`}>
          <Upload className={`w-6 h-6 ${isDragging ? "text-blue-600" : "text-gray-400"}`} />
        </div>

        <div className="text-center">
          <p className="text-sm font-medium text-gray-700">
            {isDragging ? "Drop files here" : "Drag files here or click to browse"}
          </p>
          <p className="text-xs text-gray-400 mt-1">
            {cfg.acceptedTypes.map((t) => t.split("/")[1].toUpperCase()).join(", ")} up to {maxMB}MB
            {cfg.maxFiles > 1 ? ` ยท Max ${cfg.maxFiles} files` : ""}
          </p>
        </div>
      </div>

      {/* File queue */}
      {files.length > 0 && (
        <ul className="space-y-2" aria-label="Upload queue">
          {files.map((uploadFile) => (
            <FileQueueItem
              key={uploadFile.id}
              uploadFile={uploadFile}
              onRemove={() => removeFile(uploadFile.id)}
              onRetry={() => retryFile(uploadFile.id)}
            />
          ))}
        </ul>
      )}
    </div>
  );
}

File Queue Item

// components/upload/file-queue-item.tsx
import { X, RefreshCw, CheckCircle, AlertCircle, FileText, Image } from "lucide-react";
import type { UploadFile } from "@/types/upload";

function FileIcon({ mimeType }: { mimeType: string }) {
  if (mimeType.startsWith("image/")) return <Image className="w-4 h-4 text-blue-500" />;
  return <FileText className="w-4 h-4 text-gray-400" />;
}

function formatBytes(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

interface FileQueueItemProps {
  uploadFile: UploadFile;
  onRemove: () => void;
  onRetry: () => void;
}

export function FileQueueItem({ uploadFile, onRemove, onRetry }: FileQueueItemProps) {
  const { file, status, progress, error } = uploadFile;

  return (
    <li className="flex items-center gap-3 p-3 bg-white border border-gray-200 rounded-lg">
      <FileIcon mimeType={file.type} />

      <div className="flex-1 min-w-0">
        <p className="text-sm font-medium text-gray-900 truncate">{file.name}</p>
        <p className="text-xs text-gray-400">{formatBytes(file.size)}</p>

        {/* Progress bar */}
        {status === "uploading" && (
          <div className="mt-1.5">
            <div className="w-full bg-gray-100 rounded-full h-1">
              <div
                className="h-1 bg-blue-500 rounded-full transition-all duration-200"
                style={{ width: `${progress}%` }}
              />
            </div>
            <p className="text-xs text-gray-400 mt-0.5">{progress}%</p>
          </div>
        )}

        {/* Error message */}
        {status === "error" && error && (
          <p className="text-xs text-red-600 mt-0.5">{error}</p>
        )}
      </div>

      {/* Status icon */}
      <div className="flex-shrink-0">
        {status === "complete" && (
          <CheckCircle className="w-5 h-5 text-green-500" aria-label="Upload complete" />
        )}
        {status === "error" && (
          <AlertCircle className="w-5 h-5 text-red-500" aria-label="Upload failed" />
        )}
      </div>

      {/* Actions */}
      <div className="flex items-center gap-1">
        {status === "error" && (
          <button
            onClick={onRetry}
            className="p-1 text-gray-400 hover:text-blue-600 rounded"
            aria-label="Retry upload"
          >
            <RefreshCw className="w-4 h-4" />
          </button>
        )}
        {status !== "complete" && (
          <button
            onClick={onRemove}
            className="p-1 text-gray-400 hover:text-red-600 rounded"
            aria-label="Remove file"
          >
            <X className="w-4 h-4" />
          </button>
        )}
      </div>
    </li>
  );
}

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

API: Presigned URL Endpoint

// app/api/uploads/presign/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { nanoid } from "nanoid";
import { z } from "zod";

const s3 = new S3Client({ region: process.env.AWS_REGION! });

const ALLOWED_TYPES = [
  "image/jpeg", "image/png", "image/webp", "image/gif",
  "application/pdf", "text/csv",
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
];
const MAX_SIZE = 10 * 1024 * 1024; // 10MB

const PresignSchema = z.object({
  filename:    z.string().max(255),
  contentType: z.string(),
  size:        z.number().int().positive().max(MAX_SIZE),
  destination: z.string().regex(/^[a-z0-9-/]+$/).default("uploads"),
});

export async function POST(req: NextRequest) {
  const session = await auth();
  if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const body = await req.json();
  const parsed = PresignSchema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
  }

  const { filename, contentType, size, destination } = parsed.data;

  if (!ALLOWED_TYPES.includes(contentType)) {
    return NextResponse.json({ error: "File type not allowed" }, { status: 400 });
  }

  // Sanitize filename: remove path traversal, keep extension
  const ext = filename.split(".").pop()?.toLowerCase() ?? "";
  const safeFilename = `${nanoid()}.${ext}`;
  const key = `${destination}/${session.user.workspaceId}/${safeFilename}`;

  const presignedUrl = await getSignedUrl(
    s3,
    new PutObjectCommand({
      Bucket:        process.env.S3_BUCKET_NAME!,
      Key:           key,
      ContentType:   contentType,
      ContentLength: size,
    }),
    { expiresIn: 300 } // 5 minutes
  );

  const publicUrl = `${process.env.NEXT_PUBLIC_CDN_URL}/${key}`;

  return NextResponse.json({ presignedUrl, publicUrl, key });
}

Cost and Timeline Estimates

ScopeTeamTimelineCost Range
Basic single-file upload1 dev0.5โ€“1 day$200โ€“400
Full dropzone (multi-file, progress, validation)1 dev2โ€“3 days$600โ€“1,200
+ Image preview + crop + resize1 dev3โ€“5 days$1,200โ€“2,500

See Also


Working With Viprasol

File upload UX makes or breaks products that handle documents, images, or data files. Our team builds upload components with real progress tracking via XMLHttpRequest (since fetch doesn't support upload progress), per-file cancellation, retry on failure, keyboard accessibility, and presigned S3 URLs so file bytes never pass through your server.

What we deliver:

  • useFileUpload hook with XHR-based progress, per-file abort controllers, and retry
  • Dropzone component with drag-over state, keyboard support, and ARIA labels
  • FileQueueItem with progress bar, error message, retry and cancel buttons
  • Presigned URL API with filename sanitization, type allowlist, and size limit
  • Multi-file support with configurable per-upload and total limits

Talk to our team about your file upload architecture โ†’

Or explore our web development services.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.