Blog
January 11, 2026

Building a drag-and-drop file uploader in React

18 mins read

Building a drag-and-drop file uploader in React

Letting users drag files directly into your app feels more intuitive than clicking "Choose File" and navigating through folders. This tutorial covers two approaches to building a drag-and-drop file uploader in React: using native HTML5 APIs and using the react-dropzone library.

By the end, you will have working TypeScript code for both approaches, along with file validation, visual feedback, and image previews.

Prerequisites

  • React 18+
  • Node.js 18+
  • TypeScript (examples use TypeScript, but JavaScript works with minor adjustments)

What you'll build

A reusable file upload component that:

  • Accepts files via drag-and-drop or click
  • Shows visual feedback during drag
  • Validates file types and sizes
  • Displays image previews
  • Handles errors gracefully

Approach 1: Native HTML5 drag-and-drop

The HTML5 Drag and Drop API works in all modern browsers without any dependencies. This approach gives you full control but requires handling events manually.

Understanding drag events

Four events control drag-and-drop behavior:

EventFires whenRequired action
dragenterDrag enters elementShow visual feedback
dragoverDrag hovers over elementCall e.preventDefault()
dragleaveDrag leaves elementRemove visual feedback
dropFiles are droppedCall e.preventDefault(), process files

Both dragover and drop require e.preventDefault(). Without it, the browser navigates to the dropped file instead of handling it in your app.

Step 1: Basic drop zone

Create a component that handles the four drag events:

import { useState, DragEvent } from 'react';

interface FileUploaderProps {
  onFilesSelected: (files: File[]) => void;
}

export function FileUploader({ onFilesSelected }: FileUploaderProps) {
  const [isDragging, setIsDragging] = useState(false);

  const handleDragEnter = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    setIsDragging(true);
  };

  const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
  };

  const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    setIsDragging(false);
  };

  const handleDrop = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    setIsDragging(false);

    const files = Array.from(e.dataTransfer.files);
    onFilesSelected(files);
  };

  return (
    <div
      onDragEnter={handleDragEnter}
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      onDrop={handleDrop}
      style={{
        border: `2px dashed ${isDragging ? '#2196f3' : '#ccc'}`,
        borderRadius: '8px',
        padding: '40px',
        textAlign: 'center',
        backgroundColor: isDragging ? '#e3f2fd' : '#fafafa',
        transition: 'all 0.2s ease',
        cursor: 'pointer',
      }}
    >
      {isDragging ? (
        <p>Drop files here...</p>
      ) : (
        <p>Drag and drop files here</p>
      )}
    </div>
  );
}

Step 2: Add click-to-select

Users expect to click the drop zone to open a file dialog. Add a hidden input element:

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

interface FileUploaderProps {
  onFilesSelected: (files: File[]) => void;
  accept?: string;
  multiple?: boolean;
}

export function FileUploader({
  onFilesSelected,
  accept,
  multiple = true
}: FileUploaderProps) {
  const [isDragging, setIsDragging] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  const handleDragEnter = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    setIsDragging(true);
  };

  const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
  };

  const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    setIsDragging(false);
  };

  const handleDrop = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    setIsDragging(false);

    const files = Array.from(e.dataTransfer.files);
    onFilesSelected(files);
  };

  const handleClick = () => {
    inputRef.current?.click();
  };

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

  return (
    <div
      onClick={handleClick}
      onDragEnter={handleDragEnter}
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      onDrop={handleDrop}
      style={{
        border: `2px dashed ${isDragging ? '#2196f3' : '#ccc'}`,
        borderRadius: '8px',
        padding: '40px',
        textAlign: 'center',
        backgroundColor: isDragging ? '#e3f2fd' : '#fafafa',
        transition: 'all 0.2s ease',
        cursor: 'pointer',
      }}
    >
      <input
        ref={inputRef}
        type="file"
        onChange={handleInputChange}
        accept={accept}
        multiple={multiple}
        style={{ display: 'none' }}
      />
      {isDragging ? (
        <p>Drop files here...</p>
      ) : (
        <p>Drag and drop files here, or click to select</p>
      )}
    </div>
  );
}

Step 3: Add file validation

Filter files by type and size before passing them to the callback:

interface FileUploaderProps {
  onFilesSelected: (files: File[]) => void;
  onFilesRejected?: (files: { file: File; reason: string }[]) => void;
  accept?: string[];
  maxSize?: number;
  maxFiles?: number;
  multiple?: boolean;
}

export function FileUploader({
  onFilesSelected,
  onFilesRejected,
  accept,
  maxSize = 10 * 1024 * 1024, // 10MB default
  maxFiles = 10,
  multiple = true,
}: FileUploaderProps) {
  // ... drag state and refs ...

  const validateFiles = (files: File[]) => {
    const accepted: File[] = [];
    const rejected: { file: File; reason: string }[] = [];

    files.forEach((file) => {
      // Check file count
      if (accepted.length >= maxFiles) {
        rejected.push({ file, reason: `Maximum ${maxFiles} files allowed` });
        return;
      }

      // Check file type
      if (accept && accept.length > 0) {
        const fileType = file.type;
        const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();

        const isAccepted = accept.some((type) => {
          if (type.startsWith('.')) {
            return fileExtension === type.toLowerCase();
          }
          if (type.endsWith('/*')) {
            return fileType.startsWith(type.replace('/*', '/'));
          }
          return fileType === type;
        });

        if (!isAccepted) {
          rejected.push({ file, reason: `File type not accepted` });
          return;
        }
      }

      // Check file size
      if (file.size > maxSize) {
        rejected.push({
          file,
          reason: `File exceeds ${Math.round(maxSize / 1024 / 1024)}MB limit`
        });
        return;
      }

      accepted.push(file);
    });

    return { accepted, rejected };
  };

  const handleFiles = (files: File[]) => {
    const { accepted, rejected } = validateFiles(files);

    if (accepted.length > 0) {
      onFilesSelected(accepted);
    }

    if (rejected.length > 0 && onFilesRejected) {
      onFilesRejected(rejected);
    }
  };

  const handleDrop = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    setIsDragging(false);

    const files = Array.from(e.dataTransfer.files);
    handleFiles(files);
  };

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

  // ... rest of component
}

Complete native implementation

Here is the full component with all features:

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

interface FileUploaderProps {
  onFilesSelected: (files: File[]) => void;
  onFilesRejected?: (files: { file: File; reason: string }[]) => void;
  accept?: string[];
  maxSize?: number;
  maxFiles?: number;
  multiple?: boolean;
}

export function NativeFileUploader({
  onFilesSelected,
  onFilesRejected,
  accept,
  maxSize = 10 * 1024 * 1024,
  maxFiles = 10,
  multiple = true,
}: FileUploaderProps) {
  const [isDragging, setIsDragging] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  const validateFiles = (files: File[]) => {
    const accepted: File[] = [];
    const rejected: { file: File; reason: string }[] = [];

    files.forEach((file) => {
      if (accepted.length >= maxFiles) {
        rejected.push({ file, reason: `Maximum ${maxFiles} files allowed` });
        return;
      }

      if (accept && accept.length > 0) {
        const fileType = file.type;
        const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();

        const isAccepted = accept.some((type) => {
          if (type.startsWith('.')) {
            return fileExtension === type.toLowerCase();
          }
          if (type.endsWith('/*')) {
            return fileType.startsWith(type.replace('/*', '/'));
          }
          return fileType === type;
        });

        if (!isAccepted) {
          rejected.push({ file, reason: `File type not accepted` });
          return;
        }
      }

      if (file.size > maxSize) {
        rejected.push({
          file,
          reason: `File exceeds ${Math.round(maxSize / 1024 / 1024)}MB limit`
        });
        return;
      }

      accepted.push(file);
    });

    return { accepted, rejected };
  };

  const handleFiles = (files: File[]) => {
    const { accepted, rejected } = validateFiles(files);

    if (accepted.length > 0) {
      onFilesSelected(accepted);
    }

    if (rejected.length > 0 && onFilesRejected) {
      onFilesRejected(rejected);
    }
  };

  const handleDragEnter = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    setIsDragging(true);
  };

  const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
  };

  const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    setIsDragging(false);
  };

  const handleDrop = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    setIsDragging(false);
    const files = Array.from(e.dataTransfer.files);
    handleFiles(files);
  };

  const handleClick = () => {
    inputRef.current?.click();
  };

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

  const acceptString = accept?.join(',');

  return (
    <div
      onClick={handleClick}
      onDragEnter={handleDragEnter}
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      onDrop={handleDrop}
      style={{
        border: `2px dashed ${isDragging ? '#2196f3' : '#ccc'}`,
        borderRadius: '8px',
        padding: '40px',
        textAlign: 'center',
        backgroundColor: isDragging ? '#e3f2fd' : '#fafafa',
        transition: 'all 0.2s ease',
        cursor: 'pointer',
      }}
    >
      <input
        ref={inputRef}
        type="file"
        onChange={handleInputChange}
        accept={acceptString}
        multiple={multiple}
        style={{ display: 'none' }}
      />
      {isDragging ? (
        <p style={{ margin: 0, color: '#2196f3' }}>Drop files here...</p>
      ) : (
        <div>
          <p style={{ margin: '0 0 8px 0' }}>
            Drag and drop files here, or click to select
          </p>
          <p style={{ margin: 0, fontSize: '14px', color: '#666' }}>
            Max {maxFiles} files, {Math.round(maxSize / 1024 / 1024)}MB each
          </p>
        </div>
      )}
    </div>
  );
}

Approach 2: Using react-dropzone

For production applications, react-dropzone handles edge cases you might not anticipate: keyboard navigation, screen reader support, nested dropzones, and browser quirks.

Installation

npm install react-dropzone

The package includes TypeScript declarations. No additional @types package is needed.

Version note: This tutorial uses react-dropzone 14.3.8, which requires React 16.8 or higher.

Basic usage

The useDropzone hook returns everything you need:

import { useCallback } from 'react';
import { useDropzone } from 'react-dropzone';

export function Dropzone() {
  const onDrop = useCallback((acceptedFiles: File[]) => {
    acceptedFiles.forEach((file) => {
      console.log('File:', file.name, file.size);
    });
  }, []);

  const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });

  return (
    <div {...getRootProps()} style={{
      border: '2px dashed #ccc',
      borderRadius: '8px',
      padding: '40px',
      textAlign: 'center',
      cursor: 'pointer',
    }}>
      <input {...getInputProps()} />
      {isDragActive ? (
        <p>Drop the files here...</p>
      ) : (
        <p>Drag files here, or click to select</p>
      )}
    </div>
  );
}

The getRootProps() function returns event handlers for the container. The getInputProps() function returns props for the hidden file input. Spread both onto their respective elements.

Adding file validation

react-dropzone provides built-in validation through configuration options:

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

export function ValidatedDropzone() {
  const onDrop = useCallback((
    acceptedFiles: File[],
    fileRejections: FileRejection[]
  ) => {
    // Handle accepted files
    acceptedFiles.forEach((file) => {
      console.log('Accepted:', file.name);
    });

    // Handle rejected files
    fileRejections.forEach(({ file, errors }) => {
      console.log('Rejected:', file.name);
      errors.forEach((error) => {
        console.log('  Reason:', error.message);
      });
    });
  }, []);

  const {
    getRootProps,
    getInputProps,
    isDragActive,
    isDragAccept,
    isDragReject,
  } = useDropzone({
    onDrop,
    accept: {
      'image/png': ['.png'],
      'image/jpeg': ['.jpg', '.jpeg'],
      'image/gif': ['.gif'],
    },
    maxSize: 5 * 1024 * 1024, // 5MB
    maxFiles: 5,
  });

  const getBorderColor = () => {
    if (isDragAccept) return '#00e676';
    if (isDragReject) return '#ff1744';
    if (isDragActive) return '#2196f3';
    return '#ccc';
  };

  return (
    <div {...getRootProps()} style={{
      border: `2px dashed ${getBorderColor()}`,
      borderRadius: '8px',
      padding: '40px',
      textAlign: 'center',
      cursor: 'pointer',
      transition: 'border-color 0.2s ease',
    }}>
      <input {...getInputProps()} />
      {isDragAccept && <p>Drop to upload...</p>}
      {isDragReject && <p>File type not accepted</p>}
      {!isDragActive && (
        <div>
          <p>Drag images here, or click to select</p>
          <p style={{ fontSize: '14px', color: '#666' }}>
            PNG, JPG, or GIF up to 5MB
          </p>
        </div>
      )}
    </div>
  );
}

