EmpireUI
Get Pro
← Blog9 min read#file upload#react#drag drop

File Upload in React: Drag & Drop, Progress Bar and Validation

Build a complete React file upload component with drag & drop, real progress bar, file type validation, and size limits — no third-party library required.

Developer coding a React file upload component on a laptop screen

Why Rolling Your Own File Upload Still Makes Sense

Libraries like react-dropzone are fine. But they pull in anywhere from 8 kb to 40 kb of JavaScript for something the browser already knows how to do. If you need drag-and-drop, a progress bar, file type gating, and size limits — that's maybe 120 lines of your own code. Worth knowing how it works before you reach for a package.

The File and FileList APIs have been stable since 2012. The DataTransfer interface has been broadly available across Chromium, Firefox, and Safari since before React was even a thing. In 2026 there's genuinely no browser compatibility excuse for avoiding the native file upload path.

Honestly, the bigger reason to go custom is control. Third-party upload libraries tend to bake in opinionated UI that you'll spend more time overriding than building from scratch. You also get to wire validation logic exactly where it belongs — before any XHR fires — instead of hoping the library exposes the right hooks.

One more thing — if you're already running a component system like Empire UI, building a coherent upload widget that matches your existing design tokens is faster than wrestling a third-party component into compliance.

Setting Up the Drag & Drop Zone

Drag and drop in the browser is powered by four events: dragenter, dragover, dragleave, and drop. You need all four. Miss dragover and the browser opens the file directly instead of handing it to your handler.

Here's the core of the drop zone hook. Keep it in its own file — you'll reuse it.

// useDropZone.ts
import { useState, useRef, DragEvent, useCallback } from 'react';

export function useDropZone(onFiles: (files: File[]) => void) {
  const [isDragging, setIsDragging] = useState(false);
  const dragCounter = useRef(0);

  const handleDragEnter = useCallback((e: DragEvent) => {
    e.preventDefault();
    dragCounter.current++;
    if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
      setIsDragging(true);
    }
  }, []);

  const handleDragLeave = useCallback((e: DragEvent) => {
    e.preventDefault();
    dragCounter.current--;
    if (dragCounter.current === 0) setIsDragging(false);
  }, []);

  const handleDragOver = useCallback((e: DragEvent) => {
    e.preventDefault(); // required to allow drop
  }, []);

  const handleDrop = useCallback(
    (e: DragEvent) => {
      e.preventDefault();
      setIsDragging(false);
      dragCounter.current = 0;
      const files = Array.from(e.dataTransfer.files);
      if (files.length > 0) onFiles(files);
    },
    [onFiles]
  );

  return { isDragging, handleDragEnter, handleDragLeave, handleDragOver, handleDrop };
}

The dragCounter ref is the part most tutorials skip. When you drag over a child element inside the drop zone, dragleave fires on the parent — which falsely kills your highlight state. Counting enter/leave events solves it cleanly without any coordinate math.

Worth noting: e.dataTransfer.files only has content on the drop event, not during dragover. During drag you can read e.dataTransfer.items to check MIME types early, but the actual File objects aren't available until the user actually releases. Keep that in mind if you want to show per-file validation feedback during the drag.

File Validation: Type, Size, and Count

Never trust the file extension. The browser gives you file.type which reads the MIME type, and file.name for the extension. Use both. A PNG renamed to .pdf will still report image/png in file.type — catch it.

// validateFiles.ts
export interface ValidationRule {
  accept: string[];   // MIME types, e.g. ['image/png', 'image/jpeg', 'application/pdf']
  maxSizeBytes: number; // e.g. 5 * 1024 * 1024 for 5 MB
  maxFiles?: number;
}

export interface FileError {
  file: File;
  reason: 'type' | 'size' | 'count';
}

export function validateFiles(
  files: File[],
  rules: ValidationRule
): { valid: File[]; errors: FileError[] } {
  const valid: File[] = [];
  const errors: FileError[] = [];

  if (rules.maxFiles && files.length > rules.maxFiles) {
    // reject all past the limit
    files.slice(rules.maxFiles).forEach(f =>
      errors.push({ file: f, reason: 'count' })
    );
    files = files.slice(0, rules.maxFiles);
  }

  for (const file of files) {
    if (!rules.accept.includes(file.type)) {
      errors.push({ file, reason: 'type' });
    } else if (file.size > rules.maxSizeBytes) {
      errors.push({ file, reason: 'size' });
    } else {
      valid.push(file);
    }
  }

  return { valid, errors };
}

In practice, showing the user *why* their file was rejected matters more than the rejection itself. Map the reason field to human-readable text: 'type' → "File type not supported", 'size'File exceeds 5 MB, and so on. Inline errors next to each file name beat a generic toast every time.

Quick aside: if you're building an image-heavy upload flow and you want the surrounding UI to match your design system, the glassmorphism generator is a fast way to prototype a modal or card container that doesn't look like it's from 2018.

Uploading with a Real Progress Bar (XMLHttpRequest vs fetch)

fetch doesn't expose upload progress. That's the short version. As of mid-2026 the Streams-based approach still isn't reliably cross-browser for request bodies. So for real per-file progress bars, you want XMLHttpRequest.

// uploadFile.ts
export function uploadFile(
  file: File,
  url: string,
  onProgress: (pct: number) => void
): Promise<void> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    const formData = new FormData();
    formData.append('file', file);

    xhr.upload.addEventListener('progress', (e) => {
      if (e.lengthComputable) {
        onProgress(Math.round((e.loaded / e.total) * 100));
      }
    });

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

    xhr.addEventListener('error', () => reject(new Error('Network error')));
    xhr.addEventListener('abort', () => reject(new Error('Upload aborted')));

    xhr.open('POST', url);
    xhr.send(formData);
  });
}

Pair this with per-file state — an array of { file, progress, status } objects — and you can drive a list of progress bars that update independently. Don't collapse them into a single aggregated percentage; users uploading 10 files want to see which one is lagging.

The progress bar itself needs exactly 2px of love: transition: width 120ms linear so it doesn't jump. That 120 ms matches the XHR progress event interval in most browsers. Longer and it feels laggy. Shorter and you get flicker on fast connections.

Look, there's a common mistake here — people pass file.size as a static label and call it done. Add formatted sizes ((1.4 MB)) next to each filename. It's 3 lines of code and it immediately makes your uploader feel professional.

Putting It All Together: The FileUploader Component

Here's the assembled component. It uses the hook and utilities from the previous sections, handles multiple files, shows per-file progress, and renders inline validation errors.

// FileUploader.tsx
import { useState, useCallback, ChangeEvent } from 'react';
import { useDropZone } from './useDropZone';
import { validateFiles, FileError, ValidationRule } from './validateFiles';
import { uploadFile } from './uploadFile';

interface FileState {
  file: File;
  progress: number;
  status: 'pending' | 'uploading' | 'done' | 'error';
  error?: string;
}

const RULES: ValidationRule = {
  accept: ['image/png', 'image/jpeg', 'application/pdf'],
  maxSizeBytes: 5 * 1024 * 1024, // 5 MB
  maxFiles: 6,
};

