Type something to search...
Handling File Uploads in React with Drag and Drop

Handling File Uploads in React with Drag and Drop

A plain file input works, but it is not a great experience. Users expect to drag files from their desktop onto the page, see thumbnails before anything is sent, watch a progress bar while large files upload, and cancel an upload they started by mistake. They also expect clear messages when a file is too big or the wrong type, rather than a vague failure after waiting for the upload to finish.

Building that in React involves a few browser APIs that are easy to get slightly wrong: drag events that fire in confusing orders, object URLs that leak memory, and fetch, which does not report upload progress. Accessibility matters too, because a drop zone that only works with a mouse locks out keyboard and screen reader users.

This post builds a complete uploader from scratch: a drop zone with proper drag handling, client-side validation, image previews, per-file progress with cancellation, and accessible status messages. At the end you will see the same drop zone built with react-dropzone, and when that library is the better choice.

How Browser Drag and Drop Works for Files

When a user drags files from their operating system over a page, the browser fires these events on the element under the pointer:

  • dragenter when the drag enters an element.
  • dragover repeatedly while the drag moves over it.
  • dragleave when it leaves.
  • drop when the user releases.

Two rules trip almost everyone up:

  1. You must call preventDefault() in dragover (and it is good practice in dragenter). Otherwise the browser does not consider the element a valid drop target, and drop never fires.
  2. You must call preventDefault() in drop, or the browser opens the file in the tab, navigating away from your app.

The dropped files are available as event.dataTransfer.files, a FileList you can turn into an array with Array.from.

There is also a subtle issue with dragleave. It fires when the pointer moves from the drop zone onto one of its children, even though the user is still inside the zone. If you simply set "dragging" to false on dragleave, the highlight flickers. A depth counter fixes this.

The Drop Zone Component

Here is a reusable drop zone that handles dragging, clicking, and keyboard access. It does not upload anything; it just hands files to a callback.

// src/components/FileDropzone.tsx
import { useRef, useState, type ChangeEvent, type DragEvent } from "react";

interface FileDropzoneProps {
  onFiles: (files: File[]) => void;
  accept?: string;
  multiple?: boolean;
  disabled?: boolean;
}

export function FileDropzone({
  onFiles,
  accept,
  multiple = true,
  disabled = false,
}: FileDropzoneProps) {
  const inputRef = useRef<HTMLInputElement>(null);
  const dragDepth = useRef(0);
  const [isDragging, setIsDragging] = useState(false);

  function hasFiles(e: DragEvent) {
    return Array.from(e.dataTransfer.types).includes("Files");
  }

  function handleDragEnter(e: DragEvent<HTMLDivElement>) {
    if (disabled || !hasFiles(e)) return;
    e.preventDefault();
    dragDepth.current += 1;
    setIsDragging(true);
  }

  function handleDragOver(e: DragEvent<HTMLDivElement>) {
    if (disabled || !hasFiles(e)) return;
    e.preventDefault();
    e.dataTransfer.dropEffect = "copy";
  }

  function handleDragLeave() {
    if (disabled) return;
    dragDepth.current = Math.max(0, dragDepth.current - 1);
    if (dragDepth.current === 0) setIsDragging(false);
  }

  function handleDrop(e: DragEvent<HTMLDivElement>) {
    e.preventDefault();
    dragDepth.current = 0;
    setIsDragging(false);
    if (disabled) return;

    const files = Array.from(e.dataTransfer.files);
    if (files.length > 0) onFiles(multiple ? files : files.slice(0, 1));
  }

  function handleInputChange(e: ChangeEvent<HTMLInputElement>) {
    const files = Array.from(e.target.files ?? []);
    if (files.length > 0) onFiles(files);
    // Clear the input so selecting the same file again still fires onChange.
    e.target.value = "";
  }

  return (
    <div
      className={`dropzone${isDragging ? " dropzone-active" : ""}`}
      onDragEnter={handleDragEnter}
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      onDrop={handleDrop}
    >
      <p>{isDragging ? "Drop files to add them" : "Drag files here, or"}</p>
      <button
        type="button"
        onClick={() => inputRef.current?.click()}
        disabled={disabled}
      >
        Browse files
      </button>
      <input
        ref={inputRef}
        type="file"
        accept={accept}
        multiple={multiple}
        onChange={handleInputChange}
        hidden
      />
    </div>
  );
}

Some details worth explaining:

  • dataTransfer.types includes "Files" only when the user is dragging files, not text or links. Checking it means the zone does not light up when someone drags a selected word across the page.
  • The depth counter increments on each dragenter (including children) and decrements on each dragleave. The zone is only "not dragging" when the count returns to zero.
  • The hidden input is the same native file input, just triggered by a real button. That button is focusable and works with Enter and Space, so keyboard and screen reader users get the full feature without dragging anything. Dragging is an enhancement, not the only path.
  • Resetting e.target.value after reading files lets the user pick the same file twice in a row. Without it, the second selection does not fire onChange. File inputs are always uncontrolled, which is why we reset through the DOM instead of state; controlled vs uncontrolled components explains the distinction.

Basic styles to make the active state visible:

.dropzone {
  border: 2px dashed #94a3b8;
  border-radius: 12px;
  padding: 2rem;
  text-align: center;
  transition: background-color 150ms, border-color 150ms;
}

.dropzone-active {
  border-color: #2563eb;
  background-color: #eff6ff;
}

Stopping Accidental Page Drops

If a user misses the drop zone and releases the file elsewhere on the page, the browser opens the file and your app is gone. A small hook prevents that globally:

// src/hooks/usePreventWindowDrop.ts
import { useEffect } from "react";

export function usePreventWindowDrop() {
  useEffect(() => {
    function prevent(e: DragEvent) {
      if (e.dataTransfer?.types.includes("Files")) e.preventDefault();
    }
    window.addEventListener("dragover", prevent);
    window.addEventListener("drop", prevent);
    return () => {
      window.removeEventListener("dragover", prevent);
      window.removeEventListener("drop", prevent);
    };
  }, []);
}

Here DragEvent is the browser's native type, not React's, because the listener is attached directly to window.

Validating Files Before Upload

Validate as soon as files arrive so users get instant feedback. Check type, size, and count:

// src/lib/validateFiles.ts
export interface FileRules {
  acceptedTypes: string[];
  maxSizeBytes: number;
  maxFiles: number;
}

export interface Rejection {
  file: File;
  reason: string;
}

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

function matchesType(file: File, acceptedTypes: string[]) {
  return acceptedTypes.some((type) =>
    type.endsWith("/*")
      ? file.type.startsWith(type.slice(0, -1))
      : file.type === type,
  );
}

export function validateFiles(
  files: File[],
  rules: FileRules,
  existingCount: number,
): { accepted: File[]; rejected: Rejection[] } {
  const accepted: File[] = [];
  const rejected: Rejection[] = [];

  for (const file of files) {
    if (!matchesType(file, rules.acceptedTypes)) {
      rejected.push({ file, reason: "File type is not supported" });
    } else if (file.size > rules.maxSizeBytes) {
      rejected.push({
        file,
        reason: `File is larger than ${formatBytes(rules.maxSizeBytes)}`,
      });
    } else if (existingCount + accepted.length >= rules.maxFiles) {
      rejected.push({
        file,
        reason: `You can upload up to ${rules.maxFiles} files`,
      });
    } else {
      accepted.push(file);
    }
  }

  return { accepted, rejected };
}

The accept attribute on the input only filters the file picker dialog. Dropped files bypass it entirely, which is why validation must happen in code. Also keep in mind that file.type is guessed by the browser from the file extension. It is useful for user feedback, but it is not a security check. Your server must validate type and size again, ideally by inspecting the file contents. The post on securing React apps covers why client checks never count as protection.

Image Previews With Object URLs

URL.createObjectURL(file) creates a temporary URL pointing to the file in memory, which you can use as an image src without uploading anything. Each URL holds memory until it is revoked with URL.revokeObjectURL, so tie its lifetime to a component with an effect:

// src/components/FilePreview.tsx
import { useEffect, useState } from "react";

export function FilePreview({ file }: { file: File }) {
  const [url, setUrl] = useState<string | null>(null);

  useEffect(() => {
    if (!file.type.startsWith("image/")) return;
    const objectUrl = URL.createObjectURL(file);
    setUrl(objectUrl);
    return () => URL.revokeObjectURL(objectUrl);
  }, [file]);

  if (!url) {
    return <span className="file-icon" aria-hidden="true">📄</span>;
  }

  return <img src={url} alt="" width={64} height={64} className="thumb" />;
}