Accept format

The accept prop uses MIME types as keys and file extensions as values:

accept: {
  'image/*': [],                    // All image types
  'image/png': ['.png'],            // Only PNG
  'text/csv': ['.csv'],             // CSV files
  'application/pdf': ['.pdf'],      // PDF files
  'application/vnd.ms-excel': ['.xls'],
  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
}

Note: MIME type detection varies between operating systems. CSV files report as text/plain on macOS but application/vnd.ms-excel on Windows. Include multiple MIME types when accepting formats like CSV.

Custom validation

For validation beyond file type and size, use the validator prop:

import { useDropzone, FileError } from 'react-dropzone';

function nameLengthValidator(file: File): FileError | null {
  if (file.name.length > 50) {
    return {
      code: 'name-too-long',
      message: 'File name must be 50 characters or fewer',
    };
  }
  return null;
}

function imageValidator(file: File): FileError | null {
  // Return null to accept, or an error object to reject
  if (file.name.includes(' ')) {
    return {
      code: 'name-has-spaces',
      message: 'File name cannot contain spaces',
    };
  }
  return null;
}

const { getRootProps, getInputProps } = useDropzone({
  validator: nameLengthValidator,
  // Or combine validators:
  // validator: (file) => nameLengthValidator(file) || imageValidator(file),
});

Displaying accepted and rejected files

Show users what happened after they drop files:

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

interface FileWithPreview extends File {
  preview: string;
}