export function FileUploader({ uploadUrl }: { uploadUrl: string }) {
  const [fileStates, setFileStates] = useState<FileState[]>([]);

  const updateFile = (index: number, patch: Partial<FileState>) =>
    setFileStates(prev =>
      prev.map((f, i) => (i === index ? { ...f, ...patch } : f))
    );

  const processFiles = useCallback(
    async (incoming: File[]) => {
      const { valid, errors } = validateFiles(incoming, RULES);

      const errorStates: FileState[] = errors.map(({ file, reason }) => ({
        file,
        progress: 0,
        status: 'error',
        error:
          reason === 'type'
            ? 'File type not supported'
            : reason === 'size'
            ? 'Exceeds 5 MB limit'
            : 'Too many files',
      }));

      const validStates: FileState[] = valid.map(file => ({
        file,
        progress: 0,
        status: 'pending',
      }));

      setFileStates(prev => [...prev, ...errorStates, ...validStates]);
      const startIndex = fileStates.length + errorStates.length;

      await Promise.allSettled(
        valid.map((file, i) => {
          const idx = startIndex + i;
          updateFile(idx, { status: 'uploading' });
          return uploadFile(file, uploadUrl, pct => updateFile(idx, { progress: pct }))
            .then(() => updateFile(idx, { status: 'done', progress: 100 }))
            .catch(() => updateFile(idx, { status: 'error', error: 'Upload failed' }));
        })
      );
    },
    [fileStates.length, uploadUrl]
  );

  const { isDragging, handleDragEnter, handleDragLeave, handleDragOver, handleDrop } =
    useDropZone(processFiles);

  const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) processFiles(Array.from(e.target.files));
  };

  return (
    <div className="space-y-4">
      <div
        onDragEnter={handleDragEnter}
        onDragLeave={handleDragLeave}
        onDragOver={handleDragOver}
        onDrop={handleDrop}
        className={[
          'border-2 border-dashed rounded-xl p-10 text-center transition-colors duration-150',
          isDragging
            ? 'border-violet-500 bg-violet-50 dark:bg-violet-950/30'
            : 'border-gray-300 dark:border-gray-700',
        ].join(' ')}
      >
        <p className="text-sm text-gray-500">
          Drag files here or{' '}
          <label className="text-violet-600 underline cursor-pointer">
            browse
            <input
              type="file"
              multiple
              accept="image/png,image/jpeg,application/pdf"
              className="sr-only"
              onChange={handleInputChange}
            />
          </label>
        </p>
        <p className="mt-1 text-xs text-gray-400">PNG, JPG, PDF up to 5 MB · max 6 files</p>
      </div>

      {fileStates.length > 0 && (
        <ul className="space-y-2">
          {fileStates.map((fs, i) => (
            <li key={i} className="rounded-lg border p-3 text-sm">
              <div className="flex justify-between">
                <span className="font-medium truncate max-w-[70%]">{fs.file.name}</span>
                <span className="text-gray-400 text-xs">
                  {(fs.file.size / 1024 / 1024).toFixed(1)} MB
                </span>
              </div>
              {fs.status === 'uploading' && (
                <div className="mt-2 h-1.5 rounded-full bg-gray-100 overflow-hidden">
                  <div
                    className="h-full bg-violet-500 rounded-full transition-[width] duration-[120ms] linear"
                    style={{ width: `${fs.progress}%` }}
                  />
                </div>
              )}
              {fs.status === 'error' && (
                <p className="mt-1 text-red-500 text-xs">{fs.error}</p>
              )}
              {fs.status === 'done' && (
                <p className="mt-1 text-green-600 text-xs">Uploaded</p>
              )}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

The sr-only input trick is important. Hiding the real <input type="file"> with a visually-hidden class but keeping it accessible means screen readers can still tab to the "browse" label and trigger the file picker. Don't just use display: none — that breaks keyboard navigation.

That said, this component doesn't reset fileStates between sessions. You'll probably want a "clear all" button, or reset on form submit. Wire that to setFileStates([]) and you're done.

Handling Cancellation and Retry

Real upload UIs need cancel and retry. Cancel is straightforward — store the XMLHttpRequest instance in a ref and call xhr.abort(). Retry just re-runs uploadFile for that specific file index. The tricky part is state management when you're running parallel uploads.

// Extend FileState and uploadFile to support cancellation
interface FileState {
  file: File;
  progress: number;
  status: 'pending' | 'uploading' | 'done' | 'error' | 'cancelled';
  error?: string;
  xhr?: XMLHttpRequest; // store the reference
}

// Modified uploadFile returns both promise and xhr
export function uploadFile(
  file: File,
  url: string,
  onProgress: (pct: number) => void
): { promise: Promise<void>; xhr: XMLHttpRequest } {
  const xhr = new XMLHttpRequest();
  const promise = new Promise<void>((resolve, reject) => {
    const formData = new FormData();
    formData.append('file', file);
    xhr.upload.addEventListener('progress', e => {
      if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
    });
    xhr.addEventListener('load', () => {
      xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`${xhr.status}`));
    });
    xhr.addEventListener('error', () => reject(new Error('Network error')));
    xhr.addEventListener('abort', () => reject(new Error('Cancelled')));
    xhr.open('POST', url);
    xhr.send(formData);
  });
  return { promise, xhr };
}

Store the xhr in your file state object so you can call state.xhr?.abort() from a cancel button. On abort, set status: 'cancelled' and clear the xhr reference. For retry, just re-run the upload pipeline on that single file entry — no need to rebuild the whole list.

If you want UI that's already styled, check out what Empire UI ships for modal overlays and progress indicators. Dropping a finished upload list into a well-designed card saves you the design work entirely, especially early in a project.

Accessibility, UX Polish, and What Not to Forget

Keyboard users need to reach the drop zone too. Add tabIndex={0} and handle onKeyDown with Enter or Space to trigger the hidden input's click. Without this, anybody not using a mouse is stuck.

// Add to the drop zone div
tabIndex={0}
role="button"
onKeyDown={(e) => {
  if (e.key === 'Enter' || e.key === ' ') {
    e.preventDefault();
    document.getElementById('file-input')?.click();
  }
}}
aria-label="Upload files — drag and drop or press Enter to browse"

The aria-label carries the whole interaction model for screen readers. Be specific — say what file types are accepted right in the label. Don't make someone discover the constraints from an error message.

One more thing — server-side validation is not optional. Client-side validation is UX, not security. Whatever you check in the browser, re-check on the server. Mime type spoofing is trivial; a 4 KB text file renamed to .png passes browser MIME detection on some paths. If you're building image uploads especially, run your own magic-bytes check server-side.

If your upload UI lives inside a larger form component system and you're chasing a specific visual style, pairing the upload zone with something from the box shadow generator gives you a hover/active depth effect in about 30 seconds — subtle but it makes the drop target feel clickable and distinct from the rest of the form.

FAQ

Can I use fetch instead of XMLHttpRequest for upload progress?

Not reliably in 2026. The Streams API for request bodies doesn't have consistent cross-browser support yet. XHR's upload.progress event is the dependable path for per-file progress tracking.

How do I prevent the browser from opening a dropped file as a page?

Call e.preventDefault() in both dragover and drop handlers. Missing dragover is the most common reason drops open the file in the browser tab instead of firing your callback.

What's the right way to validate file types in React?

Check file.type (MIME) against an allowlist rather than trusting the extension. Also validate on the server — client-side checks are for UX, not security.

How do I support multiple simultaneous file uploads?

Use Promise.allSettled (not Promise.all) so one failed upload doesn't abort the others. Track each file's progress and status in a separate state entry so the UI updates independently per file.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

File Upload Dropzone in React: react-dropzone, Preview, ProgressKanban Board in React: Drag-and-Drop Columns With dnd-kitFile Manager UI Design: Grid/List Toggle, Breadcrumbs, Drag UploadDrag and Drop in React 2026: dnd-kit vs react-beautiful-dnd