Creating the URL inside the effect and revoking it in the cleanup is safe in Strict Mode, where effects run twice in development, and it releases memory when the file is removed from the list. The image has an empty alt because the file name is shown as text next to it, so the thumbnail is decorative.

Uploading With Progress and Cancellation

fetch can send files, but it has no event for upload progress in browsers today. For progress bars, use XMLHttpRequest, whose upload object fires progress events. Wrapping it in a promise with an AbortSignal keeps the calling code modern:

// src/lib/uploadFile.ts
interface UploadOptions {
  url: string;
  file: File;
  fieldName?: string;
  onProgress?: (percent: number) => void;
  signal?: AbortSignal;
}

export function uploadFile<T = unknown>({
  url,
  file,
  fieldName = "file",
  onProgress,
  signal,
}: UploadOptions): Promise<T> {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      reject(new DOMException("Upload cancelled", "AbortError"));
      return;
    }

    const xhr = new XMLHttpRequest();
    xhr.open("POST", url);
    xhr.responseType = "json";

    xhr.upload.onprogress = (event) => {
      if (event.lengthComputable && onProgress) {
        onProgress(Math.round((event.loaded / event.total) * 100));
      }
    };

    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(xhr.response as T);
      } else {
        reject(new Error(`Upload failed with status ${xhr.status}`));
      }
    };
    xhr.onerror = () => reject(new Error("Network error during upload"));
    xhr.onabort = () =>
      reject(new DOMException("Upload cancelled", "AbortError"));

    signal?.addEventListener("abort", () => xhr.abort(), { once: true });

    const body = new FormData();
    body.append(fieldName, file);
    xhr.send(body);
  });
}

Do not set a Content-Type header yourself when sending FormData. The browser sets multipart/form-data with the correct boundary automatically, and overriding it breaks parsing on the server.

Putting It Together: The Uploader

The uploader tracks each file's status in a reducer, starts uploads as files are accepted, and stores an AbortController per file so uploads can be cancelled.

// src/components/Uploader.tsx
import { useReducer, useRef, useState } from "react";
import { FileDropzone } from "./FileDropzone";
import { FilePreview } from "./FilePreview";
import { validateFiles, type FileRules } from "../lib/validateFiles";
import { uploadFile } from "../lib/uploadFile";
import { usePreventWindowDrop } from "../hooks/usePreventWindowDrop";

type Status = "uploading" | "done" | "error" | "cancelled";

interface UploadItem {
  id: string;
  file: File;
  progress: number;
  status: Status;
  error?: string;
}

type Action =
  | { type: "add"; items: UploadItem[] }
  | { type: "progress"; id: string; progress: number }
  | { type: "status"; id: string; status: Status; error?: string }
  | { type: "remove"; id: string };

function reducer(state: UploadItem[], action: Action): UploadItem[] {
  switch (action.type) {
    case "add":
      return [...state, ...action.items];
    case "progress":
      return state.map((item) =>
        item.id === action.id ? { ...item, progress: action.progress } : item,
      );
    case "status":
      return state.map((item) =>
        item.id === action.id
          ? { ...item, status: action.status, error: action.error }
          : item,
      );
    case "remove":
      return state.filter((item) => item.id !== action.id);
  }
}

const rules: FileRules = {
  acceptedTypes: ["image/*", "application/pdf"],
  maxSizeBytes: 10 * 1024 * 1024,
  maxFiles: 10,
};

