Build a Robust React Drag-and-Drop File Upload Component (With Previews, Progress, and Validation)

Build a React drag-and-drop file upload with previews, progress, validation, accessibility, and testing. Includes code for react-dropzone and a custom approach.

ASOasis
8 min read
Build a Robust React Drag-and-Drop File Upload Component (With Previews, Progress, and Validation)

Image used for representation purposes only.

Overview

Drag-and-drop file upload is one of those small UX touches that makes a big difference. In React, you can build a polished dropzone that supports keyboard interaction, validation, previews, progress, and cancellations—without resorting to sprawling code. This article walks through both a library-based approach and a from-scratch implementation, then covers accessibility, performance, and testing.

What a great file uploader should do

  • Accept drag-and-drop and traditional file selection
  • Provide immediate visual feedback (hover/active/reject states)
  • Validate file type, size, and count before uploading
  • Show previews for images/videos and basic metadata for others
  • Display upload progress with the ability to cancel
  • Be accessible via keyboard and screen readers
  • Fail gracefully, with clear error messaging

Using a small, mature library keeps your component lean and robust.

Install

npm i react-dropzone
# or
pnpm add react-dropzone

Minimal component (TypeScript)

import React, { useCallback, useState } from 'react';
import { useDropzone, FileRejection } from 'react-dropzone';
import axios from 'axios';

type UploadItem = {
  file: File;
  preview?: string; // object URL for images/videos
  progress: number; // 0-100
  error?: string;
  cancel?: AbortController;
};

export function ImageDropzone() {
  const [items, setItems] = useState<UploadItem[]>([]);

  const onDrop = useCallback((acceptedFiles: File[], fileRejections: FileRejection[]) => {
    const next = acceptedFiles.map((file) => ({
      file,
      preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined,
      progress: 0
    }));

    // Record client-side rejections
    const rejected = fileRejections.map(r => ({ file: r.file, progress: 0, error: r.errors[0]?.message } as UploadItem));

    setItems((prev) => [...prev, ...next, ...rejected]);

    // Kick off uploads for accepted files
    next.forEach(uploadWithAxios);
  }, []);

  const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({
    accept: { 'image/*': [] },
    maxFiles: 6,
    maxSize: 5 * 1024 * 1024, // 5MB
    onDrop
  });

  function uploadWithAxios(item: UploadItem) {
    const form = new FormData();
    form.append('file', item.file);

    const controller = new AbortController();
    setItems((prev) => prev.map((it) => (it.file === item.file ? { ...it, cancel: controller } : it)));

    axios.post('/api/upload', form, {
      signal: controller.signal,
      headers: { 'Content-Type': 'multipart/form-data' },
      onUploadProgress: (e) => {
        const total = e.total ?? item.file.size;
        const progress = total ? Math.round((e.loaded / total) * 100) : 0;
        setItems((prev) => prev.map((it) => (it.file === item.file ? { ...it, progress } : it)));
      },
    }).catch((err) => {
      setItems((prev) => prev.map((it) => (it.file === item.file ? { ...it, error: err.message } : it)));
    });
  }

  function cancelUpload(file: File) {
    const target = items.find((it) => it.file === file);
    target?.cancel?.abort();
    setItems((prev) => prev.map((it) => (it.file === file ? { ...it, error: 'Canceled' } : it)));
  }

  return (
    <section>
      <div
        {...getRootProps({
          className: `rounded-md border-2 border-dashed p-8 text-center outline-none transition-colors ${
            isDragReject ? 'border-red-500 bg-red-50' : isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
          }`
        })}
        aria-label="Image uploader"
      >
        <input {...getInputProps()} aria-label="Choose files" />
        <p className="mb-2 font-medium">Drag & drop images here, or click to select</p>
        <p className="text-sm text-gray-500">PNG/JPG up to 5MB. Max 6 files.</p>
      </div>

      <ul className="mt-4 grid grid-cols-2 gap-4 md:grid-cols-3">
        {items.map((it) => (
          <li key={it.file.name + it.file.size} className="rounded border p-2">
            {it.preview ? (
              <img src={it.preview} alt={it.file.name} className="mb-2 h-32 w-full rounded object-cover" onLoad={() => URL.revokeObjectURL(it.preview!)} />
            ) : (
              <div className="mb-2 h-32 w-full rounded bg-gray-100 grid place-items-center text-xs text-gray-500">{it.file.type || 'file'}</div>
            )}
            <div className="mb-1 text-xs">{it.file.name}  {(it.file.size / 1024).toFixed(0)} KB</div>
            <div className="h-2 w-full overflow-hidden rounded bg-gray-200">
              <div className="h-full bg-blue-600 transition-all" style={{ width: `${it.progress}%` }} />
            </div>
            <div className="mt-1 flex items-center justify-between text-xs">
              <span className={it.error ? 'text-red-600' : 'text-gray-600'}>{it.error ?? `${it.progress}%`}</span>
              {!it.error && it.progress < 100 && (
                <button className="text-blue-600 hover:underline" onClick={() => cancelUpload(it.file)}>Cancel</button>
              )}
            </div>
          </li>
        ))}
      </ul>
    </section>
  );
}

Why this works well:

  • useDropzone abstracts the dragenter/dragover/drop event quirks
  • Built-in validation (accept, maxSize, maxFiles)
  • You retain control over actual uploads and UI

Building from scratch (no dependency)

If you prefer to own the full behavior, HTML5 drag-and-drop plus a hidden file input gets you there.

import React, { useRef, useState } from 'react';

type RawUploaderProps = {
  onFiles: (files: File[]) => void;
  accept?: string; // e.g., 'image/*,.pdf'
  multiple?: boolean;
};

export function RawUploader({ onFiles, accept, multiple = true }: RawUploaderProps) {
  const [active, setActive] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  function handleFiles(list: FileList | null) {
    if (!list) return;
    onFiles(Array.from(list));
  }

  return (
    <div
      role="button"
      tabIndex={0}
      onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && inputRef.current?.click()}
      onClick={() => inputRef.current?.click()}
      onDragOver={(e) => {
        e.preventDefault();
        e.dataTransfer.dropEffect = 'copy';
        setActive(true);
      }}
      onDragLeave={() => setActive(false)}
      onDrop={(e) => {
        e.preventDefault();
        setActive(false);
        handleFiles(e.dataTransfer.files);
      }}
      className={`rounded-md border-2 border-dashed p-8 text-center ${active ? 'border-blue-500 bg-blue-50' : 'border-gray-300'}`}
      aria-label="File uploader"
    >
      <input
        ref={inputRef}
        type="file"
        className="sr-only"
        onChange={(e) => handleFiles(e.currentTarget.files)}
        accept={accept}
        multiple={multiple}
      />
      <p>Drag & drop files here, or press Enter to browse</p>
    </div>
  );
}

Key points:

  • Always call preventDefault in onDragOver to allow dropping
  • Keep a boolean drag state for styling
  • Pair with a standard file input for keyboard accessibility

Validating files before upload

Client-side validation improves UX but never replaces server checks.

const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const MAX_FILES = 6;

function validate(files: File[]): { ok: File[]; errors: { file: File; message: string }[] } {
  const ok: File[] = [];
  const errors: { file: File; message: string }[] = [];

  files.slice(0, MAX_FILES).forEach((f) => {
    if (!ALLOWED_TYPES.includes(f.type)) errors.push({ file: f, message: 'Unsupported file type' });
    else if (f.size > MAX_SIZE) errors.push({ file: f, message: 'File too large' });
    else ok.push(f);
  });
  if (files.length > MAX_FILES) {
    errors.push({ file: files[MAX_FILES], message: `Too many files (max ${MAX_FILES})` });
  }
  return { ok, errors };
}

Uploading files with progress and cancellation

Fetch does not expose upload progress in a widely supported way yet. Use XMLHttpRequest or a small HTTP client that surfaces progress events.

import axios from 'axios';

export function uploadFile(file: File, url = '/api/upload', onProgress?: (n: number) => void) {
  const form = new FormData();
  form.append('file', file);
  const controller = new AbortController();

  const task = axios.post(url, form, {
    headers: { 'Content-Type': 'multipart/form-data' },
    signal: controller.signal,
    onUploadProgress: (e) => {
      const total = e.total ?? file.size;
      const pct = total ? Math.round((e.loaded / total) * 100) : 0;
      onProgress?.(pct);
    },
  });

  return { task, cancel: () => controller.abort() };
}

Tips:

  • Batch uploads, but limit concurrency (e.g., 3 at a time) to avoid saturating the network
  • Surface server errors (HTTP 4xx/5xx) with human-friendly messages
  • If targeting S3 or GCS, consider pre-signed URLs and direct-to-cloud uploads to offload your server

Previews done right

  • Use URL.createObjectURL for local previews; revoke it after the image loads or on unmount to prevent memory leaks
  • For large images, consider generating client-side thumbnails via Canvas to reduce memory
  • For non-image files, show a generic icon plus file name, size, and type
useEffect(() => () => items.forEach((it) => it.preview && URL.revokeObjectURL(it.preview)), [items]);

Accessibility checklist

  • Make the dropzone focusable (tabIndex=0) and operable with Enter/Space
  • Provide an accessible name (aria-label or associated label)
  • Announce validation errors using aria-live=“polite”
  • Ensure color is not the only indicator (use icons/borders/text)
  • Keep the native input visible to screen readers (do not aria-hidden it)

Example for announcing errors:

<div role="status" aria-live="polite" className="sr-only">{errorMessage}</div>

Styling and state cues

Communicate states clearly:

  • Idle: neutral border and hint text
  • Drag active: brand-colored border/background
  • Drag reject: red border with a short reason (e.g., “Only images up to 5MB”)
  • Uploading: progress bar and cancel action
  • Success: brief checkmark animation or subtle color shift

Performance considerations

  • Avoid storing massive File arrays in deeply nested state; track minimal metadata
  • Revoke object URLs promptly
  • Virtualize long lists of files (react-window) to keep the UI snappy
  • Debounce drag state changes to prevent layout thrashing on noisy dragover events
  • Limit simultaneous uploads; queue the rest

Security notes (client + server)

  • The accept attribute and client validation are advisory; enforce type/size/virus scanning server-side
  • Sanitize file names; never trust user-provided paths or extensions
  • Store to immutable locations and serve via signed URLs when possible
  • For images, consider transcoding and stripping metadata

Advanced features to consider

  • Chunked/resumable uploads (e.g., S3 multipart or a tus-compatible server) for large files
  • Pause/resume with persistent state (IndexedDB) for flaky connections
  • Dragging from external sources (e.g., dropping a URL) and server-side fetching
  • Image transformations (resize/compress) in a Web Worker to keep the main thread responsive

Testing your uploader

Use React Testing Library and user-event to verify:

  • Keyboard activation triggers the file dialog
  • Dragging over toggles the active state
  • Dropping files calls onDrop with the right payload
  • Progress updates render and cancel aborts the request

Simulating a drop:

const data = new DataTransfer();
data.items.add(new File(['hello'], 'greeting.txt', { type: 'text/plain' }));
fireEvent.drop(getByLabelText('File uploader'), { dataTransfer: data });

Server endpoints (example)

A minimal Node/Express route handling multipart form data with limits:

import express from 'express';
import multer from 'multer';

const app = express();
const upload = multer({
  limits: { fileSize: 5 * 1024 * 1024 },
  storage: multer.memoryStorage(), // or diskStorage / S3 stream
});

app.post('/api/upload', upload.single('file'), async (req, res) => {
  if (!req.file) return res.status(400).json({ error: 'No file' });
  // TODO: validate MIME, scan, persist, create URL
  return res.json({ ok: true });
});
  1. Start with react-dropzone for robust DnD and validation
  2. Use Axios/XMLHttpRequest for progress + cancel
  3. Keep state minimal: [{ file, preview, progress, error, cancel }]
  4. Make it accessible and keyboard-friendly
  5. Enforce limits server-side and sanitize aggressively
  6. Add niceties: thumbnails, retry, queued uploads, and clear error messages

Conclusion

A modern React drag-and-drop uploader is a small stack of concerns: interaction, validation, preview, transport, and feedback. With react-dropzone for the UI surface and a progress-aware upload function, you can ship a polished, accessible component that scales from a few images to large, resumable uploads—without overengineering. Start simple, keep state tidy, and add features as your product grows.

Related Posts