Skip to main content

πŸ“€ Lesson 7.4: File Uploads in React

You've mastered text inputs, dropdowns, checkboxes, and even complex validation with Zod. But there's one crucial type of user input we haven't covered yet: files. Whether it's a profile picture, a resume, product images, or documents, modern web applications frequently need users to upload files. In this lesson, you'll learn everything about handling file uploads in React with TypeScriptβ€”from basic file inputs to drag-and-drop interfaces, image previews, progress tracking, and robust validation. By the end, you'll be able to build professional file upload experiences that rival the best applications on the web.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Understand how file inputs work in HTML and React
  • Handle file selection events with proper TypeScript types
  • Preview images before upload using FileReader and Object URLs
  • Validate file types, sizes, and dimensions
  • Build drag-and-drop file upload interfaces
  • Handle multiple file uploads
  • Display upload progress with proper UI feedback
  • Integrate file uploads with React Hook Form
  • Implement file upload best practices for security and UX

Estimated Time: 60-75 minutes

Project: Build a complete image upload component with preview and validation

πŸ“‘ In This Lesson

πŸ“‹ Introduction to File Uploads

File uploads are everywhere in modern web applications. Think about all the times you've uploaded files online:

  • Social Media: Posting photos to Instagram, Facebook, or Twitter
  • Professional Profiles: Adding your profile picture to LinkedIn, uploading your resume to job applications
  • E-commerce: Sellers uploading product images to marketplaces like Etsy or eBay
  • Cloud Storage: Uploading documents to Google Drive, Dropbox, or OneDrive
  • Communication: Attaching files to emails or sharing images in messaging apps
  • Content Creation: Uploading videos to YouTube, podcast episodes to Spotify

Behind each of these experiences is sophisticated file handling code that makes uploading feel seamless and intuitive. In this lesson, you'll learn to build these experiences yourself.

πŸ“– What is a File Upload?

File Upload: The process of selecting one or more files from a user's device and sending them to a server or processing them in the browser. In React, this involves handling file input events, reading file data, validating files, and often displaying previews before the actual upload occurs.

🎯 What You'll Build

Throughout this lesson, you'll progressively build a comprehensive file upload system:

  1. Basic File Input: Start with a simple file picker that handles selection
  2. Image Preview: Display selected images before upload
  3. File Validation: Check file types, sizes, and dimensions
  4. Multiple Uploads: Handle multiple files at once with individual previews
  5. Drag and Drop: Create an intuitive drag-and-drop interface
  6. Upload Progress: Show progress bars during upload
  7. Integration: Connect everything with React Hook Form
graph LR A[User Selects File] --> B{File Valid?} B -->|Yes| C[Show Preview] B -->|No| D[Show Error] C --> E[User Confirms] E --> F[Upload to Server] F --> G[Show Progress] G --> H[Complete!] D --> A style A fill:#667eea,stroke:#333,stroke-width:2px,color:#fff style H fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff style D fill:#f44336,stroke:#333,stroke-width:2px,color:#fff

βœ… Why This Matters

File uploads are a critical feature in most real-world applications. Understanding how to handle them properlyβ€”with validation, previews, and good UXβ€”will make you a more capable React developer. Plus, the patterns you learn here (like drag-and-drop events and FileReader API) apply to many other browser APIs you'll use throughout your career.

πŸ”€ The HTML File Input

Before we dive into React, let's understand the foundation: the HTML <input type="file"> element. This special input type creates a button that opens your operating system's file picker dialog.

Basic HTML File Input

<!-- Most basic file input -->
<input type="file" />

<!-- Accept only images -->
<input type="file" accept="image/*" />

<!-- Accept specific image types -->
<input type="file" accept="image/png, image/jpeg, image/gif" />

<!-- Allow multiple files -->
<input type="file" multiple />

<!-- Capture from camera on mobile -->
<input type="file" accept="image/*" capture="user" />

Key Attributes

Attribute Purpose Example Value
accept Limits file types in picker dialog "image/*", ".pdf", "video/*"
multiple Allows selecting multiple files Boolean attribute (present or not)
capture Specifies capture source on mobile "user" (front camera), "environment" (back camera)
disabled Disables the input Boolean attribute

⚠️ Important: Accept is Not Validation

The accept attribute is just a hint to the file pickerβ€”it filters what files are shown in the dialog. However, users can still work around it (by selecting "All Files" in the picker, for example). Always validate file types on both the client and server side! Never trust the file extension or MIME type alone.

Common MIME Types and Extensions

File Category Accept Value What It Allows
All Images image/* JPG, PNG, GIF, WebP, SVG, etc.
Specific Images image/png, image/jpeg Only PNG and JPEG
All Videos video/* MP4, WebM, MOV, etc.
All Audio audio/* MP3, WAV, OGG, etc.
PDFs application/pdf or .pdf PDF documents only
Word Docs .doc, .docx Microsoft Word files
Excel .xls, .xlsx Microsoft Excel files
Text Files text/plain or .txt Plain text files

πŸ’‘ Pro Tip: Combining Accept Values

You can combine multiple accept values with commas:

<input 
  type="file" 
  accept="image/png, image/jpeg, image/gif, image/webp"
/>

Or use wildcards for broader categories:

<input 
  type="file" 
  accept="image/*, .pdf"
/>

The File Selection Event

When a user selects files, the input fires a change event. The selected files are available in the event.target.files property, which is a FileList objectβ€”similar to an array but not quite.

// Plain JavaScript example
const input = document.querySelector('input[type="file"]');

input.addEventListener('change', (event) => {
  const files = event.target.files;
  console.log('Number of files:', files.length);
  
  // FileList is array-like but not an array
  // You can access by index
  if (files.length > 0) {
    const firstFile = files[0];
    console.log('File name:', firstFile.name);
    console.log('File size:', firstFile.size, 'bytes');
    console.log('File type:', firstFile.type);
  }
});

πŸ“– FileList Object

FileList: An array-like object (not a true array) that contains File objects. It has a length property and can be accessed by index (like files[0]), but it doesn't have array methods like map or filter. You'll often convert it to a real array using Array.from(files) or the spread operator [...files].

βš›οΈ Basic File Upload Component

Let's create our first React file upload component with proper TypeScript types. We'll start simple and build up from here.

Simple File Input Component

import React, { useState } from 'react';

interface FileUploadProps {
  onFileSelect?: (file: File | null) => void;
}

const FileUpload: React.FC<FileUploadProps> = ({ onFileSelect }) => {
  const [selectedFile, setSelectedFile] = useState<File | null>(null);

  // Handle file selection
  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const files = event.target.files;
    
    // Check if files exist and grab the first one
    if (files && files.length > 0) {
      const file = files[0];
      setSelectedFile(file);
      
      // Call callback if provided
      if (onFileSelect) {
        onFileSelect(file);
      }
    } else {
      // User cancelled the file picker
      setSelectedFile(null);
      if (onFileSelect) {
        onFileSelect(null);
      }
    }
  };

  return (
    <div>
      <h3>Upload a File</h3>
      
      <input
        type="file"
        onChange={handleFileChange}
        accept="image/*"
      />
      
      {selectedFile && (
        <div style={{ marginTop: '1rem' }}>
          <h4>Selected File:</h4>
          <ul>
            <li><strong>Name:</strong> {selectedFile.name}</li>
            <li><strong>Size:</strong> {(selectedFile.size / 1024).toFixed(2)} KB</li>
            <li><strong>Type:</strong> {selectedFile.type}</li>
          </ul>
        </div>
      )}
    </div>
  );
};

export default FileUpload;

Breaking Down the TypeScript Types

// The change event for file inputs
React.ChangeEvent<HTMLInputElement>

// This event object has a target property
event.target // HTMLInputElement

// The files property is a FileList or null
event.target.files // FileList | null

// Individual files are File objects
files[0] // File

βœ… Type Safety Benefits

By properly typing our event handler as React.ChangeEvent<HTMLInputElement>, TypeScript knows exactly what properties are available on event.target. If you try to access event.target.value (which doesn't contain file data), TypeScript guides you to use event.target.files instead!

Using the Component

import FileUpload from './FileUpload';

function App() {
  const handleFileSelect = (file: File | null) => {
    if (file) {
      console.log('User selected:', file.name);
      // Here you might send the file to a server
    } else {
      console.log('User cancelled selection');
    }
  };

  return (
    <div>
      <h1>File Upload Demo</h1>
      <FileUpload onFileSelect={handleFileSelect} />
    </div>
  );
}

⚠️ Common Mistake: Accessing value Instead of files

Unlike text inputs where you access event.target.value, file inputs use event.target.files. The value property on file inputs contains a fake path (for security reasons) and isn't useful. Always use the files property!

// ❌ WRONG
const fileName = event.target.value; // Returns "C:\fakepath\image.jpg"

// βœ… CORRECT
const file = event.target.files[0];
const fileName = file.name; // Returns "image.jpg"

πŸ“„ Understanding File Objects

The File object represents a single file selected by the user. It's part of the web platform's File API and contains useful properties and methods. Let's explore what information is available and how to use it.

File Object Properties

Property Type Description
name string The file name with extension (e.g., "photo.jpg")
size number File size in bytes
type string MIME type (e.g., "image/jpeg", "application/pdf")
lastModified number Unix timestamp of last modification
lastModifiedDate Date Date object of last modification (deprecated but still works)

Practical Example: File Information Display

interface FileInfoProps {
  file: File;
}

const FileInfo: React.FC<FileInfoProps> = ({ file }) => {
  // Helper function to format file size
  const formatFileSize = (bytes: number): string => {
    if (bytes === 0) return '0 Bytes';
    
    const k = 1024;
    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    
    return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
  };

  // Helper function to format date
  const formatDate = (timestamp: number): string => {
    return new Date(timestamp).toLocaleString();
  };

  return (
    <div className="file-info">
      <h4>πŸ“„ File Information</h4>
      <table>
        <tbody>
          <tr>
            <td><strong>Name:</strong></td>
            <td>{file.name}</td>
          </tr>
          <tr>
            <td><strong>Size:</strong></td>
            <td>{formatFileSize(file.size)}</td>
          </tr>
          <tr>
            <td><strong>Type:</strong></td>
            <td>{file.type || 'Unknown'}</td>
          </tr>
          <tr>
            <td><strong>Last Modified:</strong></td>
            <td>{formatDate(file.lastModified)}</td>
          </tr>
        </tbody>
      </table>
    </div>
  );
};

πŸ’‘ Helpful Utility Functions

You'll often want to format file sizes and extract file extensions. Here are some utility functions you can reuse:

// Format bytes to human-readable string
export const formatFileSize = (bytes: number): string => {
  if (bytes === 0) return '0 Bytes';
  const k = 1024;
  const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};

// Get file extension
export const getFileExtension = (filename: string): string => {
  return filename.slice((filename.lastIndexOf('.') - 1 >>> 0) + 2);
};

// Get file name without extension
export const getFileNameWithoutExtension = (filename: string): string => {
  return filename.substring(0, filename.lastIndexOf('.')) || filename;
};

File Extends Blob

The File object extends the Blob (Binary Large Object) interface, which means files have additional methods for reading their content:

// File extends Blob, so you can use Blob methods

// Create a slice of the file (useful for chunked uploads)
const firstKB = file.slice(0, 1024); // First 1KB

// Read as text (we'll cover FileReader in detail soon)
const text = await file.text();

// Read as ArrayBuffer
const buffer = await file.arrayBuffer();

// Get a readable stream
const stream = file.stream();

πŸ“– Blob vs File

Blob: Represents raw binary data. It's the base interface for File.

File: A specific type of Blob that includes additional metadata like filename, size, type, and last modified date. When a user selects a file, you get a File object. When you create binary data programmatically, you often work with Blobs.

πŸ–ΌοΈ Image Previews with FileReader

One of the most common requirements when handling file uploads is showing a previewβ€”especially for images. Users want to see what they've selected before uploading. There are two main approaches to creating previews: using the FileReader API or creating Object URLs. Let's start with FileReader.

What is FileReader?

The FileReader API allows you to asynchronously read the contents of files stored on the user's computer. It can read files in several formats:

  • readAsDataURL: Reads the file as a base64-encoded data URL (perfect for image previews)
  • readAsText: Reads the file as text (useful for .txt, .json, .csv files)
  • readAsArrayBuffer: Reads the file as a raw binary buffer
  • readAsBinaryString: Reads the file as a binary string (deprecated, use ArrayBuffer instead)

πŸ“– Data URL

Data URL: A way to embed file content directly in a URL string using base64 encoding. It looks like: data:image/png;base64,iVBORw0KGgo.... You can use data URLs directly in <img> src attributes to display images without uploading them to a server first.

Basic FileReader Example

// Plain JavaScript FileReader example
const file = event.target.files[0];

if (file && file.type.startsWith('image/')) {
  const reader = new FileReader();
  
  // Set up what happens when reading completes
  reader.onload = (e) => {
    const dataUrl = e.target?.result as string;
    console.log('Data URL:', dataUrl);
    // Now you can use this dataUrl in an img src
  };
  
  // Set up error handling
  reader.onerror = (e) => {
    console.error('Error reading file:', e);
  };
  
  // Start reading the file
  reader.readAsDataURL(file);
}

FileReader with React

Now let's integrate FileReader into a React component that displays an image preview:

import React, { useState } from 'react';

const ImagePreview: React.FC = () => {
  const [selectedFile, setSelectedFile] = useState<File | null>(null);
  const [preview, setPreview] = useState<string>('');
  const [loading, setLoading] = useState(false);

  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const files = event.target.files;
    
    if (!files || files.length === 0) {
      setSelectedFile(null);
      setPreview('');
      return;
    }

    const file = files[0];
    
    // Validate it's an image
    if (!file.type.startsWith('image/')) {
      alert('Please select an image file');
      return;
    }

    setSelectedFile(file);
    setLoading(true);

    // Create FileReader instance
    const reader = new FileReader();

    // Handle successful read
    reader.onload = (e) => {
      const result = e.target?.result;
      if (typeof result === 'string') {
        setPreview(result);
      }
      setLoading(false);
    };

    // Handle error
    reader.onerror = () => {
      alert('Error reading file');
      setLoading(false);
    };

    // Start reading as data URL
    reader.readAsDataURL(file);
  };

  return (
    <div>
      <h3>Image Upload with Preview</h3>
      
      <input
        type="file"
        accept="image/*"
        onChange={handleFileChange}
      />

      {loading && <p>Loading preview...</p>}

      {preview && (
        <div style={{ marginTop: '1rem' }}>
          <h4>Preview:</h4>
          <img 
            src={preview} 
            alt="Preview" 
            style={{ 
              maxWidth: '400px', 
              maxHeight: '400px',
              border: '2px solid #ddd',
              borderRadius: '8px'
            }} 
          />
          {selectedFile && (
            <p>
              {selectedFile.name} - {(selectedFile.size / 1024).toFixed(2)} KB
            </p>
          )}
        </div>
      )}
    </div>
  );
};

export default ImagePreview;

βœ… FileReader Benefits

  • Works Everywhere: Excellent browser support, works in all modern browsers
  • Complete Control: Access to the actual file content as a data URL or text
  • No Cleanup: Data URLs don't need to be revoked (unlike Object URLs)

⚠️ FileReader Drawbacks

  • Memory Intensive: Base64 encoding increases file size by ~33%, which uses more memory
  • Slower for Large Files: Reading and encoding large files can take time
  • Event-based API: Requires callbacks, though you can wrap it in a Promise

Promise-based FileReader Wrapper

To make FileReader easier to work with, you can wrap it in a Promise. This is especially useful with async/await:

// Utility function: Read file as data URL
const readFileAsDataURL = (file: File): Promise<string> => {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    
    reader.onload = (e) => {
      const result = e.target?.result;
      if (typeof result === 'string') {
        resolve(result);
      } else {
        reject(new Error('Failed to read file as data URL'));
      }
    };
    
    reader.onerror = () => {
      reject(new Error('FileReader error'));
    };
    
    reader.readAsDataURL(file);
  });
};

// Now you can use it with async/await
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
  const file = event.target.files?.[0];
  
  if (!file) return;
  
  try {
    setLoading(true);
    const dataUrl = await readFileAsDataURL(file);
    setPreview(dataUrl);
  } catch (error) {
    console.error('Error reading file:', error);
    alert('Failed to read file');
  } finally {
    setLoading(false);
  }
};

πŸ’‘ Reading Text Files

FileReader is also useful for reading text files like JSON, CSV, or plain text:

const readFileAsText = (file: File): Promise<string> => {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = (e) => resolve(e.target?.result as string);
    reader.onerror = reject;
    reader.readAsText(file);
  });
};

// Usage
const file = event.target.files[0];
const text = await readFileAsText(file);
const data = JSON.parse(text); // If it's JSON

πŸ”— Image Previews with Object URLs

While FileReader works great, there's an alternative approach that's often more efficient for image previews: Object URLs (also called Blob URLs). This method creates a temporary URL that points directly to the file in memory, without reading the entire file content.

What is URL.createObjectURL?

The URL.createObjectURL() method creates a special URL that represents a File or Blob object. This URL looks like: blob:https://yoursite.com/uuid-here and can be used directly in <img> tags, just like any other URL.

πŸ“– Object URL

Object URL (Blob URL): A temporary URL that points to a File or Blob object in the browser's memory. It looks like blob:http://localhost:3000/abc-123-def and remains valid as long as the document exists or until you explicitly revoke it with URL.revokeObjectURL().

FileReader vs Object URL Comparison

Aspect FileReader (Data URL) Object URL
Speed Slower (must read/encode entire file) Instant (creates reference only)
Memory Higher (base64 ~33% larger) Lower (file stays as-is)
Cleanup Automatic (no cleanup needed) Manual (must call revokeObjectURL)
Persistence Data URL persists in string form URL is temporary, file must exist
Large Files Can be slow and memory-heavy Efficient even for large files
Best For Small files, need actual content Preview only, especially large images

Basic Object URL Example

// Plain JavaScript example
const file = event.target.files[0];

if (file && file.type.startsWith('image/')) {
  // Create object URL - instant, no reading required!
  const objectUrl = URL.createObjectURL(file);
  
  console.log('Object URL:', objectUrl);
  // Output: blob:http://localhost:3000/abc-123-def-456
  
  // Use it in an img tag
  imgElement.src = objectUrl;
  
  // IMPORTANT: Clean up when done
  // This releases the memory
  imgElement.onload = () => {
    URL.revokeObjectURL(objectUrl);
  };
}

Object URL with React

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

const ImagePreviewObjectURL: React.FC = () => {
  const [selectedFile, setSelectedFile] = useState<File | null>(null);
  const [preview, setPreview] = useState<string>('');

  // Clean up object URL when component unmounts or file changes
  useEffect(() => {
    // Create object URL when file changes
    if (selectedFile) {
      const objectUrl = URL.createObjectURL(selectedFile);
      setPreview(objectUrl);

      // Cleanup function - revoke the object URL
      return () => {
        URL.revokeObjectURL(objectUrl);
      };
    } else {
      // No file selected
      setPreview('');
    }
  }, [selectedFile]);

  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const files = event.target.files;
    
    if (!files || files.length === 0) {
      setSelectedFile(null);
      return;
    }

    const file = files[0];
    
    // Validate it's an image
    if (!file.type.startsWith('image/')) {
      alert('Please select an image file');
      return;
    }

    setSelectedFile(file);
  };

  const clearSelection = () => {
    setSelectedFile(null);
  };

  return (
    <div>
      <h3>Image Upload with Object URL Preview</h3>
      
      <input
        type="file"
        accept="image/*"
        onChange={handleFileChange}
      />

      {preview && (
        <div style={{ marginTop: '1rem' }}>
          <h4>Preview:</h4>
          <img 
            src={preview} 
            alt="Preview" 
            style={{ 
              maxWidth: '400px', 
              maxHeight: '400px',
              border: '2px solid #ddd',
              borderRadius: '8px',
              display: 'block',
              marginBottom: '0.5rem'
            }} 
          />
          {selectedFile && (
            <div>
              <p>
                {selectedFile.name} - {(selectedFile.size / 1024).toFixed(2)} KB
              </p>
              <button onClick={clearSelection}>Clear</button>
            </div>
          )}
        </div>
      )}
    </div>
  );
};

export default ImagePreviewObjectURL;

βœ… Why Use useEffect for Cleanup

The cleanup function in useEffect is perfect for revoking object URLs because:

  • It runs when the component unmounts (preventing memory leaks)
  • It runs when selectedFile changes (cleaning up old URLs)
  • It keeps cleanup logic alongside URL creation

Without cleanup, object URLs stay in memory even after the component is gone!

⚠️ Critical: Always Revoke Object URLs

Object URLs hold references to file data in memory. If you create many object URLs without revoking them, you'll have a memory leak. Always revoke when:

  • The component unmounts
  • A new file is selected (revoking the old URL)
  • The preview is closed or cleared
// ❌ BAD: Memory leak
const url = URL.createObjectURL(file);
setPreview(url);
// URL never revoked!

// βœ… GOOD: Cleanup with useEffect
useEffect(() => {
  if (file) {
    const url = URL.createObjectURL(file);
    setPreview(url);
    return () => URL.revokeObjectURL(url); // Cleanup!
  }
}, [file]);

When to Use Each Method

πŸ’‘ Decision Guide

Use Object URLs when:

  • Creating image/video previews
  • Working with large files
  • Performance is important
  • You only need to display the file, not manipulate its content

Use FileReader when:

  • You need the actual file content (for manipulation, sending to server as base64)
  • Reading text files (JSON, CSV, TXT)
  • You need the data URL to persist beyond component lifecycle
  • Working with very small files where the overhead doesn't matter

⚑ Interactive: Object URL Memory Leak Visualization

See why revoking Object URLs matters β€” watch memory grow without cleanup!

Browser Memory Usage 0 MB
0 MB 50 MB (Warning) 100 MB
❌ Without Cleanup

URLs created here leak memory

βœ… With revokeObjectURL()

URLs created and revoked β€” no leak!

0
URLs Created
0
Leaked (Not Revoked)
0
Properly Revoked
0 MB
Memory Saved

βœ… File Validation

File validation is crucial for security, user experience, and preventing issues. You should validate files on the client side for immediate feedback, and always validate on the server side for security. Let's explore comprehensive client-side validation techniques.

Why Validate Files?

  • Security: Prevent malicious file uploads (executables, scripts disguised as images)
  • User Experience: Provide immediate feedback before upload attempts
  • Server Protection: Prevent overwhelming your server with huge files
  • Storage Costs: Avoid storing files that are too large or wrong format
  • Application Logic: Ensure files meet your app's requirements (dimensions, aspect ratio, etc.)

⚠️ Client-Side Validation is Not Security

Never rely on client-side validation alone! Users can bypass it by modifying browser code or sending requests directly. Always validate files on the server side as well. Client-side validation is for UX, server-side validation is for security.

Types of Validation

1. File Type Validation

Check the file's MIME type and optionally its extension:

const validateFileType = (
  file: File, 
  allowedTypes: string[]
): { valid: boolean; error?: string } => {
  // Check MIME type
  if (!allowedTypes.includes(file.type)) {
    return {
      valid: false,
      error: `File type ${file.type} is not allowed. Allowed types: ${allowedTypes.join(', ')}`
    };
  }
  
  return { valid: true };
};

// Usage
const allowedImageTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const result = validateFileType(file, allowedImageTypes);

if (!result.valid) {
  alert(result.error);
}

2. File Size Validation

Limit file size to prevent huge uploads:

const validateFileSize = (
  file: File,
  maxSizeInMB: number
): { valid: boolean; error?: string } => {
  const maxSizeInBytes = maxSizeInMB * 1024 * 1024;
  
  if (file.size > maxSizeInBytes) {
    const fileSizeInMB = (file.size / (1024 * 1024)).toFixed(2);
    return {
      valid: false,
      error: `File size (${fileSizeInMB}MB) exceeds maximum allowed size (${maxSizeInMB}MB)`
    };
  }
  
  return { valid: true };
};

// Usage
const result = validateFileSize(file, 5); // Max 5MB

if (!result.valid) {
  alert(result.error);
}

3. Image Dimension Validation

For images, you might need to check width, height, or aspect ratio:

interface DimensionConstraints {
  minWidth?: number;
  maxWidth?: number;
  minHeight?: number;
  maxHeight?: number;
  aspectRatio?: number; // e.g., 16/9 or 1 for square
  aspectRatioTolerance?: number; // e.g., 0.1 for 10% tolerance
}

const validateImageDimensions = (
  file: File,
  constraints: DimensionConstraints
): Promise<{ valid: boolean; error?: string; dimensions?: { width: number; height: number } }> => {
  return new Promise((resolve) => {
    // Only works for images
    if (!file.type.startsWith('image/')) {
      resolve({ valid: false, error: 'File is not an image' });
      return;
    }

    // Create an image element to load the file
    const img = new Image();
    const objectUrl = URL.createObjectURL(file);

    img.onload = () => {
      // Clean up object URL
      URL.revokeObjectURL(objectUrl);

      const width = img.width;
      const height = img.height;
      const actualRatio = width / height;

      // Check minimum width
      if (constraints.minWidth && width < constraints.minWidth) {
        resolve({
          valid: false,
          error: `Image width (${width}px) is less than minimum (${constraints.minWidth}px)`,
          dimensions: { width, height }
        });
        return;
      }

      // Check maximum width
      if (constraints.maxWidth && width > constraints.maxWidth) {
        resolve({
          valid: false,
          error: `Image width (${width}px) exceeds maximum (${constraints.maxWidth}px)`,
          dimensions: { width, height }
        });
        return;
      }

      // Check minimum height
      if (constraints.minHeight && height < constraints.minHeight) {
        resolve({
          valid: false,
          error: `Image height (${height}px) is less than minimum (${constraints.minHeight}px)`,
          dimensions: { width, height }
        });
        return;
      }

      // Check maximum height
      if (constraints.maxHeight && height > constraints.maxHeight) {
        resolve({
          valid: false,
          error: `Image height (${height}px) exceeds maximum (${constraints.maxHeight}px)`,
          dimensions: { width, height }
        });
        return;
      }

      // Check aspect ratio
      if (constraints.aspectRatio) {
        const tolerance = constraints.aspectRatioTolerance || 0.01;
        const ratioDifference = Math.abs(actualRatio - constraints.aspectRatio);
        
        if (ratioDifference > tolerance) {
          resolve({
            valid: false,
            error: `Image aspect ratio (${actualRatio.toFixed(2)}) doesn't match required ratio (${constraints.aspectRatio.toFixed(2)})`,
            dimensions: { width, height }
          });
          return;
        }
      }

      // All checks passed!
      resolve({ valid: true, dimensions: { width, height } });
    };

    img.onerror = () => {
      URL.revokeObjectURL(objectUrl);
      resolve({ valid: false, error: 'Failed to load image' });
    };

    img.src = objectUrl;
  });
};

// Usage
const result = await validateImageDimensions(file, {
  minWidth: 800,
  minHeight: 600,
  maxWidth: 4000,
  maxHeight: 4000,
  aspectRatio: 16 / 9,
  aspectRatioTolerance: 0.1
});

if (!result.valid) {
  alert(result.error);
} else {
  console.log('Image dimensions:', result.dimensions);
}

βœ… How Image Dimension Validation Works

To check image dimensions, we:

  1. Create an object URL from the file
  2. Create an <img> element in memory (not in DOM)
  3. Set the object URL as the image source
  4. Wait for the image to load
  5. Read the width and height properties
  6. Revoke the object URL to free memory

This all happens in memory without displaying anything to the user!

Comprehensive File Validator

Let's combine all validation types into one comprehensive validator:

interface FileValidationRules {
  allowedTypes?: string[];
  maxSizeInMB?: number;
  minSizeInKB?: number;
  imageConstraints?: DimensionConstraints;
}

interface ValidationResult {
  valid: boolean;
  errors: string[];
  warnings?: string[];
}

const validateFile = async (
  file: File,
  rules: FileValidationRules
): Promise<ValidationResult> => {
  const errors: string[] = [];
  const warnings: string[] = [];

  // 1. Validate file type
  if (rules.allowedTypes && !rules.allowedTypes.includes(file.type)) {
    errors.push(
      `File type "${file.type}" is not allowed. ` +
      `Allowed types: ${rules.allowedTypes.join(', ')}`
    );
  }

  // 2. Validate file size (max)
  if (rules.maxSizeInMB) {
    const maxBytes = rules.maxSizeInMB * 1024 * 1024;
    if (file.size > maxBytes) {
      const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
      errors.push(
        `File size (${sizeMB}MB) exceeds maximum (${rules.maxSizeInMB}MB)`
      );
    }
  }

  // 3. Validate file size (min)
  if (rules.minSizeInKB) {
    const minBytes = rules.minSizeInKB * 1024;
    if (file.size < minBytes) {
      const sizeKB = (file.size / 1024).toFixed(2);
      errors.push(
        `File size (${sizeKB}KB) is less than minimum (${rules.minSizeInKB}KB)`
      );
    }
  }

  // 4. Validate image dimensions (if applicable)
  if (rules.imageConstraints && file.type.startsWith('image/')) {
    const dimensionResult = await validateImageDimensions(
      file,
      rules.imageConstraints
    );
    
    if (!dimensionResult.valid && dimensionResult.error) {
      errors.push(dimensionResult.error);
    }
  }

  return {
    valid: errors.length === 0,
    errors,
    warnings
  };
};

// Usage Example
const handleFileSelection = async (file: File) => {
  const validationRules: FileValidationRules = {
    allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
    maxSizeInMB: 5,
    minSizeInKB: 10,
    imageConstraints: {
      minWidth: 800,
      minHeight: 600,
      maxWidth: 4000,
      maxHeight: 4000
    }
  };

  const result = await validateFile(file, validationRules);

  if (!result.valid) {
    alert('Validation failed:\n' + result.errors.join('\n'));
    return;
  }

  console.log('File passed validation!');
  // Proceed with upload...
};

πŸ’‘ User-Friendly Error Messages

Always provide clear, actionable error messages:

  • ❌ Bad: "Invalid file"
  • βœ… Good: "File must be a JPEG or PNG image, maximum 5MB"

Tell users exactly what went wrong and how to fix it!

πŸ“š Multiple File Uploads

So far, we've focused on single file uploads. But what if users need to upload multiple files at once? Maybe they're uploading a photo album, multiple documents, or product images. Let's learn how to handle multiple file selections and display previews for each one.

Enabling Multiple File Selection

The HTML file input supports multiple file selection with the multiple attribute:

<input type="file" multiple accept="image/*" />

When multiple is present, users can select multiple files from the file picker (usually by holding Ctrl/Cmd or Shift). The event.target.files will then contain a FileList with all selected files.

Converting FileList to Array

Since FileList is array-like but not a true array, we need to convert it to use array methods:

// Method 1: Array.from()
const filesArray = Array.from(event.target.files || []);

// Method 2: Spread operator
const filesArray = [...(event.target.files || [])];

// Method 3: Array.prototype.slice.call() (older approach)
const filesArray = Array.prototype.slice.call(event.target.files || []);

βœ… Why Convert to Array?

Converting to an array allows you to use helpful methods like:

  • .map() - Transform each file
  • .filter() - Remove invalid files
  • .forEach() - Process each file
  • .some() / .every() - Check conditions

Basic Multiple File Upload Component

import React, { useState } from 'react';

interface FileWithPreview {
  file: File;
  preview: string;
  id: string;
}

const MultipleFileUpload: React.FC = () => {
  const [files, setFiles] = useState<FileWithPreview[]>([]);

  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const selectedFiles = event.target.files;
    
    if (!selectedFiles || selectedFiles.length === 0) {
      return;
    }

    // Convert FileList to array
    const filesArray = Array.from(selectedFiles);

    // Filter for images only
    const imageFiles = filesArray.filter(file => 
      file.type.startsWith('image/')
    );

    if (imageFiles.length === 0) {
      alert('Please select at least one image file');
      return;
    }

    // Create preview URLs for each file
    const filesWithPreviews: FileWithPreview[] = imageFiles.map(file => ({
      file,
      preview: URL.createObjectURL(file),
      id: `${file.name}-${Date.now()}-${Math.random()}`
    }));

    setFiles(filesWithPreviews);
  };

  const removeFile = (id: string) => {
    setFiles(prevFiles => {
      // Find the file to remove
      const fileToRemove = prevFiles.find(f => f.id === id);
      
      // Revoke its object URL to free memory
      if (fileToRemove) {
        URL.revokeObjectURL(fileToRemove.preview);
      }
      
      // Return filtered array
      return prevFiles.filter(f => f.id !== id);
    });
  };

  const clearAll = () => {
    // Revoke all object URLs
    files.forEach(f => URL.revokeObjectURL(f.preview));
    setFiles([]);
  };

  // Cleanup on unmount
  React.useEffect(() => {
    return () => {
      // Revoke all object URLs when component unmounts
      files.forEach(f => URL.revokeObjectURL(f.preview));
    };
  }, [files]);

  return (
    <div>
      <h3>Upload Multiple Images</h3>
      
      <input
        type="file"
        multiple
        accept="image/*"
        onChange={handleFileChange}
      />

      {files.length > 0 && (
        <div style={{ marginTop: '1rem' }}>
          <div style={{ 
            display: 'flex', 
            justifyContent: 'space-between', 
            alignItems: 'center',
            marginBottom: '1rem'
          }}>
            <h4>Selected Files: {files.length}</h4>
            <button onClick={clearAll}>Clear All</button>
          </div>

          <div style={{ 
            display: 'grid', 
            gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
            gap: '1rem'
          }}>
            {files.map((fileItem) => (
              <div 
                key={fileItem.id}
                style={{
                  border: '2px solid #ddd',
                  borderRadius: '8px',
                  padding: '0.5rem',
                  position: 'relative'
                }}
              >
                <img 
                  src={fileItem.preview} 
                  alt={fileItem.file.name}
                  style={{
                    width: '100%',
                    height: '150px',
                    objectFit: 'cover',
                    borderRadius: '4px'
                  }}
                />
                <p style={{ 
                  fontSize: '0.875rem', 
                  marginTop: '0.5rem',
                  wordBreak: 'break-word'
                }}>
                  {fileItem.file.name}
                </p>
                <p style={{ fontSize: '0.75rem', color: '#666' }}>
                  {(fileItem.file.size / 1024).toFixed(2)} KB
                </p>
                <button 
                  onClick={() => removeFile(fileItem.id)}
                  style={{
                    width: '100%',
                    marginTop: '0.5rem',
                    padding: '0.25rem',
                    backgroundColor: '#f44336',
                    color: 'white',
                    border: 'none',
                    borderRadius: '4px',
                    cursor: 'pointer'
                  }}
                >
                  Remove
                </button>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
};

export default MultipleFileUpload;

πŸ’‘ Key Patterns in Multiple File Handling

  • Unique IDs: Generate unique IDs for each file (using name + timestamp + random) so React can track them properly
  • Object URL Management: Store preview URLs alongside files and revoke them individually when removed
  • Cleanup: Revoke all URLs on unmount to prevent memory leaks
  • Grid Layout: Use CSS Grid for responsive preview gallery

Validating Multiple Files

When handling multiple files, you'll want to validate each one individually and provide feedback:

interface ValidationError {
  fileName: string;
  error: string;
}

const handleMultipleFileValidation = async (
  files: File[],
  rules: FileValidationRules
): Promise<{
  validFiles: File[];
  errors: ValidationError[];
}> => {
  const validFiles: File[] = [];
  const errors: ValidationError[] = [];

  // Validate each file
  for (const file of files) {
    const result = await validateFile(file, rules);
    
    if (result.valid) {
      validFiles.push(file);
    } else {
      errors.push({
        fileName: file.name,
        error: result.errors.join(', ')
      });
    }
  }

  return { validFiles, errors };
};

// Usage in component
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
  const selectedFiles = event.target.files;
  if (!selectedFiles || selectedFiles.length === 0) return;

  const filesArray = Array.from(selectedFiles);

  // Validate all files
  const { validFiles, errors } = await handleMultipleFileValidation(
    filesArray,
    {
      allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
      maxSizeInMB: 5,
      imageConstraints: {
        minWidth: 800,
        minHeight: 600
      }
    }
  );

  // Show errors if any
  if (errors.length > 0) {
    const errorMessage = errors
      .map(e => `${e.fileName}: ${e.error}`)
      .join('\n');
    alert(`Some files were invalid:\n\n${errorMessage}`);
  }

  // Process valid files
  if (validFiles.length > 0) {
    const filesWithPreviews = validFiles.map(file => ({
      file,
      preview: URL.createObjectURL(file),
      id: `${file.name}-${Date.now()}-${Math.random()}`
    }));

    setFiles(filesWithPreviews);
  }
};

⚠️ Performance Consideration

Validating many large files (especially with dimension checks) can take time. Consider:

  • Showing a loading indicator during validation
  • Processing files in batches
  • Using Web Workers for heavy validation
  • Setting reasonable limits on the number of files (e.g., max 10-20 at once)

Limiting the Number of Files

const MAX_FILES = 10;

const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
  const selectedFiles = event.target.files;
  if (!selectedFiles || selectedFiles.length === 0) return;

  // Check file count limit
  if (selectedFiles.length > MAX_FILES) {
    alert(`You can only upload up to ${MAX_FILES} files at once`);
    return;
  }

  // Also check total count if adding to existing files
  if (files.length + selectedFiles.length > MAX_FILES) {
    alert(
      `You already have ${files.length} files. ` +
      `You can only upload ${MAX_FILES - files.length} more.`
    );
    return;
  }

  // Process files...
};

Adding Files Instead of Replacing

Sometimes users want to add more files to their existing selection rather than replacing it:

const addMoreFiles = (event: React.ChangeEvent<HTMLInputElement>) => {
  const selectedFiles = event.target.files;
  if (!selectedFiles || selectedFiles.length === 0) return;

  const filesArray = Array.from(selectedFiles);
  
  // Filter for images
  const imageFiles = filesArray.filter(file => file.type.startsWith('image/'));

  // Create new previews
  const newFilesWithPreviews = imageFiles.map(file => ({
    file,
    preview: URL.createObjectURL(file),
    id: `${file.name}-${Date.now()}-${Math.random()}`
  }));

  // Add to existing files (not replace)
  setFiles(prevFiles => [...prevFiles, ...newFilesWithPreviews]);
};

🎯 Drag and Drop Basics

Drag and drop provides a more intuitive user experience than clicking a file input button. Users can simply drag files from their file explorer and drop them onto your component. Let's learn how to implement this powerful feature!

Understanding Drag and Drop Events

The HTML Drag and Drop API provides several events for the drop zone (the area where users drop files):

Event When It Fires What To Do
dragenter When dragged item enters the drop zone Add visual feedback (highlight border)
dragover Continuously while item is over drop zone Must call preventDefault() to allow dropping
dragleave When dragged item leaves the drop zone Remove visual feedback
drop When item is dropped in the zone Get files from event.dataTransfer.files

⚠️ Critical: preventDefault()

You must call event.preventDefault() in both the dragover and drop event handlers. Otherwise, the browser's default behavior (often opening the file in a new tab) will occur instead of your custom drop handling.

const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
  e.preventDefault(); // ← Essential!
};

Basic Drag and Drop Implementation

import React, { useState } from 'react';

const BasicDragDrop: React.FC = () => {
  const [isDragging, setIsDragging] = useState(false);
  const [files, setFiles] = useState<File[]>([]);

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

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

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

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

    // Get dropped files
    const droppedFiles = e.dataTransfer.files;
    
    if (droppedFiles && droppedFiles.length > 0) {
      const filesArray = Array.from(droppedFiles);
      setFiles(filesArray);
    }
  };

  return (
    <div>
      <h3>Drag and Drop File Upload</h3>
      
      <div
        onDragEnter={handleDragEnter}
        onDragLeave={handleDragLeave}
        onDragOver={handleDragOver}
        onDrop={handleDrop}
        style={{
          border: isDragging 
            ? '3px dashed #667eea' 
            : '2px dashed #ccc',
          borderRadius: '8px',
          padding: '2rem',
          textAlign: 'center',
          backgroundColor: isDragging 
            ? '#f0f4ff' 
            : '#fafafa',
          transition: 'all 0.3s ease',
          cursor: 'pointer'
        }}
      >
        {isDragging ? (
          <p style={{ fontSize: '1.2rem', color: '#667eea' }}>
            πŸ“₯ Drop files here...
          </p>
        ) : (
          <div>
            <p style={{ fontSize: '1.2rem' }}>
              πŸ“‚ Drag and drop files here
            </p>
            <p style={{ color: '#666' }}>
              or click to browse
            </p>
          </div>
        )}
      </div>

      {files.length > 0 && (
        <div style={{ marginTop: '1rem' }}>
          <h4>Selected Files:</h4>
          <ul>
            {files.map((file, index) => (
              <li key={index}>
                {file.name} - {(file.size / 1024).toFixed(2)} KB
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
};

export default BasicDragDrop;

βœ… Why stopPropagation()?

Calling e.stopPropagation() prevents the event from bubbling up to parent elements. This is important if you have nested drop zones or if parent elements also handle drag events. It ensures only your intended drop zone processes the event.

Combining File Input with Drag and Drop

Best practice is to support both methodsβ€”drag and drop AND the traditional file inputβ€”so users can choose their preferred method:

const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
  const selectedFiles = e.target.files;
  if (selectedFiles && selectedFiles.length > 0) {
    const filesArray = Array.from(selectedFiles);
    setFiles(filesArray);
  }
};

return (
  <div
    onDragEnter={handleDragEnter}
    onDragLeave={handleDragLeave}
    onDragOver={handleDragOver}
    onDrop={handleDrop}
    onClick={() => fileInputRef.current?.click()}
    style={{ /* drop zone styles */ }}
  >
    <input
      ref={fileInputRef}
      type="file"
      multiple
      accept="image/*"
      onChange={handleFileInput}
      style={{ display: 'none' }}
    />
    
    <p>Drag and drop files or click to browse</p>
  </div>
);

πŸ’‘ UX Best Practices for Drag and Drop

  • Visual Feedback: Change appearance when files are dragged over (border color, background)
  • Clear Instructions: Tell users they can drag files OR click
  • Icon/Illustration: Add a visual element (folder icon, upload icon) to make it obvious
  • Accepted Types: Indicate what file types are accepted
  • Hover State: Show the drop zone is interactive even without dragging

Handling dragenter/dragleave Issues

One common issue with drag and drop is that dragleave fires when the mouse enters child elements inside your drop zone, causing flickering. Here's a robust solution using a counter:

const DragDropFixed: React.FC = () => {
  const [dragCounter, setDragCounter] = useState(0);
  const isDragging = dragCounter > 0;

  const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setDragCounter(prev => prev + 1);
  };

  const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setDragCounter(prev => prev - 1);
  };

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

  const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setDragCounter(0); // Reset counter

    const droppedFiles = e.dataTransfer.files;
    // Process files...
  };

  return (
    <div
      onDragEnter={handleDragEnter}
      onDragLeave={handleDragLeave}
      onDragOver={handleDragOver}
      onDrop={handleDrop}
      style={{
        border: isDragging ? '3px dashed #667eea' : '2px dashed #ccc',
        // ... other styles
      }}
    >
      {/* Drop zone content */}
    </div>
  );
};

πŸ“– The Drag Counter Pattern

Drag Counter: Increment on dragenter, decrement on dragleave. This works because entering a child element fires both leave (for parent) and enter (for child), keeping the counter above zero. Only when you truly leave the entire drop zone does the counter return to zero.

🎨 Complete Drag and Drop Component

Let's put everything together into a production-ready drag-and-drop file upload component with previews, validation, and both drag-drop and click-to-browse functionality:

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

interface FileWithPreview {
  file: File;
  preview: string;
  id: string;
}

interface DragDropUploadProps {
  maxFiles?: number;
  maxSizeInMB?: number;
  acceptedTypes?: string[];
  onFilesChange?: (files: File[]) => void;
}

const DragDropUpload: React.FC<DragDropUploadProps> = ({
  maxFiles = 10,
  maxSizeInMB = 5,
  acceptedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
  onFilesChange
}) => {
  const [files, setFiles] = useState<FileWithPreview[]>([]);
  const [dragCounter, setDragCounter] = useState(0);
  const [errors, setErrors] = useState<string[]>([]);
  const fileInputRef = useRef<HTMLInputElement>(null);

  const isDragging = dragCounter > 0;

  // Cleanup object URLs on unmount
  useEffect(() => {
    return () => {
      files.forEach(f => URL.revokeObjectURL(f.preview));
    };
  }, [files]);

  // Notify parent of file changes
  useEffect(() => {
    if (onFilesChange) {
      onFilesChange(files.map(f => f.file));
    }
  }, [files, onFilesChange]);

  const validateAndProcessFiles = (newFiles: File[]) => {
    const validationErrors: string[] = [];
    const validFiles: File[] = [];

    // Check total file count
    if (files.length + newFiles.length > maxFiles) {
      validationErrors.push(
        `Maximum ${maxFiles} files allowed. ` +
        `You have ${files.length} and tried to add ${newFiles.length}.`
      );
      setErrors(validationErrors);
      return;
    }

    // Validate each file
    newFiles.forEach(file => {
      // Check file type
      if (!acceptedTypes.includes(file.type)) {
        validationErrors.push(
          `${file.name}: Invalid file type. ` +
          `Accepted types: ${acceptedTypes.join(', ')}`
        );
        return;
      }

      // Check file size
      const sizeInMB = file.size / (1024 * 1024);
      if (sizeInMB > maxSizeInMB) {
        validationErrors.push(
          `${file.name}: File too large (${sizeInMB.toFixed(2)}MB). ` +
          `Maximum size: ${maxSizeInMB}MB`
        );
        return;
      }

      validFiles.push(file);
    });

    // Update errors
    setErrors(validationErrors);

    // Add valid files
    if (validFiles.length > 0) {
      const filesWithPreviews: FileWithPreview[] = validFiles.map(file => ({
        file,
        preview: URL.createObjectURL(file),
        id: `${file.name}-${Date.now()}-${Math.random()}`
      }));

      setFiles(prev => [...prev, ...filesWithPreviews]);
    }
  };

  const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setDragCounter(prev => prev + 1);
  };

  const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setDragCounter(prev => prev - 1);
  };

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

  const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setDragCounter(0);

    const droppedFiles = e.dataTransfer.files;
    if (droppedFiles && droppedFiles.length > 0) {
      const filesArray = Array.from(droppedFiles);
      validateAndProcessFiles(filesArray);
    }
  };

  const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
    const selectedFiles = e.target.files;
    if (selectedFiles && selectedFiles.length > 0) {
      const filesArray = Array.from(selectedFiles);
      validateAndProcessFiles(filesArray);
    }
    
    // Reset input so the same file can be selected again
    e.target.value = '';
  };

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

  const clearAll = () => {
    files.forEach(f => URL.revokeObjectURL(f.preview));
    setFiles([]);
    setErrors([]);
  };

  const openFilePicker = () => {
    fileInputRef.current?.click();
  };

  return (
    <div style={{ width: '100%' }}>
      {/* Drop Zone */}
      <div
        onDragEnter={handleDragEnter}
        onDragLeave={handleDragLeave}
        onDragOver={handleDragOver}
        onDrop={handleDrop}
        onClick={openFilePicker}
        style={{
          border: isDragging 
            ? '3px dashed #667eea' 
            : '2px dashed #ccc',
          borderRadius: '12px',
          padding: '3rem 2rem',
          textAlign: 'center',
          backgroundColor: isDragging 
            ? '#f0f4ff' 
            : '#fafafa',
          transition: 'all 0.3s ease',
          cursor: 'pointer',
          marginBottom: '1rem'
        }}
      >
        <input
          ref={fileInputRef}
          type="file"
          multiple
          accept={acceptedTypes.join(',')}
          onChange={handleFileInput}
          style={{ display: 'none' }}
        />

        {isDragging ? (
          <div>
            <p style={{ fontSize: '2rem', margin: 0 }}>πŸ“₯</p>
            <p style={{ 
              fontSize: '1.2rem', 
              color: '#667eea',
              margin: '0.5rem 0 0 0'
            }}>
              Drop your files here
            </p>
          </div>
        ) : (
          <div>
            <p style={{ fontSize: '2rem', margin: 0 }}>πŸ“</p>
            <p style={{ 
              fontSize: '1.2rem', 
              margin: '0.5rem 0',
              fontWeight: 500
            }}>
              Drag and drop files here
            </p>
            <p style={{ color: '#666', margin: '0.25rem 0' }}>
              or click to browse
            </p>
            <p style={{ 
              fontSize: '0.875rem', 
              color: '#999',
              margin: '1rem 0 0 0'
            }}>
              Accepted: {acceptedTypes.map(t => t.split('/')[1]).join(', ')} 
              | Max {maxSizeInMB}MB | Up to {maxFiles} files
            </p>
          </div>
        )}
      </div>

      {/* Errors */}
      {errors.length > 0 && (
        <div style={{
          backgroundColor: '#fee',
          border: '1px solid #fcc',
          borderRadius: '8px',
          padding: '1rem',
          marginBottom: '1rem'
        }}>
          <h4 style={{ margin: '0 0 0.5rem 0', color: '#c33' }}>
            ⚠️ Validation Errors:
          </h4>
          <ul style={{ margin: 0, paddingLeft: '1.5rem' }}>
            {errors.map((error, index) => (
              <li key={index} style={{ color: '#c33' }}>{error}</li>
            ))}
          </ul>
        </div>
      )}

      {/* File Preview Grid */}
      {files.length > 0 && (
        <div>
          <div style={{
            display: 'flex',
            justifyContent: 'space-between',
            alignItems: 'center',
            marginBottom: '1rem'
          }}>
            <h4 style={{ margin: 0 }}>
              Selected Files ({files.length}/{maxFiles})
            </h4>
            <button
              onClick={clearAll}
              style={{
                padding: '0.5rem 1rem',
                backgroundColor: '#f44336',
                color: 'white',
                border: 'none',
                borderRadius: '6px',
                cursor: 'pointer',
                fontSize: '0.875rem'
              }}
            >
              Clear All
            </button>
          </div>

          <div style={{
            display: 'grid',
            gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
            gap: '1rem'
          }}>
            {files.map((fileItem) => (
              <div
                key={fileItem.id}
                style={{
                  border: '2px solid #e0e0e0',
                  borderRadius: '8px',
                  padding: '0.5rem',
                  backgroundColor: 'white',
                  position: 'relative'
                }}
              >
                <img
                  src={fileItem.preview}
                  alt={fileItem.file.name}
                  style={{
                    width: '100%',
                    height: '150px',
                    objectFit: 'cover',
                    borderRadius: '6px',
                    marginBottom: '0.5rem'
                  }}
                />
                <p style={{
                  fontSize: '0.875rem',
                  margin: '0 0 0.25rem 0',
                  fontWeight: 500,
                  wordBreak: 'break-word'
                }}>
                  {fileItem.file.name}
                </p>
                <p style={{
                  fontSize: '0.75rem',
                  color: '#666',
                  margin: '0 0 0.5rem 0'
                }}>
                  {(fileItem.file.size / 1024).toFixed(2)} KB
                </p>
                <button
                  onClick={(e) => {
                    e.stopPropagation();
                    removeFile(fileItem.id);
                  }}
                  style={{
                    width: '100%',
                    padding: '0.5rem',
                    backgroundColor: '#ff5252',
                    color: 'white',
                    border: 'none',
                    borderRadius: '6px',
                    cursor: 'pointer',
                    fontSize: '0.875rem',
                    transition: 'background-color 0.2s'
                  }}
                  onMouseOver={(e) => {
                    e.currentTarget.style.backgroundColor = '#ff1744';
                  }}
                  onMouseOut={(e) => {
                    e.currentTarget.style.backgroundColor = '#ff5252';
                  }}
                >
                  Remove
                </button>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
};

export default DragDropUpload;

Using the Complete Component

import DragDropUpload from './DragDropUpload';

function App() {
  const handleFilesChange = (files: File[]) => {
    console.log('Selected files:', files);
    // Here you would typically upload these files
  };

  return (
    <div style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto' }}>
      <h1>File Upload Demo</h1>
      
      <DragDropUpload
        maxFiles={5}
        maxSizeInMB={10}
        acceptedTypes={[
          'image/jpeg',
          'image/png',
          'image/webp',
          'image/gif'
        ]}
        onFilesChange={handleFilesChange}
      />
    </div>
  );
}

βœ… Features of Our Complete Component

  • Dual Input: Supports both drag-drop and click-to-browse
  • Visual Feedback: Border and background change on drag
  • Validation: File type and size validation with clear error messages
  • Preview Grid: Responsive grid layout for file previews
  • Individual Removal: Remove files one at a time
  • Bulk Actions: Clear all files at once
  • Memory Management: Properly revokes object URLs
  • Configurable: Props for max files, size limits, accepted types
  • Parent Communication: Callback when files change
  • Accessible: Works with keyboard navigation

πŸ’‘ Enhancement Ideas

You could extend this component further with:

  • Upload Progress: Show progress bars during actual upload
  • Image Editing: Crop, rotate, or resize before upload
  • Dimension Validation: Check image dimensions
  • Server Upload: Actually send files to a backend
  • Reorder Files: Drag to reorder the file list
  • Paste Support: Allow pasting images from clipboard
  • Camera Capture: On mobile, open camera directly
  • Chunked Upload: Split large files for reliable uploads

πŸ“Š Upload Progress Tracking

When uploading files, especially large ones, users need feedback about what's happening. Is the upload progressing? How much longer will it take? A progress bar provides crucial visual feedback and reassures users that the upload is working. Let's implement upload progress tracking using XMLHttpRequest or the Fetch API with streams.

Upload Progress with XMLHttpRequest

The older XMLHttpRequest API has built-in support for upload progress tracking through the progress event:

interface UploadProgress {
  fileName: string;
  progress: number; // 0-100
  loaded: number; // bytes uploaded
  total: number; // total bytes
}

const uploadFileWithProgress = (
  file: File,
  url: string,
  onProgress: (progress: UploadProgress) => void
): Promise<void> => {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    const formData = new FormData();
    formData.append('file', file);

    // Track upload progress
    xhr.upload.addEventListener('progress', (e) => {
      if (e.lengthComputable) {
        const percentComplete = (e.loaded / e.total) * 100;
        
        onProgress({
          fileName: file.name,
          progress: Math.round(percentComplete),
          loaded: e.loaded,
          total: e.total
        });
      }
    });

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

    // Handle errors
    xhr.addEventListener('error', () => {
      reject(new Error('Upload failed due to network error'));
    });

    xhr.addEventListener('abort', () => {
      reject(new Error('Upload was cancelled'));
    });

    // Send the request
    xhr.open('POST', url);
    xhr.send(formData);
  });
};

// Usage
const handleUpload = async (file: File) => {
  try {
    await uploadFileWithProgress(
      file,
      '/api/upload',
      (progress) => {
        console.log(
          `${progress.fileName}: ${progress.progress}% ` +
          `(${progress.loaded}/${progress.total} bytes)`
        );
      }
    );
    console.log('Upload complete!');
  } catch (error) {
    console.error('Upload failed:', error);
  }
};

πŸ“– FormData

FormData: A Web API that provides a way to construct key/value pairs representing form fields and their values, which can then be sent using XMLHttpRequest or fetch. It's the standard way to send files to a server. You append files and other data to it, then send it as the request body.

React Component with Upload Progress

import React, { useState } from 'react';

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

const FileUploadWithProgress: React.FC = () => {
  const [uploadStates, setUploadStates] = useState<
    Map<string, FileUploadState>
  >(new Map());

  const uploadFile = async (file: File) => {
    const fileId = `${file.name}-${Date.now()}`;

    // Initialize upload state
    setUploadStates(prev => {
      const newMap = new Map(prev);
      newMap.set(fileId, {
        file,
        progress: 0,
        status: 'uploading'
      });
      return newMap;
    });

    try {
      await uploadFileWithProgress(
        file,
        '/api/upload',
        (progressInfo) => {
          setUploadStates(prev => {
            const newMap = new Map(prev);
            const state = newMap.get(fileId);
            if (state) {
              newMap.set(fileId, {
                ...state,
                progress: progressInfo.progress
              });
            }
            return newMap;
          });
        }
      );

      // Mark as success
      setUploadStates(prev => {
        const newMap = new Map(prev);
        const state = newMap.get(fileId);
        if (state) {
          newMap.set(fileId, {
            ...state,
            status: 'success',
            progress: 100
          });
        }
        return newMap;
      });
    } catch (error) {
      // Mark as error
      setUploadStates(prev => {
        const newMap = new Map(prev);
        const state = newMap.get(fileId);
        if (state) {
          newMap.set(fileId, {
            ...state,
            status: 'error',
            error: error instanceof Error ? error.message : 'Upload failed'
          });
        }
        return newMap;
      });
    }
  };

  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files;
    if (!files || files.length === 0) return;

    // Upload each file
    Array.from(files).forEach(file => {
      uploadFile(file);
    });

    // Reset input
    e.target.value = '';
  };

  return (
    <div>
      <h3>Upload Files with Progress</h3>
      
      <input
        type="file"
        multiple
        onChange={handleFileSelect}
      />

      {uploadStates.size > 0 && (
        <div style={{ marginTop: '2rem' }}>
          <h4>Uploads</h4>
          
          {Array.from(uploadStates.entries()).map(([fileId, state]) => (
            <div
              key={fileId}
              style={{
                border: '1px solid #ddd',
                borderRadius: '8px',
                padding: '1rem',
                marginBottom: '1rem'
              }}
            >
              <div style={{
                display: 'flex',
                justifyContent: 'space-between',
                alignItems: 'center',
                marginBottom: '0.5rem'
              }}>
                <span style={{ fontWeight: 500 }}>
                  {state.file.name}
                </span>
                <span style={{
                  fontSize: '0.875rem',
                  color: state.status === 'success' 
                    ? '#4CAF50' 
                    : state.status === 'error' 
                    ? '#f44336' 
                    : '#666'
                }}>
                  {state.status === 'uploading' && `${state.progress}%`}
                  {state.status === 'success' && 'βœ“ Complete'}
                  {state.status === 'error' && 'βœ— Failed'}
                </span>
              </div>

              {/* Progress Bar */}
              <div style={{
                width: '100%',
                height: '8px',
                backgroundColor: '#e0e0e0',
                borderRadius: '4px',
                overflow: 'hidden'
              }}>
                <div style={{
                  width: `${state.progress}%`,
                  height: '100%',
                  backgroundColor: state.status === 'error' 
                    ? '#f44336' 
                    : state.status === 'success'
                    ? '#4CAF50'
                    : '#667eea',
                  transition: 'width 0.3s ease'
                }} />
              </div>

              {state.error && (
                <p style={{ 
                  color: '#f44336', 
                  fontSize: '0.875rem',
                  marginTop: '0.5rem'
                }}>
                  {state.error}
                </p>
              )}
            </div>
          ))}
        </div>
      )}
    </div>
  );
};

export default FileUploadWithProgress;

βœ… Progress Bar Best Practices

  • Show Percentage: Display numeric progress (e.g., "45%") alongside the bar
  • Color Coding: Use colors to indicate status (blue for uploading, green for success, red for error)
  • File Name: Always show which file is being uploaded
  • Error Messages: Display specific error messages when uploads fail
  • Smooth Animation: Use CSS transitions for smooth progress bar updates
  • Multiple Files: Show individual progress for each file in multi-file uploads

Cancellable Uploads

Users should be able to cancel long-running uploads. Here's how to implement cancellation:

const uploadFileWithCancellation = (
  file: File,
  url: string,
  onProgress: (progress: UploadProgress) => void
): { promise: Promise<void>; cancel: () => void } => {
  const xhr = new XMLHttpRequest();
  const formData = new FormData();
  formData.append('file', file);

  const promise = new Promise<void>((resolve, reject) => {
    xhr.upload.addEventListener('progress', (e) => {
      if (e.lengthComputable) {
        const percentComplete = (e.loaded / e.total) * 100;
        onProgress({
          fileName: file.name,
          progress: Math.round(percentComplete),
          loaded: e.loaded,
          total: e.total
        });
      }
    });

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

    xhr.addEventListener('error', () => {
      reject(new Error('Upload failed due to network error'));
    });

    xhr.addEventListener('abort', () => {
      reject(new Error('Upload was cancelled'));
    });

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

  // Return both the promise and a cancel function
  return {
    promise,
    cancel: () => xhr.abort()
  };
};

// Usage with cancel button
const FileUploadWithCancel: React.FC = () => {
  const [uploadController, setUploadController] = useState<{
    cancel: () => void;
  } | null>(null);

  const handleUpload = async (file: File) => {
    const controller = uploadFileWithCancellation(
      file,
      '/api/upload',
      (progress) => {
        console.log(`Progress: ${progress.progress}%`);
      }
    );

    setUploadController(controller);

    try {
      await controller.promise;
      console.log('Upload complete!');
      setUploadController(null);
    } catch (error) {
      console.error('Upload failed or cancelled:', error);
      setUploadController(null);
    }
  };

  const handleCancel = () => {
    if (uploadController) {
      uploadController.cancel();
    }
  };

  return (
    <div>
      {uploadController && (
        <button onClick={handleCancel}>
          Cancel Upload
        </button>
      )}
    </div>
  );
};

πŸ’‘ Modern Alternative: Fetch with AbortController

The Fetch API doesn't support upload progress natively, but you can use AbortController for cancellation:

const controller = new AbortController();

fetch('/api/upload', {
  method: 'POST',
  body: formData,
  signal: controller.signal
});

// To cancel:
controller.abort();

However, for upload progress, XMLHttpRequest is still the best choice.

Simulated Upload for Testing

When developing, you might not have a backend ready. Here's a simulated upload function for testing:

const simulateUpload = (
  file: File,
  onProgress: (progress: UploadProgress) => void,
  durationMs: number = 3000
): Promise<void> => {
  return new Promise((resolve) => {
    const startTime = Date.now();
    const interval = 100; // Update every 100ms

    const timer = setInterval(() => {
      const elapsed = Date.now() - startTime;
      const progress = Math.min((elapsed / durationMs) * 100, 100);

      onProgress({
        fileName: file.name,
        progress: Math.round(progress),
        loaded: Math.round((file.size * progress) / 100),
        total: file.size
      });

      if (progress >= 100) {
        clearInterval(timer);
        resolve();
      }
    }, interval);
  });
};

πŸ“ Integration with React Hook Form

File uploads often need to be part of larger formsβ€”think profile updates with avatar uploads, or product listings with multiple images. Let's integrate our file upload component with React Hook Form for complete form management.

Basic File Input with React Hook Form

import { useForm } from 'react-hook-form';

interface FormData {
  name: string;
  email: string;
  avatar: FileList;
}

const ProfileForm: React.FC = () => {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>();

  const onSubmit = (data: FormData) => {
    console.log('Form data:', data);
    
    // Access the file
    if (data.avatar && data.avatar.length > 0) {
      const file = data.avatar[0];
      console.log('Avatar file:', file);
      
      // Here you would upload the file
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>Name:</label>
        <input {...register('name', { required: 'Name is required' })} />
        {errors.name && <span>{errors.name.message}</span>}
      </div>

      <div>
        <label>Email:</label>
        <input
          type="email"
          {...register('email', { required: 'Email is required' })}
        />
        {errors.email && <span>{errors.email.message}</span>}
      </div>

      <div>
        <label>Avatar:</label>
        <input
          type="file"
          accept="image/*"
          {...register('avatar', {
            required: 'Avatar is required',
            validate: {
              fileSize: (files: FileList) => {
                if (!files || files.length === 0) return true;
                const file = files[0];
                const maxSize = 5 * 1024 * 1024; // 5MB
                return file.size <= maxSize || 'File must be less than 5MB';
              },
              fileType: (files: FileList) => {
                if (!files || files.length === 0) return true;
                const file = files[0];
                const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
                return allowedTypes.includes(file.type) || 
                  'File must be JPEG, PNG, or WebP';
              }
            }
          })}
        />
        {errors.avatar && <span>{errors.avatar.message}</span>}
      </div>

      <button type="submit">Submit</button>
    </form>
  );
};

βœ… File Validation with React Hook Form

React Hook Form's validate option lets you add custom validation functions that receive the FileList. You can check file size, type, count, and even image dimensions (though that requires async validation).

Custom Controller for File Upload Component

If you have a custom file upload component (like our drag-drop component), use React Hook Form's Controller:

import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

// Define schema
const profileSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Invalid email address'),
  avatar: z
    .custom<File>((file) => file instanceof File, 'Avatar is required')
    .refine(
      (file) => file.size <= 5 * 1024 * 1024,
      'File must be less than 5MB'
    )
    .refine(
      (file) => ['image/jpeg', 'image/png', 'image/webp'].includes(file.type),
      'File must be JPEG, PNG, or WebP'
    )
});

type ProfileFormData = z.infer<typeof profileSchema>;

const ProfileFormWithCustomComponent: React.FC = () => {
  const {
    control,
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<ProfileFormData>({
    resolver: zodResolver(profileSchema)
  });

  const onSubmit = async (data: ProfileFormData) => {
    console.log('Submitting:', data);
    
    // Upload the file
    const formData = new FormData();
    formData.append('name', data.name);
    formData.append('email', data.email);
    formData.append('avatar', data.avatar);

    try {
      const response = await fetch('/api/profile', {
        method: 'POST',
        body: formData
      });
      
      if (response.ok) {
        alert('Profile updated successfully!');
      }
    } catch (error) {
      console.error('Upload failed:', error);
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>Name:</label>
        <input {...register('name')} />
        {errors.name && <span>{errors.name.message}</span>}
      </div>

      <div>
        <label>Email:</label>
        <input type="email" {...register('email')} />
        {errors.email && <span>{errors.email.message}</span>}
      </div>

      <div>
        <label>Avatar:</label>
        <Controller
          name="avatar"
          control={control}
          render={({ field: { onChange, value } }) => (
            <div>
              <input
                type="file"
                accept="image/*"
                onChange={(e) => {
                  const file = e.target.files?.[0];
                  if (file) {
                    onChange(file);
                  }
                }}
              />
              {value && (
                <div style={{ marginTop: '0.5rem' }}>
                  <p>Selected: {value.name}</p>
                  <img
                    src={URL.createObjectURL(value)}
                    alt="Preview"
                    style={{ maxWidth: '200px', maxHeight: '200px' }}
                  />
                </div>
              )}
            </div>
          )}
        />
        {errors.avatar && <span>{errors.avatar.message}</span>}
      </div>

      <button type="submit">Update Profile</button>
    </form>
  );
};

πŸ“– Controller Component

Controller: A React Hook Form component that wraps controlled components (like custom inputs) and connects them to the form state. It provides field props (value, onChange, onBlur) that you use to connect your custom component to the form.

Multiple File Upload with React Hook Form

import { z } from 'zod';

const gallerySchema = z.object({
  title: z.string().min(3, 'Title is required'),
  description: z.string().optional(),
  images: z
    .array(z.custom<File>((file) => file instanceof File))
    .min(1, 'At least one image is required')
    .max(10, 'Maximum 10 images allowed')
    .refine(
      (files) => files.every(file => file.size <= 5 * 1024 * 1024),
      'Each file must be less than 5MB'
    )
    .refine(
      (files) => files.every(file => 
        ['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
      ),
      'All files must be JPEG, PNG, or WebP'
    )
});

type GalleryFormData = z.infer<typeof gallerySchema>;

const GalleryForm: React.FC = () => {
  const {
    control,
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<GalleryFormData>({
    resolver: zodResolver(gallerySchema),
    defaultValues: {
      images: []
    }
  });

  const onSubmit = async (data: GalleryFormData) => {
    console.log('Gallery data:', data);

    // Create FormData for upload
    const formData = new FormData();
    formData.append('title', data.title);
    if (data.description) {
      formData.append('description', data.description);
    }
    
    // Append all images
    data.images.forEach((file, index) => {
      formData.append(`images[${index}]`, file);
    });

    // Upload to server
    try {
      const response = await fetch('/api/gallery', {
        method: 'POST',
        body: formData
      });
      
      if (response.ok) {
        alert('Gallery created successfully!');
      }
    } catch (error) {
      console.error('Upload failed:', error);
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>Gallery Title:</label>
        <input {...register('title')} />
        {errors.title && <span>{errors.title.message}</span>}
      </div>

      <div>
        <label>Description:</label>
        <textarea {...register('description')} />
      </div>

      <div>
        <label>Images:</label>
        <Controller
          name="images"
          control={control}
          render={({ field: { onChange, value } }) => (
            <div>
              <input
                type="file"
                multiple
                accept="image/*"
                onChange={(e) => {
                  const files = e.target.files;
                  if (files) {
                    onChange(Array.from(files));
                  }
                }}
              />
              {value && value.length > 0 && (
                <div style={{
                  display: 'grid',
                  gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
                  gap: '1rem',
                  marginTop: '1rem'
                }}>
                  {value.map((file, index) => (
                    <div key={index}>
                      <img
                        src={URL.createObjectURL(file)}
                        alt={`Preview ${index}`}
                        style={{
                          width: '100%',
                          height: '150px',
                          objectFit: 'cover',
                          borderRadius: '8px'
                        }}
                      />
                      <p style={{ fontSize: '0.75rem', marginTop: '0.25rem' }}>
                        {file.name}
                      </p>
                    </div>
                  ))}
                </div>
              )}
            </div>
          )}
        />
        {errors.images && <span>{errors.images.message}</span>}
      </div>

      <button type="submit">Create Gallery</button>
    </form>
  );
};

πŸ’‘ FormData Best Practices

  • Multiple Files: Use array notation like images[0], images[1] or just append multiple times with the same key
  • Mixed Content: FormData can contain both files and regular form fields
  • No JSON: Don't try to JSON.stringify FormDataβ€”send it as-is
  • Content-Type: Don't set Content-Type header manuallyβ€”browser sets it automatically with the correct boundary

πŸ”’ Best Practices and Security

File uploads can be a security risk if not handled properly. Let's cover essential best practices and security considerations for safe file handling.

Client-Side Security Considerations

1. Never Trust File Extensions or MIME Types

Malicious users can rename files or modify MIME types. Always validate on the server side:

// ❌ BAD: Only checking extension
const isImage = filename.endsWith('.jpg');

// ❌ BAD: Only checking MIME type
const isImage = file.type.startsWith('image/');

// βœ… BETTER: Check both (but still validate server-side!)
const isValidImage = (file: File): boolean => {
  const validTypes = ['image/jpeg', 'image/png', 'image/webp'];
  const validExtensions = ['.jpg', '.jpeg', '.png', '.webp'];
  
  const hasValidType = validTypes.includes(file.type);
  const hasValidExtension = validExtensions.some(ext => 
    file.name.toLowerCase().endsWith(ext)
  );
  
  return hasValidType && hasValidExtension;
};

2. Validate File Content (Magic Numbers)

For extra security, check file "magic numbers" (file signatures in the first bytes):

const checkFileSignature = async (file: File): Promise<boolean> => {
  // Read first few bytes
  const buffer = await file.slice(0, 4).arrayBuffer();
  const bytes = new Uint8Array(buffer);
  
  // Check magic numbers for common image formats
  // JPEG: FF D8 FF
  if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) {
    return true;
  }
  
  // PNG: 89 50 4E 47
  if (bytes[0] === 0x89 && bytes[1] === 0x50 && 
      bytes[2] === 0x4E && bytes[3] === 0x47) {
    return true;
  }
  
  // WebP: 52 49 46 46
  if (bytes[0] === 0x52 && bytes[1] === 0x49 && 
      bytes[2] === 0x46 && bytes[3] === 0x46) {
    return true;
  }
  
  return false;
};

3. Limit File Sizes

// Set reasonable limits based on your use case
const MAX_FILE_SIZE = {
  avatar: 2 * 1024 * 1024,      // 2MB for avatars
  document: 10 * 1024 * 1024,   // 10MB for documents
  video: 100 * 1024 * 1024      // 100MB for videos
};

4. Sanitize File Names

const sanitizeFileName = (filename: string): string => {
  return filename
    .toLowerCase()
    .replace(/[^a-z0-9.-]/g, '_')  // Replace invalid chars with underscore
    .replace(/_{2,}/g, '_')        // Replace multiple underscores with one
    .replace(/^_|_$/g, '');        // Remove leading/trailing underscores
};

// Usage
const safeFilename = sanitizeFileName(file.name);
// "My Photo (1).jpg" β†’ "my_photo_1.jpg"

⚠️ Critical Server-Side Security

Remember: All client-side validation can be bypassed! Always validate files on the server:

  • Re-validate file type, size, and content
  • Scan for malware/viruses
  • Store files outside the web root or serve through a CDN
  • Use random file names (don't trust user-provided names)
  • Set proper Content-Type headers when serving files
  • Implement rate limiting to prevent abuse
  • Use signed URLs for sensitive files

Performance Best Practices

1. Use Object URLs for Previews

// βœ… GOOD: Fast and memory-efficient
const url = URL.createObjectURL(file);
// Remember to revoke when done!
URL.revokeObjectURL(url);

2. Compress Images Before Upload

Reduce file sizes client-side to save bandwidth and storage:

const compressImage = (
  file: File,
  maxWidth: number = 1920,
  maxHeight: number = 1080,
  quality: number = 0.8
): Promise<Blob> => {
  return new Promise((resolve, reject) => {
    const img = new Image();
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');

    img.onload = () => {
      let { width, height } = img;

      // Calculate new dimensions
      if (width > maxWidth) {
        height = (height * maxWidth) / width;
        width = maxWidth;
      }
      if (height > maxHeight) {
        width = (width * maxHeight) / height;
        height = maxHeight;
      }

      canvas.width = width;
      canvas.height = height;

      // Draw and compress
      ctx?.drawImage(img, 0, 0, width, height);
      
      canvas.toBlob(
        (blob) => {
          if (blob) {
            resolve(blob);
          } else {
            reject(new Error('Compression failed'));
          }
        },
        'image/jpeg',
        quality
      );

      URL.revokeObjectURL(img.src);
    };

    img.onerror = reject;
    img.src = URL.createObjectURL(file);
  });
};

3. Lazy Load Preview Images

// Use loading="lazy" for preview images
<img 
  src={preview} 
  alt="Preview" 
  loading="lazy"
  style={{ maxWidth: '200px' }}
/>

4. Debounce Validation

If validating dimensions or other async checks, debounce to avoid excessive processing:

import { useState, useEffect } from 'react';

const useDebounce = <T,>(value: T, delay: number): T => {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debouncedValue;
};

UX Best Practices

βœ… Good User Experience

  • Clear Instructions: Tell users what files are accepted and size limits
  • Immediate Feedback: Show errors right away, not after form submission
  • Progress Indication: Always show progress for uploads that take >2 seconds
  • Cancellation: Allow users to cancel long uploads
  • Error Recovery: Let users retry failed uploads without reselecting files
  • Multiple Methods: Support both click and drag-drop
  • Mobile-Friendly: Test on mobile devices, ensure touch targets are large enough
  • Accessibility: Use proper labels, ARIA attributes, keyboard navigation

Accessibility Checklist

<!-- βœ… Good: Accessible file upload -->
<div role="region" aria-label="File upload">
  <label htmlFor="file-upload" id="file-label">
    Upload your document
    <span class="hint">(Max 5MB, PDF or Word)</span>
  </label>
  
  <input
    id="file-upload"
    type="file"
    accept=".pdf,.doc,.docx"
    aria-describedby="file-hint file-error"
  />
  
  <p id="file-hint" class="hint">
    Choose a file or drag and drop it here
  </p>
  
  <div id="file-error" role="alert" aria-live="polite">
    {/* Error messages appear here */}
  </div>
</div>

πŸ’‘ Testing Your File Upload

Always test with:

  • Various file sizes (tiny, normal, huge)
  • Different file types (valid and invalid)
  • Multiple files at once
  • Slow network conditions
  • Mobile devices and touch interactions
  • Screen readers and keyboard-only navigation
  • Edge cases (empty files, corrupted files, same file twice)

πŸ‹οΈ Hands-On Exercises

Now it's time to practice! Work through these exercises to solidify your understanding of file uploads in React.

πŸ‹οΈ Exercise 1: Profile Picture Upload

Objective: Create a profile picture upload component with preview, validation, and crop suggestion.

Requirements:

  1. Single image upload (click or drag-drop)
  2. Validate: only JPEG/PNG, max 5MB, minimum 200x200px
  3. Show preview in a circular frame (like a profile picture)
  4. Display file name and size
  5. Clear/remove button
  6. Show error messages for invalid files

Bonus Challenges:

  • Add a "crop to square" suggestion if image isn't square
  • Implement basic image compression before upload
  • Add a "take photo" option for mobile devices
πŸ’‘ Hint

Use URL.createObjectURL() for the preview and validateImageDimensions() function from earlier for dimension checking. For the circular frame, use CSS: border-radius: 50%; overflow: hidden;

βœ… Solution Approach
  1. Create state for file, preview URL, errors, and loading
  2. Implement file selection handler with validation
  3. Check dimensions using Image element
  4. Create object URL for preview
  5. Display preview in circular container
  6. Implement clear function that revokes URL
  7. Clean up URL in useEffect cleanup

πŸ‹οΈ Exercise 2: Multi-Image Gallery Upload

Objective: Build a component for uploading multiple images for a photo gallery.

Requirements:

  1. Support multiple image selection (up to 10 images)
  2. Drag and drop support
  3. Grid preview of all selected images
  4. Individual remove button for each image
  5. Add more images without replacing existing ones
  6. Validate each image (type, size)
  7. Show count (e.g., "5/10 images selected")

Bonus Challenges:

  • Allow reordering images by drag-drop
  • Add "set as cover image" functionality
  • Implement batch actions (clear all, remove invalid)
  • Show total size of all images
πŸ’‘ Hint

Use an array of objects with structure { file: File, preview: string, id: string }. For adding more files, use setFiles(prev => [...prev, ...newFiles]) instead of replacing. Remember to revoke all URLs on unmount!

πŸ‹οΈ Exercise 3: Document Upload Form

Objective: Create a complete form with document upload using React Hook Form and Zod.

Requirements:

  1. Form fields: title, description, category (dropdown)
  2. File upload field for PDF documents
  3. Validate: PDF only, max 10MB, required
  4. Show document icon preview (since PDFs can't be image-previewed easily)
  5. Integrate with React Hook Form using Controller
  6. Use Zod for validation schema
  7. Display validation errors properly
  8. On submit, create FormData and log it

Bonus Challenges:

  • Support multiple file formats (PDF, DOC, DOCX)
  • Show number of pages in PDF (use pdf.js library)
  • Implement upload progress simulation
πŸ’‘ Hint

Use z.custom<File>() for file validation in Zod. For the document icon, you can use an emoji or SVG icon since PDFs can't be previewed as images directly without additional libraries.

πŸ‹οΈ Exercise 4: Upload with Progress and Cancellation

Objective: Implement a file upload component with real-time progress tracking and ability to cancel.

Requirements:

  1. File selection (any type, max 50MB for testing)
  2. Start upload button
  3. Progress bar showing percentage
  4. Display upload speed and time remaining estimates
  5. Cancel button that actually stops the upload
  6. Success message with uploaded file details
  7. Error handling with retry option
  8. Use simulated upload function (provided earlier)

Bonus Challenges:

  • Implement chunked upload (split file into chunks)
  • Add resume capability for failed uploads
  • Queue multiple files and upload sequentially
πŸ’‘ Hint

Use the simulateUpload or uploadFileWithProgress functions from earlier. Store upload state including progress percentage, loaded bytes, and total bytes. Calculate speed as bytes/second and estimate remaining time.

🎯 Challenge Project

Build a Complete Image Upload System

Combine everything you've learned to create a production-ready image upload component with:

  • Drag-and-drop interface
  • Multiple image support
  • Individual upload progress for each image
  • Image previews with zoom
  • Validation (type, size, dimensions)
  • Integration with React Hook Form
  • Mobile-responsive design
  • Accessibility features

This will be a portfolio-worthy component!

πŸ“š Summary and Next Steps

πŸŽ‰ Key Takeaways

  • File Input Basics: Understanding the HTML file input, FileList, and File objects
  • File Previews: Two approaches - FileReader (data URLs) and Object URLs (blob URLs)
  • Validation: Client-side validation for file type, size, and image dimensions
  • Multiple Files: Handling multiple file selection and managing arrays of files
  • Drag and Drop: Implementing intuitive drag-drop interfaces with proper event handling
  • Upload Progress: Tracking and displaying upload progress with XMLHttpRequest
  • Form Integration: Using file uploads with React Hook Form and Zod validation
  • Security: Understanding security risks and implementing proper validation
  • Performance: Optimizing with Object URLs, compression, and lazy loading
  • UX Best Practices: Creating accessible, user-friendly upload experiences

πŸ“– Additional Resources

Official Documentation

Libraries and Tools

  • react-dropzone: Popular drag-drop file upload library
  • react-image-crop: Image cropping component
  • compressor.js: Client-side image compression
  • pdf.js: PDF viewing and manipulation
  • uppy: Complete file upload solution with many features

Advanced Topics to Explore

  • Chunked Uploads: Splitting large files for reliable uploads
  • Resumable Uploads: Continuing interrupted uploads
  • Image Manipulation: Cropping, rotating, filters before upload
  • WebRTC: Camera/video capture directly in the browser
  • Service Workers: Background uploads that survive page refresh
  • Direct-to-S3 Upload: Uploading directly to cloud storage

πŸš€ What's Next?

Now that you've mastered file uploads, you can:

  • Build Real Applications: Add file upload features to your projects
  • Explore Advanced Patterns: Learn about multi-step forms and wizards
  • Backend Integration: Connect your uploads to real server endpoints
  • Cloud Storage: Learn to upload directly to AWS S3, Cloudinary, or Firebase
  • Image Processing: Explore client-side image manipulation libraries

In the next lesson, we'll explore Advanced Form Patterns including multi-step forms, conditional fields, and form wizards!

πŸŽ‰ Congratulations!

You've completed the File Uploads lesson!

You now have the skills to build professional file upload experiences in React with TypeScript. From basic file inputs to complete drag-and-drop systems with validation and progress tracking, you're ready to handle any file upload requirement in your applications!

Keep practicing and building! πŸš€