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
- Why Build a Custom File Upload Hook?
- Basic Implementation: Single File Upload
- Adding File Preview with FileReader
- Multi-File Upload Support
- File Validation: Type and Size
- Drag-and-Drop Interface
- Upload Progress Tracking
- Memory Management and Cleanup
- TypeScript Type Safety
- Complete Hook Implementation
- Practical Example: Avatar Upload Component
- FAQ
Why Build a Custom File Upload Hook?
Consider what happens when you implement file uploads in multiple components:
// 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
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
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.filesreturns 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
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
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/PDFsFileReader.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
useEffectprevents 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
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
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
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
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.typeagainst 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
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
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()andstopPropagation()on drag events dragOvermust be handled (even if empty) to enable droppinge.dataTransfer.filescontains dropped files- Visual feedback (
isDraggingstate) improves UX
Upload Progress Tracking
For large files or slow connections, show upload progress:
TypeScript Version
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
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 yetXMLHttpRequest.upload.onprogressprovidesloadedandtotalbytes- For downloads, use
fetch()with response.body.getReader()`
Memory Management and Cleanup
File uploads create memory that must be manually released:
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:
- Creating object URLs without revoking them
- Navigating away before cleanup runs
- 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
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
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
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
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
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).
// ✅ 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:
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:
// 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:
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:
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:
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?
Google AdSense Placeholder
CONTENT SLOT