DEV3 Technologies home

Drag-and-drop file hook

A small React hook that turns any element into a file drop zone with an accept filter and a stable dragging flag.

React / Next.jsHooksreactdrag-dropfileshook

Live preview

Code

import { useCallback, useRef, useState, type DragEvent, type ChangeEvent } from 'react';

type Options = { accept?: string[]; multiple?: boolean };

// Matches "image/*", "application/pdf" or ".png" against a file.
function matches(file: File, patterns: string[]): boolean {
  if (patterns.length === 0) return true;
  return patterns.some((p) => {
    if (p.startsWith('.')) return file.name.toLowerCase().endsWith(p.toLowerCase());
    if (p.endsWith('/*')) return file.type.startsWith(p.slice(0, -1));
    return file.type === p;
  });
}

export function useFileDrop(onFiles: (files: File[]) => void, { accept = [], multiple = true }: Options = {}) {
  const [dragging, setDragging] = useState(false);
  const depth = useRef(0); // enter/leave pairs fire for every child element
  const input = useRef<HTMLInputElement | null>(null);

  const deliver = useCallback(
    (list: FileList | null) => {
      const files = Array.from(list ?? []).filter((f) => matches(f, accept));
      if (files.length) onFiles(multiple ? files : files.slice(0, 1));
    },
    [accept, multiple, onFiles],
  );

  const bind = {
    onDragEnter: (e: DragEvent) => {
      e.preventDefault();
      depth.current += 1;
      setDragging(true);
    },
    onDragOver: (e: DragEvent) => e.preventDefault(), // required to allow a drop
    onDragLeave: () => {
      depth.current = Math.max(0, depth.current - 1);
      if (depth.current === 0) setDragging(false);
    },
    onDrop: (e: DragEvent) => {
      e.preventDefault();
      depth.current = 0;
      setDragging(false);
      deliver(e.dataTransfer.files);
    },
  };

  const inputProps = {
    ref: input,
    type: 'file' as const,
    accept: accept.join(','),
    multiple,
    onChange: (e: ChangeEvent<HTMLInputElement>) => {
      deliver(e.target.files);
      e.target.value = ''; // allow picking the same file twice
    },
  };

  return { bind, dragging, inputProps, openPicker: () => input.current?.click() };
}

How it works

What it solves

Native drag-and-drop events are noisy. Moving the pointer across child elements fires dragenter and dragleave repeatedly, which makes a naive "is dragging" flag flicker. This hook gives you a reliable flag, an accept filter and a keyboard-friendly click fallback in a few lines.

How it works

The hook keeps a counter in a ref. Each dragenter increments it and each dragleave decrements it, so the dragging state only turns off when the pointer has really left the zone and all of its children. dragover calls preventDefault, which is required for the browser to allow a drop at all. On drop the files are read from dataTransfer, filtered with the accept patterns (supporting image/* wildcards and exact types or extensions) and handed to your callback.

It returns a props object to spread on the zone plus an openPicker function that clicks a hidden input, so the same callback handles both dropped and picked files.

Adapting it

  • Pass multiple: false to take only the first accepted file.
  • Add a maxBytes option and call an onReject callback for oversized files.
  • Spread bind onto the document body for a full-page drop target.

Pitfalls

  • Without preventDefault on dragover, the browser opens the dropped file instead of calling you.
  • File types from dataTransfer can be empty for unusual extensions; the extension check covers that.
  • Remember to revoke any object URLs you create from dropped files.

How to use it

  1. Save as hooks/useFileDrop.ts.
  2. Call const { bind, dragging, openPicker, inputProps } = useFileDrop(onFiles, { accept: ['image/*'] }).
  3. Spread {...bind} on a div and render <input {...inputProps} hidden /> once.
  4. Use dragging to style the highlighted state and openPicker() from a button.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets