AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

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?

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT