File Upload Hook with Preview: Complete Implementation (2026)

Last updated:
File Upload Hook with Preview: Complete Implementation (2026)

Build production-ready file upload with preview in React. Master drag-and-drop, validation, progress tracking, and multi-file handling with complete TypeScript examples.

# File Upload Hook with Preview: Complete Implementation (2026)

File uploads seem simple until you actually build them. You need preview images, drag-and-drop support, validation, error handling, and progress tracking. Copy-paste this logic across components a few times and you'll wish you'd extracted it into a custom hook from the start.

This guide shows you how to build a production-ready useFileUpload hook that handles everything: single and multiple files, previews for images/videos/PDFs, drag-and-drop, size/type validation, and upload progress. No third-party libraries—just React, TypeScript, and the browser's File API.

# Table of Contents

  1. Why Build a Custom File Upload Hook?
  2. Basic Implementation: Single File Upload
  3. Adding File Preview with FileReader
  4. Multi-File Upload Support
  5. File Validation: Type and Size
  6. Drag-and-Drop Interface
  7. Upload Progress Tracking
  8. Memory Management and Cleanup
  9. TypeScript Type Safety
  10. Complete Hook Implementation
  11. Practical Example: Avatar Upload Component
  12. FAQ

# Why Build a Custom File Upload Hook?

Consider what happens when you implement file uploads in multiple components:

typescript
// Component 1: Profile avatar upload
function ProfileAvatar() {
  const [file, setFile] = useState(null);
  const [preview, setPreview] = useState('');
  const [error, setError] = useState('');
  
  // 50 lines of file handling logic...
}

// Component 2: Document upload
function DocumentUpload() {
  const [file, setFile] = useState(null);
  const [preview, setPreview] = useState('');
  const [error, setError] = useState('');
  
  // Same 50 lines duplicated...
}

Problems with this approach:

  • Code duplication across components
  • Inconsistent validation rules
  • Hard to maintain preview generation logic
  • Memory leaks from unreleased object URLs
  • No centralized error handling

A custom hook solves all of this by extracting the logic into a reusable function.

# Basic Implementation: Single File Upload

Let's start with the simplest version—accepting a single file:

# TypeScript Version

typescript
import { useState, ChangeEvent } from 'react';

interface UseFileUploadResult {
  file: File | null;
  handleFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
  clearFile: () => void;
}

function useFileUpload(): UseFileUploadResult {
  const [file, setFile] = useState<File | null>(null);

  const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
    const selectedFile = event.target.files?.[0];
    if (selectedFile) {
      setFile(selectedFile);
    }
  };

  const clearFile = () => {
    setFile(null);
  };

  return {
    file,
    handleFileChange,
    clearFile,
  };
}

// Usage in component
function SimpleUpload() {
  const { file, handleFileChange, clearFile } = useFileUpload();

  return (
    <div>
      <input type="file" onChange={handleFileChange} />
      {file && (
        <div>
          <p>Selected: {file.name}</p>
          <button onClick={clearFile}>Remove</button>
        </div>
      )}
    </div>
  );
}

# JavaScript Version

javascript
import { useState } from 'react';

function useFileUpload() {
  const [file, setFile] = useState(null);

  const handleFileChange = (event) => {
    const selectedFile = event.target.files?.[0];
    if (selectedFile) {
      setFile(selectedFile);
    }
  };

  const clearFile = () => {
    setFile(null);
  };

  return {
    file,
    handleFileChange,
    clearFile,
  };
}

// Usage in component
function SimpleUpload() {
  const { file, handleFileChange, clearFile } = useFileUpload();

  return (
    <div>
      <input type="file" onChange={handleFileChange} />
      {file && (
        <div>
          <p>Selected: {file.name}</p>
          <button onClick={clearFile}>Remove</button>
        </div>
      )}
    </div>
  );
}

Key points:

  • event.target.files returns a FileList (array-like object)
  • files?.[0] gets the first file (optional chaining handles null input)
  • File object contains: name, size, type, lastModified

# Adding File Preview with FileReader

The real power comes from generating preview URLs for uploaded files:

# TypeScript Version

typescript
import { useState, useEffect, ChangeEvent } from 'react';

interface UseFileUploadResult {
  file: File | null;
  preview: string | null;
  handleFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
  clearFile: () => void;
}

function useFileUpload(): UseFileUploadResult {
  const [file, setFile] = useState<File | null>(null);
  const [preview, setPreview] = useState<string | null>(null);

  useEffect(() => {
    // No file selected, clear preview
    if (!file) {
      setPreview(null);
      return;
    }

    // Create object URL for preview
    // Works for images, videos, PDFs
    const objectUrl = URL.createObjectURL(file);
    setPreview(objectUrl);

    // Cleanup function: release object URL to prevent memory leak
    return () => {
      URL.revokeObjectURL(objectUrl);
    };
  }, [file]);

  const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
    const selectedFile = event.target.files?.[0];
    if (selectedFile) {
      setFile(selectedFile);
    }
  };

  const clearFile = () => {
    setFile(null);
  };

  return {
    file,
    preview,
    handleFileChange,
    clearFile,
  };
}

// Usage with image preview
function ImageUpload() {
  const { file, preview, handleFileChange, clearFile } = useFileUpload();

  return (
    <div>
      <input type="file" accept="image/*" onChange={handleFileChange} />
      
      {preview && (
        <div>
          <img 
            src={preview} 
            alt="Preview" 
            style={{ maxWidth: '300px', maxHeight: '300px' }}
          />
          <button onClick={clearFile}>Remove</button>
        </div>
      )}
    </div>
  );
}

# JavaScript Version

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

function useFileUpload() {
  const [file, setFile] = useState(null);
  const [preview, setPreview] = useState(null);

  useEffect(() => {
    if (!file) {
      setPreview(null);
      return;
    }

    const objectUrl = URL.createObjectURL(file);
    setPreview(objectUrl);

    return () => {
      URL.revokeObjectURL(objectUrl);
    };
  }, [file]);

  const handleFileChange = (event) => {
    const selectedFile = event.target.files?.[0];
    if (selectedFile) {
      setFile(selectedFile);
    }
  };

  const clearFile = () => {
    setFile(null);
  };

  return {
    file,
    preview,
    handleFileChange,
    clearFile,
  };
}

// Usage with image preview
function ImageUpload() {
  const { file, preview, handleFileChange, clearFile } = useFileUpload();

  return (
    <div>
      <input type="file" accept="image/*" onChange={handleFileChange} />
      
      {preview && (
        <div>
          <img 
            src={preview} 
            alt="Preview" 
            style={{ maxWidth: '300px', maxHeight: '300px' }}
          />
          <button onClick={clearFile}>Remove</button>
        </div>
      )}
    </div>
  );
}

Why URL.createObjectURL instead of FileReader?

  • URL.createObjectURL: Instant, returns string URL, works for images/videos/PDFs
  • FileReader.readAsDataURL: Async, returns base64 string (larger), only needed for data URIs

Critical: Always revoke object URLs

  • Object URLs persist in memory until manually revoked
  • Cleanup function in useEffect prevents memory leaks
  • Called when file changes or component unmounts

# Multi-File Upload Support

Real apps often need multiple file uploads (photo galleries, document batches):

# TypeScript Version

typescript
import { useState, useEffect, ChangeEvent } from 'react';

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

interface UseMultiFileUploadResult {
  files: FileWithPreview[];
  handleFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
  removeFile: (id: string) => void;
  clearAllFiles: () => void;
}

function useMultiFileUpload(): UseMultiFileUploadResult {
  const [files, setFiles] = useState<FileWithPreview[]>([]);

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

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

    // Convert FileList to array and create previews
    const newFiles = Array.from(selectedFiles).map((file) => ({
      file,
      preview: URL.createObjectURL(file),
      id: `${file.name}-${Date.now()}-${Math.random()}`, // Unique ID
    }));

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

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

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

  return {
    files,
    handleFileChange,
    removeFile,
    clearAllFiles,
  };
}

// Usage
function MultiImageUpload() {
  const { files, handleFileChange, removeFile, clearAllFiles } = useMultiFileUpload();

  return (
    <div>
      <input 
        type="file" 
        accept="image/*" 
        multiple 
        onChange={handleFileChange} 
      />
      
      {files.length > 0 && (
        <button onClick={clearAllFiles}>Clear All</button>
      )}

      <div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
        {files.map(({ id, preview, file }) => (
          <div key={id} style={{ position: 'relative' }}>
            <img 
              src={preview} 
              alt={file.name}
              style={{ width: '150px', height: '150px', objectFit: 'cover' }}
            />
            <button 
              onClick={() => removeFile(id)}
              style={{ position: 'absolute', top: '5px', right: '5px' }}
            >
              ×
            </button>
          </div>
        ))}
      </div>
    </div>
  );
}

# JavaScript Version

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

function useMultiFileUpload() {
  const [files, setFiles] = useState([]);

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

  const handleFileChange = (event) => {
    const selectedFiles = event.target.files;
    if (!selectedFiles || selectedFiles.length === 0) return;

    const newFiles = Array.from(selectedFiles).map((file) => ({
      file,
      preview: URL.createObjectURL(file),
      id: `${file.name}-${Date.now()}-${Math.random()}`,
    }));

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

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

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

  return {
    files,
    handleFileChange,
    removeFile,
    clearAllFiles,
  };
}

Key patterns:

  • Array.from() converts FileList to real array
  • Each file gets unique ID for React keys and removal
  • Object URLs revoked individually when files removed
  • Cleanup effect revokes all URLs on unmount

# File Validation: Type and Size

Production apps must validate files before accepting them:

# TypeScript Version

typescript
import { useState, useEffect, ChangeEvent } from 'react';

interface ValidationOptions {
  maxSizeMB?: number;
  allowedTypes?: string[];
}

interface UseFileUploadOptions extends ValidationOptions {
  multiple?: boolean;
}

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

interface UseFileUploadResult {
  files: FileWithPreview[];
  error: string | null;
  handleFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
  removeFile: (id: string) => void;
  clearAllFiles: () => void;
}

function useFileUpload(options: UseFileUploadOptions = {}): UseFileUploadResult {
  const { 
    maxSizeMB = 5, 
    allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
    multiple = false 
  } = options;

  const [files, setFiles] = useState<FileWithPreview[]>([]);
  const [error, setError] = useState<string | null>(null);

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

  const validateFile = (file: File): string | null => {
    // Check file type
    if (!allowedTypes.includes(file.type)) {
      const allowedExtensions = allowedTypes
        .map((type) => type.split('/')[1])
        .join(', ');
      return `Invalid file type. Allowed: ${allowedExtensions}`;
    }

    // Check file size
    const maxSizeBytes = maxSizeMB * 1024 * 1024;
    if (file.size > maxSizeBytes) {
      return `File too large. Maximum size: ${maxSizeMB}MB`;
    }

    return null;
  };

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

    setError(null);

    const filesToProcess = Array.from(selectedFiles);

    // Validate all files first
    for (const file of filesToProcess) {
      const validationError = validateFile(file);
      if (validationError) {
        setError(validationError);
        return;
      }
    }

    // All files valid, create previews
    const newFiles = filesToProcess.map((file) => ({
      file,
      preview: URL.createObjectURL(file),
      id: `${file.name}-${Date.now()}-${Math.random()}`,
    }));

    if (multiple) {
      setFiles((prev) => [...prev, ...newFiles]);
    } else {
      // Single file mode: revoke old preview first
      files.forEach(({ preview }) => URL.revokeObjectURL(preview));
      setFiles(newFiles);
    }
  };

  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 clearAllFiles = () => {
    files.forEach(({ preview }) => URL.revokeObjectURL(preview));
    setFiles([]);
    setError(null);
  };

  return {
    files,
    error,
    handleFileChange,
    removeFile,
    clearAllFiles,
  };
}

// Usage with validation
function ValidatedUpload() {
  const { files, error, handleFileChange, clearAllFiles } = useFileUpload({
    maxSizeMB: 2,
    allowedTypes: ['image/jpeg', 'image/png'],
    multiple: true,
  });

  return (
    <div>
      <input type="file" accept="image/*" multiple onChange={handleFileChange} />
      
      {error && <div style={{ color: 'red' }}>{error}</div>}
      
      {files.length > 0 && (
        <>
          <button onClick={clearAllFiles}>Clear All</button>
          <div style={{ display: 'flex', gap: '10px' }}>
            {files.map(({ id, preview }) => (
              <img key={id} src={preview} alt="" style={{ width: '100px' }} />
            ))}
          </div>
        </>
      )}
    </div>
  );
}

# JavaScript Version

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

function useFileUpload(options = {}) {
  const { 
    maxSizeMB = 5, 
    allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
    multiple = false 
  } = options;

  const [files, setFiles] = useState([]);
  const [error, setError] = useState(null);

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

  const validateFile = (file) => {
    if (!allowedTypes.includes(file.type)) {
      const allowedExtensions = allowedTypes
        .map((type) => type.split('/')[1])
        .join(', ');
      return `Invalid file type. Allowed: ${allowedExtensions}`;
    }

    const maxSizeBytes = maxSizeMB * 1024 * 1024;
    if (file.size > maxSizeBytes) {
      return `File too large. Maximum size: ${maxSizeMB}MB`;
    }

    return null;
  };

  const handleFileChange = (event) => {
    const selectedFiles = event.target.files;
    if (!selectedFiles || selectedFiles.length === 0) return;

    setError(null);

    const filesToProcess = Array.from(selectedFiles);

    for (const file of filesToProcess) {
      const validationError = validateFile(file);
      if (validationError) {
        setError(validationError);
        return;
      }
    }

    const newFiles = filesToProcess.map((file) => ({
      file,
      preview: URL.createObjectURL(file),
      id: `${file.name}-${Date.now()}-${Math.random()}`,
    }));

    if (multiple) {
      setFiles((prev) => [...prev, ...newFiles]);
    } else {
      files.forEach(({ preview }) => URL.revokeObjectURL(preview));
      setFiles(newFiles);
    }
  };

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

  const clearAllFiles = () => {
    files.forEach(({ preview }) => URL.revokeObjectURL(preview));
    setFiles([]);
    setError(null);
  };

  return {
    files,
    error,
    handleFileChange,
    removeFile,
    clearAllFiles,
  };
}

Validation strategies:

  • Type checking: Compare file.type against allowed MIME types
  • Size checking: Convert MB to bytes (1MB = 1024 × 1024 bytes)
  • Validate before creating previews (no wasted memory on invalid files)
  • Clear, user-friendly error messages

# Drag-and-Drop Interface

Modern file uploads need drag-and-drop support:

# TypeScript Version

typescript
import { useState, useEffect, DragEvent, ChangeEvent } from 'react';

interface UseDragDropResult {
  isDragging: boolean;
  handleDragEnter: (e: DragEvent<HTMLDivElement>) => void;
  handleDragLeave: (e: DragEvent<HTMLDivElement>) => void;
  handleDragOver: (e: DragEvent<HTMLDivElement>) => void;
  handleDrop: (e: DragEvent<HTMLDivElement>) => void;
}

function useFileUploadWithDragDrop(
  onFilesSelected: (files: File[]) => void
): UseDragDropResult {
  const [isDragging, setIsDragging] = useState(false);

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

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

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

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

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

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

// Complete upload component with drag-drop
function DragDropUpload() {
  const { files, error, handleFileChange, removeFile } = useFileUpload({
    multiple: true,
    maxSizeMB: 10,
  });

  const processFiles = (selectedFiles: File[]) => {
    // Create synthetic event to reuse handleFileChange
    const mockEvent = {
      target: { files: selectedFiles },
    } as unknown as ChangeEvent<HTMLInputElement>;
    
    handleFileChange(mockEvent);
  };

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

  return (
    <div>
      <div
        onDragEnter={handleDragEnter}
        onDragLeave={handleDragLeave}
        onDragOver={handleDragOver}
        onDrop={handleDrop}
        style={{
          border: `2px dashed ${isDragging ? '#007bff' : '#ccc'}`,
          borderRadius: '8px',
          padding: '40px',
          textAlign: 'center',
          backgroundColor: isDragging ? '#f0f8ff' : 'transparent',
          cursor: 'pointer',
          transition: 'all 0.3s ease',
        }}
      >
        <input
          type="file"
          multiple
          accept="image/*"
          onChange={handleFileChange}
          style={{ display: 'none' }}
          id="file-input"
        />
        <label htmlFor="file-input" style={{ cursor: 'pointer' }}>
          {isDragging ? (
            <p>Drop files here...</p>
          ) : (
            <>
              <p>Drag and drop files here</p>
              <p>or click to select</p>
            </>
          )}
        </label>
      </div>

      {error && <div style={{ color: 'red', marginTop: '10px' }}>{error}</div>}

      {files.length > 0 && (
        <div style={{ marginTop: '20px', display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
          {files.map(({ id, preview, file }) => (
            <div key={id} style={{ position: 'relative' }}>
              <img 
                src={preview} 
                alt={file.name}
                style={{ width: '150px', height: '150px', objectFit: 'cover' }}
              />
              <button
                onClick={() => removeFile(id)}
                style={{
                  position: 'absolute',
                  top: '5px',
                  right: '5px',
                  background: 'red',
                  color: 'white',
                  border: 'none',
                  borderRadius: '50%',
                  width: '25px',
                  height: '25px',
                  cursor: 'pointer',
                }}
              >
                ×
              </button>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

# JavaScript Version

javascript
import { useState } from 'react';

function useFileUploadWithDragDrop(onFilesSelected) {
  const [isDragging, setIsDragging] = useState(false);

  const handleDragEnter = (e) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragging(true);
  };

  const handleDragLeave = (e) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragging(false);
  };

  const handleDragOver = (e) => {
    e.preventDefault();
    e.stopPropagation();
  };

  const handleDrop = (e) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragging(false);

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

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

Drag-and-drop essentials:

  • Always call preventDefault() and stopPropagation() on drag events
  • dragOver must be handled (even if empty) to enable dropping
  • e.dataTransfer.files contains dropped files
  • Visual feedback (isDragging state) improves UX

# Upload Progress Tracking

For large files or slow connections, show upload progress:

# TypeScript Version

typescript
import { useState } from 'react';

interface UploadProgress {
  [fileId: string]: number; // 0-100
}

interface UseFileUploadResult {
  files: FileWithPreview[];
  uploadProgress: UploadProgress;
  uploadFile: (fileId: string) => Promise<void>;
}

function useFileUploadWithProgress(): UseFileUploadResult {
  const [files, setFiles] = useState<FileWithPreview[]>([]);
  const [uploadProgress, setUploadProgress] = useState<UploadProgress>({});

  const uploadFile = async (fileId: string) => {
    const fileData = files.find((f) => f.id === fileId);
    if (!fileData) return;

    const formData = new FormData();
    formData.append('file', fileData.file);

    try {
      await new Promise<void>((resolve, reject) => {
        const xhr = new XMLHttpRequest();

        // Track upload progress
        xhr.upload.addEventListener('progress', (e) => {
          if (e.lengthComputable) {
            const percentComplete = (e.loaded / e.total) * 100;
            setUploadProgress((prev) => ({
              ...prev,
              [fileId]: Math.round(percentComplete),
            }));
          }
        });

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

        xhr.addEventListener('error', () => {
          reject(new Error('Network error'));
        });

        xhr.open('POST', '/api/upload');
        xhr.send(formData);
      });

      console.log(`File ${fileData.file.name} uploaded successfully`);
    } catch (error) {
      console.error('Upload error:', error);
      setUploadProgress((prev) => ({
        ...prev,
        [fileId]: 0,
      }));
    }
  };

  return {
    files,
    uploadProgress,
    uploadFile,
  };
}

// Usage
function UploadWithProgress() {
  const { files, uploadProgress, uploadFile } = useFileUploadWithProgress();

  return (
    <div>
      {files.map(({ id, file, preview }) => (
        <div key={id} style={{ marginBottom: '20px' }}>
          <img src={preview} alt={file.name} style={{ width: '100px' }} />
          <p>{file.name}</p>
          
          {uploadProgress[id] !== undefined && (
            <div>
              <progress value={uploadProgress[id]} max="100" />
              <span>{uploadProgress[id]}%</span>
            </div>
          )}
          
          <button onClick={() => uploadFile(id)}>Upload</button>
        </div>
      ))}
    </div>
  );
}

# JavaScript Version

javascript
import { useState } from 'react';

function useFileUploadWithProgress() {
  const [files, setFiles] = useState([]);
  const [uploadProgress, setUploadProgress] = useState({});

  const uploadFile = async (fileId) => {
    const fileData = files.find((f) => f.id === fileId);
    if (!fileData) return;

    const formData = new FormData();
    formData.append('file', fileData.file);

    try {
      await new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();

        xhr.upload.addEventListener('progress', (e) => {
          if (e.lengthComputable) {
            const percentComplete = (e.loaded / e.total) * 100;
            setUploadProgress((prev) => ({
              ...prev,
              [fileId]: Math.round(percentComplete),
            }));
          }
        });

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

        xhr.addEventListener('error', () => {
          reject(new Error('Network error'));
        });

        xhr.open('POST', '/api/upload');
        xhr.send(formData);
      });

      console.log(`File ${fileData.file.name} uploaded successfully`);
    } catch (error) {
      console.error('Upload error:', error);
      setUploadProgress((prev) => ({
        ...prev,
        [fileId]: 0,
      }));
    }
  };

  return {
    files,
    uploadProgress,
    uploadFile,
  };
}

Why XMLHttpRequest over fetch?

  • fetch() doesn't support upload progress events yet
  • XMLHttpRequest.upload.onprogress provides loaded and total bytes
  • For downloads, use fetch() with response.body.getReader()`

# Memory Management and Cleanup

File uploads create memory that must be manually released:

typescript
useEffect(() => {
  // Cleanup function runs on:
  // 1. Component unmount
  // 2. When files array changes (before next effect)
  return () => {
    files.forEach(({ preview }) => {
      URL.revokeObjectURL(preview);
    });
  };
}, [files]);

Memory leak scenarios:

  1. Creating object URLs without revoking them
  2. Navigating away before cleanup runs
  3. Removing files from state without revoking URLs

Best practices:

  • Revoke URLs immediately when files removed individually
  • Global cleanup in useEffect for component unmount
  • Test by uploading many files and checking memory in DevTools

# TypeScript Type Safety

Proper typing prevents runtime errors:

# TypeScript Version

typescript
import { ChangeEvent } from 'react';

// File metadata with preview
interface FileWithPreview {
  file: File;
  preview: string;
  id: string;
  uploadedAt: Date;
}

// Validation configuration
interface ValidationConfig {
  maxSizeMB: number;
  allowedTypes: string[];
  maxFiles?: number;
}

// Hook options
interface UseFileUploadOptions {
  validation?: Partial<ValidationConfig>;
  multiple?: boolean;
  onUploadSuccess?: (fileId: string) => void;
  onUploadError?: (fileId: string, error: Error) => void;
}

// Return type
interface UseFileUploadReturn {
  files: FileWithPreview[];
  error: string | null;
  isUploading: boolean;
  handleFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
  uploadFile: (fileId: string) => Promise<void>;
  removeFile: (id: string) => void;
  clearAllFiles: () => void;
}

// Type guard for file validation
function isValidFileType(file: File, allowedTypes: string[]): boolean {
  return allowedTypes.includes(file.type);
}

function useFileUpload(options: UseFileUploadOptions = {}): UseFileUploadReturn {
  // Implementation...
  
  return {
    files: [],
    error: null,
    isUploading: false,
    handleFileChange: () => {},
    uploadFile: async () => {},
    removeFile: () => {},
    clearAllFiles: () => {},
  };
}

Type safety benefits:

  • Autocomplete for options and return values
  • Catch errors at compile time
  • Self-documenting API
  • Easier refactoring

# Complete Hook Implementation

Here's the production-ready version combining all features:

# TypeScript Version

typescript
import { useState, useEffect, useCallback, ChangeEvent } from 'react';

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

interface ValidationOptions {
  maxSizeMB?: number;
  allowedTypes?: string[];
  maxFiles?: number;
}

interface UseFileUploadOptions {
  multiple?: boolean;
  validation?: ValidationOptions;
}

interface UseFileUploadReturn {
  files: FileWithPreview[];
  error: string | null;
  handleFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
  removeFile: (id: string) => void;
  clearAllFiles: () => void;
}

function useFileUpload(options: UseFileUploadOptions = {}): UseFileUploadReturn {
  const {
    multiple = false,
    validation = {},
  } = options;

  const {
    maxSizeMB = 5,
    allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
    maxFiles = 10,
  } = validation;

  const [files, setFiles] = useState<FileWithPreview[]>([]);
  const [error, setError] = useState<string | null>(null);

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

  const validateFile = useCallback((file: File): string | null => {
    // Type validation
    if (!allowedTypes.includes(file.type)) {
      const extensions = allowedTypes.map(t => t.split('/')[1]).join(', ');
      return `Invalid type. Allowed: ${extensions}`;
    }

    // Size validation
    const maxBytes = maxSizeMB * 1024 * 1024;
    if (file.size > maxBytes) {
      return `File too large. Max: ${maxSizeMB}MB`;
    }

    return null;
  }, [allowedTypes, maxSizeMB]);

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

    setError(null);

    // Check max files limit
    if (multiple && files.length + selectedFiles.length > maxFiles) {
      setError(`Maximum ${maxFiles} files allowed`);
      return;
    }

    const filesToProcess = Array.from(selectedFiles);

    // Validate all files
    for (const file of filesToProcess) {
      const validationError = validateFile(file);
      if (validationError) {
        setError(validationError);
        return;
      }
    }

    // Create previews
    const newFiles: FileWithPreview[] = filesToProcess.map((file) => ({
      file,
      preview: URL.createObjectURL(file),
      id: `${file.name}-${Date.now()}-${Math.random()}`,
    }));

    if (multiple) {
      setFiles((prev) => [...prev, ...newFiles]);
    } else {
      // Single file mode: clean up old preview
      files.forEach(({ preview }) => URL.revokeObjectURL(preview));
      setFiles(newFiles);
    }

    // Reset input to allow re-selecting same file
    event.target.value = '';
  }, [files, multiple, maxFiles, validateFile]);

  const removeFile = useCallback((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);
    });
    setError(null);
  }, []);

  const clearAllFiles = useCallback(() => {
    files.forEach(({ preview }) => {
      URL.revokeObjectURL(preview);
    });
    setFiles([]);
    setError(null);
  }, [files]);

  return {
    files,
    error,
    handleFileChange,
    removeFile,
    clearAllFiles,
  };
}

export default useFileUpload;

# JavaScript Version

javascript
import { useState, useEffect, useCallback } from 'react';

function useFileUpload(options = {}) {
  const {
    multiple = false,
    validation = {},
  } = options;

  const {
    maxSizeMB = 5,
    allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
    maxFiles = 10,
  } = validation;

  const [files, setFiles] = useState([]);
  const [error, setError] = useState(null);

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

  const validateFile = useCallback((file) => {
    if (!allowedTypes.includes(file.type)) {
      const extensions = allowedTypes.map(t => t.split('/')[1]).join(', ');
      return `Invalid type. Allowed: ${extensions}`;
    }

    const maxBytes = maxSizeMB * 1024 * 1024;
    if (file.size > maxBytes) {
      return `File too large. Max: ${maxSizeMB}MB`;
    }

    return null;
  }, [allowedTypes, maxSizeMB]);

  const handleFileChange = useCallback((event) => {
    const selectedFiles = event.target.files;
    if (!selectedFiles || selectedFiles.length === 0) return;

    setError(null);

    if (multiple && files.length + selectedFiles.length > maxFiles) {
      setError(`Maximum ${maxFiles} files allowed`);
      return;
    }

    const filesToProcess = Array.from(selectedFiles);

    for (const file of filesToProcess) {
      const validationError = validateFile(file);
      if (validationError) {
        setError(validationError);
        return;
      }
    }

    const newFiles = filesToProcess.map((file) => ({
      file,
      preview: URL.createObjectURL(file),
      id: `${file.name}-${Date.now()}-${Math.random()}`,
    }));

    if (multiple) {
      setFiles((prev) => [...prev, ...newFiles]);
    } else {
      files.forEach(({ preview }) => URL.revokeObjectURL(preview));
      setFiles(newFiles);
    }

    event.target.value = '';
  }, [files, multiple, maxFiles, validateFile]);

  const removeFile = useCallback((id) => {
    setFiles((prev) => {
      const fileToRemove = prev.find((f) => f.id === id);
      if (fileToRemove) {
        URL.revokeObjectURL(fileToRemove.preview);
      }
      return prev.filter((f) => f.id !== id);
    });
    setError(null);
  }, []);

  const clearAllFiles = useCallback(() => {
    files.forEach(({ preview }) => {
      URL.revokeObjectURL(preview);
    });
    setFiles([]);
    setError(null);
  }, [files]);

  return {
    files,
    error,
    handleFileChange,
    removeFile,
    clearAllFiles,
  };
}

export default useFileUpload;

# Practical Example: Avatar Upload Component

Real-world implementation with all features:

# TypeScript Version

typescript
import useFileUpload from './hooks/useFileUpload';

function AvatarUpload() {
  const { files, error, handleFileChange, removeFile } = useFileUpload({
    multiple: false,
    validation: {
      maxSizeMB: 2,
      allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
    },
  });

  const currentFile = files[0];

  const handleUpload = async () => {
    if (!currentFile) return;

    const formData = new FormData();
    formData.append('avatar', currentFile.file);

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

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

      alert('Avatar updated successfully!');
      removeFile(currentFile.id);
    } catch (err) {
      alert('Upload failed. Please try again.');
    }
  };

  return (
    <div style={{ maxWidth: '400px', margin: '0 auto', padding: '20px' }}>
      <h2>Upload Avatar</h2>

      <div
        style={{
          width: '200px',
          height: '200px',
          margin: '0 auto 20px',
          borderRadius: '50%',
          overflow: 'hidden',
          border: '3px solid #ddd',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          backgroundColor: '#f5f5f5',
        }}
      >
        {currentFile ? (
          <img
            src={currentFile.preview}
            alt="Avatar preview"
            style={{ width: '100%', height: '100%', objectFit: 'cover' }}
          />
        ) : (
          <span style={{ color: '#999' }}>No avatar</span>
        )}
      </div>

      <div style={{ textAlign: 'center' }}>
        <input
          type="file"
          accept="image/*"
          onChange={handleFileChange}
          style={{ display: 'none' }}
          id="avatar-input"
        />
        
        <label
          htmlFor="avatar-input"
          style={{
            display: 'inline-block',
            padding: '10px 20px',
            backgroundColor: '#007bff',
            color: 'white',
            borderRadius: '5px',
            cursor: 'pointer',
            marginBottom: '10px',
          }}
        >
          Choose Photo
        </label>

        {error && (
          <div style={{ color: 'red', marginBottom: '10px' }}>
            {error}
          </div>
        )}

        {currentFile && (
          <div>
            <p style={{ fontSize: '14px', color: '#666' }}>
              {currentFile.file.name} ({(currentFile.file.size / 1024).toFixed(1)}KB)
            </p>
            <div style={{ display: 'flex', gap: '10px', justifyContent: 'center' }}>
              <button
                onClick={handleUpload}
                style={{
                  padding: '10px 20px',
                  backgroundColor: '#28a745',
                  color: 'white',
                  border: 'none',
                  borderRadius: '5px',
                  cursor: 'pointer',
                }}
              >
                Upload
              </button>
              <button
                onClick={() => removeFile(currentFile.id)}
                style={{
                  padding: '10px 20px',
                  backgroundColor: '#dc3545',
                  color: 'white',
                  border: 'none',
                  borderRadius: '5px',
                  cursor: 'pointer',
                }}
              >
                Remove
              </button>
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

export default AvatarUpload;

# JavaScript Version

javascript
import useFileUpload from './hooks/useFileUpload';

function AvatarUpload() {
  const { files, error, handleFileChange, removeFile } = useFileUpload({
    multiple: false,
    validation: {
      maxSizeMB: 2,
      allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
    },
  });

  const currentFile = files[0];

  const handleUpload = async () => {
    if (!currentFile) return;

    const formData = new FormData();
    formData.append('avatar', currentFile.file);

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

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

      alert('Avatar updated successfully!');
      removeFile(currentFile.id);
    } catch (err) {
      alert('Upload failed. Please try again.');
    }
  };

  return (
    <div style={{ maxWidth: '400px', margin: '0 auto', padding: '20px' }}>
      <h2>Upload Avatar</h2>

      <div
        style={{
          width: '200px',
          height: '200px',
          margin: '0 auto 20px',
          borderRadius: '50%',
          overflow: 'hidden',
          border: '3px solid #ddd',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          backgroundColor: '#f5f5f5',
        }}
      >
        {currentFile ? (
          <img
            src={currentFile.preview}
            alt="Avatar preview"
            style={{ width: '100%', height: '100%', objectFit: 'cover' }}
          />
        ) : (
          <span style={{ color: '#999' }}>No avatar</span>
        )}
      </div>

      <div style={{ textAlign: 'center' }}>
        <input
          type="file"
          accept="image/*"
          onChange={handleFileChange}
          style={{ display: 'none' }}
          id="avatar-input"
        />
        
        <label
          htmlFor="avatar-input"
          style={{
            display: 'inline-block',
            padding: '10px 20px',
            backgroundColor: '#007bff',
            color: 'white',
            borderRadius: '5px',
            cursor: 'pointer',
            marginBottom: '10px',
          }}
        >
          Choose Photo
        </label>

        {error && (
          <div style={{ color: 'red', marginBottom: '10px' }}>
            {error}
          </div>
        )}

        {currentFile && (
          <div>
            <p style={{ fontSize: '14px', color: '#666' }}>
              {currentFile.file.name} ({(currentFile.file.size / 1024).toFixed(1)}KB)
            </p>
            <div style={{ display: 'flex', gap: '10px', justifyContent: 'center' }}>
              <button
                onClick={handleUpload}
                style={{
                  padding: '10px 20px',
                  backgroundColor: '#28a745',
                  color: 'white',
                  border: 'none',
                  borderRadius: '5px',
                  cursor: 'pointer',
                }}
              >
                Upload
              </button>
              <button
                onClick={() => removeFile(currentFile.id)}
                style={{
                  padding: '10px 20px',
                  backgroundColor: '#dc3545',
                  color: 'white',
                  border: 'none',
                  borderRadius: '5px',
                  cursor: 'pointer',
                }}
              >
                Remove
              </button>
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

export default AvatarUpload;

# FAQ

# Q: Should I use FileReader or URL.createObjectURL for previews?

A: Use URL.createObjectURL for previews—it's synchronous, memory-efficient, and works for images, videos, and PDFs. Use FileReader.readAsDataURL only when you need base64 strings (e.g., storing in localStorage or sending in JSON).

typescript
// ✅ RECOMMENDED: URL.createObjectURL
const preview = URL.createObjectURL(file);
// Instant, returns: "blob:http://localhost:3000/abc-123"

// ❌ AVOID for previews: FileReader
const reader = new FileReader();
reader.onload = (e) => setPreview(e.target.result);
reader.readAsDataURL(file);
// Slow, returns: "data:image/png;base64,iVBORw0KGgo..."

# Q: How do I handle different file types (PDF, video)?

A: Check file.type and render appropriate preview components:

typescript
function FilePreview({ file, preview }: { file: File; preview: string }) {
  if (file.type.startsWith('image/')) {
    return <img src={preview} alt={file.name} />;
  }
  
  if (file.type.startsWith('video/')) {
    return <video src={preview} controls />;
  }
  
  if (file.type === 'application/pdf') {
    return <embed src={preview} type="application/pdf" width="100%" height="600px" />;
  }
  
  return <p>Preview not available for {file.type}</p>;
}

# Q: Do I need to revoke object URLs immediately or can I wait until unmount?

A: Revoke immediately when possible (e.g., when removing individual files), and use cleanup effect as a safety net for unmount. This prevents memory buildup in long-lived components:

typescript
// Immediate revocation when removing file
const removeFile = (id: string) => {
  const file = files.find(f => f.id === id);
  if (file) {
    URL.revokeObjectURL(file.preview); // ✅ Immediate
  }
  setFiles(prev => prev.filter(f => f.id !== id));
};

// Safety net cleanup on unmount
useEffect(() => {
  return () => {
    files.forEach(({ preview }) => URL.revokeObjectURL(preview));
  };
}, [files]);

# Q: How do I validate file dimensions (width/height) for images?

A: Use Image() constructor to load and check dimensions:

typescript
const validateImageDimensions = (
  file: File, 
  maxWidth: number, 
  maxHeight: number
): Promise<string | null> => {
  return new Promise((resolve) => {
    const img = new Image();
    
    img.onload = () => {
      URL.revokeObjectURL(img.src); // Clean up
      
      if (img.width > maxWidth || img.height > maxHeight) {
        resolve(`Image must be ${maxWidth}×${maxHeight}px or smaller`);
      } else {
        resolve(null);
      }
    };
    
    img.onerror = () => {
      resolve('Invalid image file');
    };
    
    img.src = URL.createObjectURL(file);
  });
};

// Usage in hook
const error = await validateImageDimensions(file, 1920, 1080);
if (error) {
  setError(error);
  return;
}

# Q: Can I compress images before uploading?

A: Yes, use Canvas API:

typescript
const compressImage = (file: File, maxSizeMB: number = 1): Promise<Blob> => {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => {
      const canvas = document.createElement('canvas');
      const ctx = canvas.getContext('2d')!;
      
      // Calculate new dimensions
      let { width, height } = img;
      const maxDim = 1920;
      if (width > maxDim || height > maxDim) {
        if (width > height) {
          height = (height / width) * maxDim;
          width = maxDim;
        } else {
          width = (width / height) * maxDim;
          height = maxDim;
        }
      }
      
      canvas.width = width;
      canvas.height = height;
      ctx.drawImage(img, 0, 0, width, height);
      
      // Convert to blob with quality adjustment
      canvas.toBlob(
        (blob) => {
          if (blob) resolve(blob);
          else reject(new Error('Compression failed'));
        },
        'image/jpeg',
        0.8 // Quality: 0-1
      );
      
      URL.revokeObjectURL(img.src);
    };
    
    img.src = URL.createObjectURL(file);
  });
};

# Q: How do I handle upload failures and retry?

A: Add retry logic with exponential backoff:

typescript
const uploadWithRetry = async (
  file: File, 
  maxRetries: number = 3
): Promise<void> => {
  let attempt = 0;
  
  while (attempt < maxRetries) {
    try {
      const formData = new FormData();
      formData.append('file', file);
      
      const response = await fetch('/api/upload', {
        method: 'POST',
        body: formData,
      });
      
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      
      return; // Success
    } catch (error) {
      attempt++;
      
      if (attempt >= maxRetries) {
        throw new Error(`Upload failed after ${maxRetries} attempts`);
      }
      
      // Exponential backoff: 1s, 2s, 4s
      const delay = Math.pow(2, attempt - 1) * 1000;
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
};

Related Articles:

Questions? Share your file upload challenges and solutions in the comments! Have you implemented image compression or dimension validation? What edge cases did you encounter?