export function DropzoneWithFeedback() {
  const [acceptedFiles, setAcceptedFiles] = useState<FileWithPreview[]>([]);
  const [rejectedFiles, setRejectedFiles] = useState<FileRejection[]>([]);

  const onDrop = useCallback((
    accepted: File[],
    rejected: FileRejection[]
  ) => {
    const filesWithPreview = accepted.map((file) =>
      Object.assign(file, {
        preview: URL.createObjectURL(file),
      })
    );
    setAcceptedFiles(filesWithPreview);
    setRejectedFiles(rejected);
  }, []);

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

  return (
    <div>
      <div {...getRootProps()} style={{
        border: '2px dashed #ccc',
        borderRadius: '8px',
        padding: '40px',
        textAlign: 'center',
        cursor: 'pointer',
      }}>
        <input {...getInputProps()} />
        {isDragActive ? (
          <p>Drop files here...</p>
        ) : (
          <p>Drag images here, or click to select</p>
        )}
      </div>

      {acceptedFiles.length > 0 && (
        <div style={{ marginTop: '20px' }}>
          <h4>Accepted files:</h4>
          <ul>
            {acceptedFiles.map((file) => (
              <li key={file.name}>
                {file.name} - {(file.size / 1024).toFixed(1)} KB
              </li>
            ))}
          </ul>
        </div>
      )}

      {rejectedFiles.length > 0 && (
        <div style={{ marginTop: '20px', color: '#d32f2f' }}>
          <h4>Rejected files:</h4>
          <ul>
            {rejectedFiles.map(({ file, errors }) => (
              <li key={file.name}>
                {file.name}
                <ul>
                  {errors.map((e) => (
                    <li key={e.code}>{e.message}</li>
                  ))}
                </ul>
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}

Adding image previews

For image uploads, show thumbnails of selected files:

import { useState, useCallback, useEffect } from 'react';
import { useDropzone } from 'react-dropzone';

interface FileWithPreview extends File {
  preview: string;
}

export function ImageDropzone() {
  const [files, setFiles] = useState<FileWithPreview[]>([]);

  const onDrop = useCallback((acceptedFiles: File[]) => {
    setFiles(
      acceptedFiles.map((file) =>
        Object.assign(file, {
          preview: URL.createObjectURL(file),
        })
      )
    );
  }, []);

  const { getRootProps, getInputProps } = useDropzone({
    onDrop,
    accept: { 'image/*': [] },
  });

  // Clean up object URLs to prevent memory leaks
  useEffect(() => {
    return () => {
      files.forEach((file) => URL.revokeObjectURL(file.preview));
    };
  }, [files]);

  const removeFile = (name: string) => {
    setFiles((prev) => {
      const fileToRemove = prev.find((f) => f.name === name);
      if (fileToRemove) {
        URL.revokeObjectURL(fileToRemove.preview);
      }
      return prev.filter((f) => f.name !== name);
    });
  };

  return (
    <div>
      <div {...getRootProps()} style={{
        border: '2px dashed #ccc',
        borderRadius: '8px',
        padding: '40px',
        textAlign: 'center',
        cursor: 'pointer',
      }}>
        <input {...getInputProps()} />
        <p>Drag images here, or click to select</p>
      </div>

      {files.length > 0 && (
        <div style={{
          display: 'flex',
          flexWrap: 'wrap',
          gap: '10px',
          marginTop: '20px',
        }}>
          {files.map((file) => (
            <div key={file.name} style={{
              position: 'relative',
              width: '100px',
              height: '100px',
            }}>
              <img
                src={file.preview}
                alt={file.name}
                style={{
                  width: '100%',
                  height: '100%',
                  objectFit: 'cover',
                  borderRadius: '4px',
                }}
                onLoad={() => {
                  // Revoke after image loads to free memory
                  // (Comment this out if you need preview for upload later)
                }}
              />
              <button
                onClick={() => removeFile(file.name)}
                style={{
                  position: 'absolute',
                  top: '-8px',
                  right: '-8px',
                  width: '24px',
                  height: '24px',
                  borderRadius: '50%',
                  border: 'none',
                  backgroundColor: '#d32f2f',
                  color: 'white',
                  cursor: 'pointer',
                  fontSize: '14px',
                }}
              >
                x
              </button>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

The URL.createObjectURL() function creates a temporary URL for displaying the file. Call URL.revokeObjectURL() when the preview is no longer needed to free memory.

Uploading to a server

After collecting files, send them to your backend using FormData:

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

export function UploadDropzone() {
  const [uploading, setUploading] = useState(false);
  const [uploadProgress, setUploadProgress] = useState(0);

  const uploadFiles = async (files: File[]) => {
    setUploading(true);
    setUploadProgress(0);

    const formData = new FormData();
    files.forEach((file) => {
      formData.append('files', file);
    });

    try {
      const response = await fetch('/api/upload', {
        method: 'POST',
        body: formData,
      });

      if (!response.ok) {
        throw new Error('Upload failed');
      }

      const result = await response.json();
      console.log('Upload complete:', result);
    } catch (error) {
      console.error('Upload error:', error);
    } finally {
      setUploading(false);
    }
  };

  const onDrop = useCallback((acceptedFiles: File[]) => {
    uploadFiles(acceptedFiles);
  }, []);

  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    disabled: uploading,
  });

  return (
    <div {...getRootProps()} style={{
      border: '2px dashed #ccc',
      borderRadius: '8px',
      padding: '40px',
      textAlign: 'center',
      cursor: uploading ? 'not-allowed' : 'pointer',
      opacity: uploading ? 0.6 : 1,
    }}>
      <input {...getInputProps()} />
      {uploading ? (
        <p>Uploading...</p>
      ) : isDragActive ? (
        <p>Drop files to upload...</p>
      ) : (
        <p>Drag files here to upload</p>
      )}
    </div>
  );
}

For tracking upload progress with XMLHttpRequest or axios, see your HTTP client's documentation on progress events.

Common pitfalls and solutions

File dialog opens twice

This happens in two scenarios:

Using <label> as the root element: The label's native click behavior conflicts with react-dropzone's click handler.

// Problem
<label {...getRootProps()}>
  <input {...getInputProps()} />
</label>

// Solution: disable click handling
const { getRootProps, getInputProps } = useDropzone({ noClick: true });
<label {...getRootProps()}>
  <input {...getInputProps()} />
</label>

Using open() with a button inside the dropzone: The button click bubbles to the root element.

// Problem
<div {...getRootProps()}>
  <button onClick={open}>Select Files</button>
</div>

// Solution: disable click on root
const { getRootProps, getInputProps, open } = useDropzone({ noClick: true });
<div {...getRootProps()}>
  <input {...getInputProps()} />
  <button type="button" onClick={open}>Select Files</button>
</div>

MIME types differ between operating systems

CSV files report as text/plain on macOS but application/vnd.ms-excel on Windows. Accept multiple MIME types:

accept: {
  'text/csv': ['.csv'],
  'text/plain': ['.csv'],
  'application/vnd.ms-excel': ['.csv'],
}

Browser navigates to dropped file

This happens when e.preventDefault() is missing from dragover or drop handlers. With react-dropzone, this is handled automatically. With native implementation, ensure both events call preventDefault():

const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
  e.preventDefault(); // Required
};

const handleDrop = (e: DragEvent<HTMLDivElement>) => {
  e.preventDefault(); // Required
  // ... handle files
};

Memory leaks with image previews

Object URLs created with URL.createObjectURL() persist until explicitly revoked or the page is closed. Clean them up:

useEffect(() => {
  // Clean up when component unmounts
  return () => {
    files.forEach((file) => URL.revokeObjectURL(file.preview));
  };
}, [files]);

File path not available

For security reasons, browsers do not expose file system paths. The File.path property will be empty. Use FileReader to access contents:

const reader = new FileReader();
reader.onload = () => {
  const content = reader.result;
  console.log(content);
};
reader.readAsText(file); // or readAsArrayBuffer, readAsDataURL

Complete working example

Here is a full-featured file upload component using react-dropzone:

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

interface FileWithPreview extends File {
  preview: string;
}

interface FileUploaderProps {
  onFilesReady: (files: File[]) => void;
  accept?: Record<string, string[]>;
  maxSize?: number;
  maxFiles?: number;
}

export function FileUploader({
  onFilesReady,
  accept = { 'image/*': [] },
  maxSize = 5 * 1024 * 1024,
  maxFiles = 10,
}: FileUploaderProps) {
  const [files, setFiles] = useState<FileWithPreview[]>([]);
  const [rejected, setRejected] = useState<FileRejection[]>([]);

  const onDrop = useCallback(
    (acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
      const newFiles = acceptedFiles.map((file) =>
        Object.assign(file, { preview: URL.createObjectURL(file) })
      );
      setFiles((prev) => [...prev, ...newFiles].slice(0, maxFiles));
      setRejected(rejectedFiles);
      onFilesReady([...files, ...acceptedFiles].slice(0, maxFiles));
    },
    [files, maxFiles, onFilesReady]
  );

  const {
    getRootProps,
    getInputProps,
    isDragActive,
    isDragAccept,
    isDragReject,
  } = useDropzone({
    onDrop,
    accept,
    maxSize,
    maxFiles: maxFiles - files.length,
    disabled: files.length >= maxFiles,
  });

  useEffect(() => {
    return () => {
      files.forEach((file) => URL.revokeObjectURL(file.preview));
    };
  }, [files]);

  const removeFile = (name: string) => {
    setFiles((prev) => {
      const updated = prev.filter((f) => f.name !== name);
      const removed = prev.find((f) => f.name === name);
      if (removed) URL.revokeObjectURL(removed.preview);
      onFilesReady(updated);
      return updated;
    });
  };

  const getBorderColor = () => {
    if (isDragAccept) return '#00e676';
    if (isDragReject) return '#ff1744';
    if (isDragActive) return '#2196f3';
    return '#e0e0e0';
  };

  return (
    <div>
      <div
        {...getRootProps()}
        style={{
          border: `2px dashed ${getBorderColor()}`,
          borderRadius: '8px',
          padding: '32px',
          textAlign: 'center',
          cursor: files.length >= maxFiles ? 'not-allowed' : 'pointer',
          backgroundColor: isDragActive ? '#f5f5f5' : 'white',
          transition: 'all 0.2s ease',
        }}
      >
        <input {...getInputProps()} />
        {isDragAccept && <p>Drop to add files</p>}
        {isDragReject && <p style={{ color: '#d32f2f' }}>Some files will be rejected</p>}
        {!isDragActive && (
          <div>
            <p style={{ margin: '0 0 8px' }}>
              {files.length >= maxFiles
                ? 'Maximum files reached'
                : 'Drag files here, or click to select'}
            </p>
            <p style={{ margin: 0, fontSize: '14px', color: '#666' }}>
              {files.length} / {maxFiles} files selected
            </p>
          </div>
        )}
      </div>

      {files.length > 0 && (
        <div
          style={{
            display: 'flex',
            flexWrap: 'wrap',
            gap: '12px',
            marginTop: '16px',
          }}
        >
          {files.map((file) => (
            <div
              key={file.name}
              style={{
                position: 'relative',
                width: '80px',
                height: '80px',
              }}
            >
              <img
                src={file.preview}
                alt={file.name}
                style={{
                  width: '100%',
                  height: '100%',
                  objectFit: 'cover',
                  borderRadius: '4px',
                }}
              />
              <button
                type="button"
                onClick={() => removeFile(file.name)}
                style={{
                  position: 'absolute',
                  top: '-6px',
                  right: '-6px',
                  width: '20px',
                  height: '20px',
                  borderRadius: '50%',
                  border: 'none',
                  backgroundColor: '#d32f2f',
                  color: 'white',
                  cursor: 'pointer',
                  fontSize: '12px',
                  lineHeight: '1',
                }}
              >
                x
              </button>
            </div>
          ))}
        </div>
      )}

      {rejected.length > 0 && (
        <div style={{ marginTop: '16px', color: '#d32f2f', fontSize: '14px' }}>
          <p style={{ fontWeight: 'bold', margin: '0 0 8px' }}>Rejected files:</p>
          {rejected.map(({ file, errors }) => (
            <p key={file.name} style={{ margin: '4px 0' }}>
              {file.name}: {errors.map((e) => e.message).join(', ')}
            </p>
          ))}
        </div>
      )}
    </div>
  );
}

When to use a dedicated CSV solution

The drag-and-drop file upload we built works for any file type: images, PDFs, documents, or CSV files. For general file uploads, this is all you need.

However, CSV imports typically require more than uploading. Users need to:

  • Map CSV columns to your database fields
  • Validate data types before import
  • Handle and correct errors in the data
  • Preview what will be imported

Building these features from scratch adds significant complexity on top of file upload.

If you're building a CSV import feature, ImportCSV provides a drop-in component that handles drag-and-drop, column mapping, validation, and error handling out of the box:

import { CSVImporter } from '@importcsv/react';

<CSVImporter
  onComplete={(data) => {
    console.log('Validated rows:', data);
  }}
/>

Wrap-up

CSV imports shouldn't slow you down. ImportCSV aims to expand into your workflow — whether you're building data import flows, handling customer uploads, or processing large datasets.

If that sounds like the kind of tooling you want to use, try ImportCSV .