export function Uploader() {
  usePreventWindowDrop();
  const [items, dispatch] = useReducer(reducer, []);
  const [announcement, setAnnouncement] = useState("");
  const [rejections, setRejections] = useState<string[]>([]);
  const controllers = useRef(new Map<string, AbortController>());

  async function start(item: UploadItem) {
    const controller = new AbortController();
    controllers.current.set(item.id, controller);

    try {
      await uploadFile({
        url: "/api/uploads",
        file: item.file,
        signal: controller.signal,
        onProgress: (progress) =>
          dispatch({ type: "progress", id: item.id, progress }),
      });
      dispatch({ type: "status", id: item.id, status: "done" });
      setAnnouncement(`${item.file.name} uploaded`);
    } catch (error) {
      const cancelled =
        error instanceof DOMException && error.name === "AbortError";
      dispatch({
        type: "status",
        id: item.id,
        status: cancelled ? "cancelled" : "error",
        error: cancelled ? undefined : (error as Error).message,
      });
      setAnnouncement(
        cancelled
          ? `${item.file.name} cancelled`
          : `${item.file.name} failed to upload`,
      );
    } finally {
      controllers.current.delete(item.id);
    }
  }

  function handleFiles(files: File[]) {
    const active = items.filter((i) => i.status !== "cancelled").length;
    const { accepted, rejected } = validateFiles(files, rules, active);

    setRejections(rejected.map((r) => `${r.file.name}: ${r.reason}`));

    const newItems: UploadItem[] = accepted.map((file) => ({
      id: crypto.randomUUID(),
      file,
      progress: 0,
      status: "uploading",
    }));

    if (newItems.length > 0) {
      dispatch({ type: "add", items: newItems });
      newItems.forEach(start);
    }

    setAnnouncement(
      `${accepted.length} file${accepted.length === 1 ? "" : "s"} added` +
        (rejected.length ? `, ${rejected.length} rejected` : ""),
    );
  }

  function cancel(id: string) {
    controllers.current.get(id)?.abort();
  }

  function retry(item: UploadItem) {
    dispatch({ type: "status", id: item.id, status: "uploading" });
    dispatch({ type: "progress", id: item.id, progress: 0 });
    start(item);
  }

  return (
    <section aria-labelledby="uploader-heading">
      <h2 id="uploader-heading">Upload documents</h2>
      <p id="uploader-rules">Images or PDF files, up to 10 MB each.</p>

      <FileDropzone onFiles={handleFiles} accept="image/*,application/pdf" />

      {rejections.length > 0 && (
        <ul className="rejections">
          {rejections.map((message) => (
            <li key={message}>{message}</li>
          ))}
        </ul>
      )}

      <ul className="upload-list">
        {items.map((item) => (
          <li key={item.id}>
            <FilePreview file={item.file} />
            <span className="file-name">{item.file.name}</span>

            {item.status === "uploading" && (
              <>
                <progress
                  value={item.progress}
                  max={100}
                  aria-label={`Uploading ${item.file.name}`}
                />
                <button type="button" onClick={() => cancel(item.id)}>
                  Cancel
                </button>
              </>
            )}
            {item.status === "done" && <span>Uploaded</span>}
            {item.status === "error" && (
              <>
                <span className="error">{item.error}</span>
                <button type="button" onClick={() => retry(item)}>
                  Retry
                </button>
              </>
            )}
            {item.status !== "uploading" && (
              <button
                type="button"
                onClick={() => dispatch({ type: "remove", id: item.id })}
              >
                Remove
              </button>
            )}
          </li>
        ))}
      </ul>

      <p role="status" className="visually-hidden">
        {announcement}
      </p>
    </section>
  );
}

How the pieces fit:

  • useReducer keeps all status transitions in one place. Progress updates touch only the matching item. If you are deciding between reducers and plain state for this kind of logic, see useState vs useReducer.
  • AbortControllers live in a ref, not state, because they are not rendered and changing them should not cause a re-render.
  • Cancel calls abort(), which makes xhr.abort() run, which rejects the promise with an AbortError, which marks the item as cancelled. One flow, no special cases.
  • Removing an item unmounts its FilePreview, whose effect cleanup revokes the object URL.
  • The role="status" region announces additions, rejections, completions, and failures to screen reader users without moving focus. It is rendered all the time so that changes to its text are announced.
  • The native progress element is exposed to assistive technology as a progress bar with its value, so you get accessible progress for free.

All uploads start at once here. For many large files, you may want to limit concurrency to two or three at a time with a simple queue so the browser is not saturating the connection with parallel requests.

Uploading Directly to Cloud Storage

Sending files through your own server works for small files, but for large uploads, it is common to upload straight to object storage such as S3, R2, or Google Cloud Storage. The flow is:

  1. The client asks your API for a presigned URL for a specific file name, type, and size.
  2. Your API checks the user's permissions and returns a short-lived URL.
  3. The client uploads the file directly to storage with a PUT request.
  4. The client tells your API the upload finished, and the API records it.

With a presigned PUT URL, the body is the raw file rather than FormData. Adapting the XHR helper is a small change: call xhr.open("PUT", presignedUrl), set xhr.setRequestHeader("Content-Type", file.type) to match what was signed, and call xhr.send(file). Progress events work exactly the same way.

The Same Drop Zone With react-dropzone

If you would rather not maintain the drag event handling yourself, react-dropzone wraps it in a hook:

npm install react-dropzone
// src/components/SimpleDropzone.tsx
import { useDropzone, type FileRejection } from "react-dropzone";

interface SimpleDropzoneProps {
  onAccepted: (files: File[]) => void;
  onRejected?: (rejections: FileRejection[]) => void;
}

export function SimpleDropzone({ onAccepted, onRejected }: SimpleDropzoneProps) {
  const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
    accept: {
      "image/*": [".png", ".jpg", ".jpeg", ".webp"],
      "application/pdf": [".pdf"],
    },
    maxSize: 10 * 1024 * 1024,
    maxFiles: 10,
    noClick: true,
    onDrop: (accepted, rejected) => {
      if (accepted.length) onAccepted(accepted);
      if (rejected.length) onRejected?.(rejected);
    },
  });

  return (
    <div
      {...getRootProps({
        className: `dropzone${isDragActive ? " dropzone-active" : ""}`,
      })}
    >
      <input {...getInputProps()} />
      <p>{isDragActive ? "Drop files to add them" : "Drag files here, or"}</p>
      <button type="button" onClick={open}>
        Browse files
      </button>
    </div>
  );
}

react-dropzone handles the depth counter, type checks that consider both MIME types and extensions, size limits, and rejection reasons with error codes like file-too-large and file-invalid-type. The noClick option plus an explicit button keeps the keyboard behavior identical to our hand-written version. You still need your own upload, progress, and preview logic, so the uploadFile helper and FilePreview component above plug straight in.

Use the library when you want a well-tested drop zone with less code. Write your own when you want zero dependencies or very custom behavior. Note that neither approach is the same as drag-and-drop for reordering elements inside your app, which is a different problem; for that, see building drag-and-drop interfaces with dnd-kit.

Common Mistakes With File Uploads

  • Forgetting preventDefault() in dragover. The drop event never fires, and the browser opens the file instead.
  • Trusting the accept attribute. It only filters the picker. Dropped files are not checked, so validate in code.
  • Trusting client validation. file.type comes from the extension and can be faked. Validate type and size on the server.
  • Setting Content-Type for FormData uploads. Let the browser set the multipart boundary.
  • Leaking object URLs. Every createObjectURL needs a matching revokeObjectURL, ideally in an effect cleanup.
  • Mouse-only drop zones. Always provide a real button that opens the file picker.
  • Not resetting the file input. Selecting the same file twice does nothing unless you clear input.value.
  • Silent failures. Show errors per file, offer Retry, and announce results through a live region.

Frequently Asked Questions (FAQ) About File Uploads in React

The browser only treats an element as a drop target if you call preventDefault() in its dragover handler. Add it to dragover, and to dragenter for good measure, and call it in drop as well to stop the browser from opening the file.

Browsers do not expose upload progress events for fetch requests in a widely supported way. Use XMLHttpRequest and listen to xhr.upload.onprogress, or use a library like axios, whose onUploadProgress option is built on XHR in the browser.

No. Browsers do not allow scripts to set a file input's value, except clearing it to an empty string. File inputs are always uncontrolled. Read files from e.target.files and keep the File objects in state if you need them.

Set the accept attribute to image/* to filter the file picker, and check file.type in code for dropped files. Then validate again on the server, because the browser derives file.type from the file extension and it can be wrong or spoofed.

Yes. Each URL from URL.createObjectURL keeps the file data in memory until the page unloads or you call URL.revokeObjectURL. Create the URL in an effect and revoke it in the cleanup so it is released when the preview unmounts.

For small files and simple apps, posting to your own API is easiest. For large files or high traffic, presigned URLs let the browser upload directly to object storage, which keeps large payloads off your servers. Your API still authorizes each upload and records the result.

Conclusion

A good uploader combines a few small, well-defined pieces. The drop zone calls preventDefault() in the right events, tracks drag depth to avoid flicker, and offers a real button so it works without a mouse. Validation runs as soon as files arrive, previews use object URLs that are revoked in effect cleanups, and uploads use XMLHttpRequest for progress with an AbortController for cancellation.

From here, add a concurrency limit if users upload many large files, switch to presigned URLs when files get big, and make sure your server repeats every validation. If you prefer less code to maintain, swap the hand-written drop zone for react-dropzone and keep the upload, preview, and status logic you built here.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading