React File Upload: Multipart Forms and Progress Tracking (2026)
File upload is deceptively complex: you need a drop zone, file validation, image previews, upload progress, error handling and cleanup. This guide builds each piece from scratch, then shows how to combine them with react-dropzone, and covers production patterns like S3 presigned URL uploads and Next.js Server Actions.
Table of Contents
Basic File Input
import { useRef } from 'react'
function FileUpload() {
const inputRef = useRef<HTMLInputElement>(null)
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
uploadFile(file)
}
async function uploadFile(file: File) {
const formData = new FormData()
formData.append('file', file)
formData.append('userId', '123')
const res = await fetch('/api/upload', {
method: 'POST',
body: formData, // Don't set Content-Type — browser sets multipart boundary
})
const data = await res.json()
console.log('Uploaded:', data.url)
}
return (
<div>
{/* Hidden native input */}
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="sr-only"
aria-label="Upload file"
/>
{/* Custom trigger */}
<button onClick={() => inputRef.current?.click()}>
Choose File
</button>
</div>
)
}
Image Preview
import { useState, useCallback } from 'react'
interface FileWithPreview extends File {
preview: string
}
function ImagePreview() {
const [preview, setPreview] = useState<string | null>(null)
const handleFile = useCallback((file: File) => {
// Revoke previous object URL to prevent memory leaks
if (preview) URL.revokeObjectURL(preview)
const url = URL.createObjectURL(file)
setPreview(url)
}, [preview])
// Clean up on unmount
useEffect(() => {
return () => { if (preview) URL.revokeObjectURL(preview) }
}, [preview])
return (
<div>
<input
type="file"
accept="image/*"
onChange={e => e.target.files?.[0] && handleFile(e.target.files[0])}
/>
{preview && (
<div className="mt-4">
<img
src={preview}
alt="Preview"
className="max-w-xs max-h-48 rounded-lg object-cover"
/>
<button
onClick={() => { URL.revokeObjectURL(preview); setPreview(null) }}
className="mt-2 text-sm text-red-500"
>
Remove
</button>
</div>
)}
</div>
)
}
Upload with Progress
The Fetch API doesn't support upload progress. Use XMLHttpRequest instead:
function useFileUpload() {
const [progress, setProgress] = useState(0)
const [status, setStatus] = useState<'idle' | 'uploading' | 'success' | 'error'>('idle')
const xhrRef = useRef<XMLHttpRequest | null>(null)
const upload = useCallback(async (file: File) => {
setStatus('uploading')
setProgress(0)
return new Promise<string>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhrRef.current = xhr
// Progress events
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
setProgress(Math.round((e.loaded / e.total) * 100))
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
setStatus('success')
resolve(JSON.parse(xhr.responseText).url)
} else {
setStatus('error')
reject(new Error(`Upload failed: ${xhr.statusText}`))
}
})
xhr.addEventListener('error', () => {
setStatus('error')
reject(new Error('Network error'))
})
xhr.addEventListener('abort', () => {
setStatus('idle')
setProgress(0)
})
const formData = new FormData()
formData.append('file', file)
xhr.open('POST', '/api/upload')
xhr.send(formData)
})
}, [])
const cancel = useCallback(() => {
xhrRef.current?.abort()
}, [])
return { upload, cancel, progress, status }
}
// Usage
function UploadWithProgress() {
const { upload, cancel, progress, status } = useFileUpload()
const [fileUrl, setFileUrl] = useState<string | null>(null)
async function handleFile(file: File) {
try {
const url = await upload(file)
setFileUrl(url)
} catch (e) {
console.error(e)
}
}
return (
<div>
<input type="file" onChange={e => e.target.files?.[0] && handleFile(e.target.files[0])} />
{status === 'uploading' && (
<div className="mt-4">
<div className="flex justify-between text-sm mb-1">
<span>Uploading...</span>
<span>{progress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-indigo-500 h-2 rounded-full transition-all"
style={{ width: `${progress}%` }}
/>
</div>
<button onClick={cancel} className="mt-2 text-sm text-red-500">Cancel</button>
</div>
)}
{status === 'success' && <p className="text-green-500 mt-2">✓ Uploaded: {fileUrl}</p>}
{status === 'error' && <p className="text-red-500 mt-2">Upload failed. Try again.</p>}
</div>
)
}
react-dropzone
npm install react-dropzone
import { useDropzone } from 'react-dropzone'
function DropZone({ onFiles }: { onFiles: (files: File[]) => void }) {
const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({
onDrop: onFiles,
accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.webp'] },
maxSize: 5 * 1024 * 1024, // 5MB
maxFiles: 10,
})
return (
<div
{...getRootProps()}
style={{
border: '2px dashed',
borderColor: isDragReject ? '#ef4444' : isDragActive ? '#6366f1' : '#334155',
borderRadius: 12,
padding: 40,
textAlign: 'center',
cursor: 'pointer',
background: isDragActive ? 'rgba(99,102,241,0.08)' : 'transparent',
transition: 'all 0.2s',
}}
>
<input {...getInputProps()} />
{isDragReject
? <p className="text-red-400">File type not accepted</p>
: isDragActive
? <p className="text-indigo-400">Drop files here...</p>
: <p className="text-gray-400">Drag & drop images here, or click to select<br/><small>PNG, JPG, WebP up to 5MB</small></p>
}
</div>
)
}
File Validation
function validateFile(file: File): string | null {
const MAX_SIZE = 10 * 1024 * 1024 // 10MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
if (!ALLOWED_TYPES.includes(file.type)) {
return `File type ${file.type} is not allowed`
}
if (file.size > MAX_SIZE) {
return `File size ${(file.size / 1024 / 1024).toFixed(1)}MB exceeds 10MB limit`
}
return null
}
// Validate image dimensions
async function validateImageDimensions(file: File, maxWidth = 4000, maxHeight = 4000): Promise<string | null> {
return new Promise((resolve) => {
const img = new Image()
img.onload = () => {
URL.revokeObjectURL(img.src)
if (img.width > maxWidth || img.height > maxHeight) {
resolve(`Image must be at most ${maxWidth}×${maxHeight}px`)
} else {
resolve(null)
}
}
img.src = URL.createObjectURL(file)
})
}
Multiple File Upload
interface UploadItem {
file: File
id: string
preview: string
progress: number
status: 'pending' | 'uploading' | 'done' | 'error'
url?: string
error?: string
}
function MultiFileUpload() {
const [uploads, setUploads] = useState<UploadItem[]>([])
function addFiles(files: File[]) {
const newItems: UploadItem[] = files.map(file => ({
file,
id: crypto.randomUUID(),
preview: URL.createObjectURL(file),
progress: 0,
status: 'pending',
}))
setUploads(prev => [...prev, ...newItems])
newItems.forEach(item => startUpload(item))
}
async function startUpload(item: UploadItem) {
setUploads(prev => prev.map(u => u.id === item.id ? { ...u, status: 'uploading' } : u))
try {
// ... XHR upload with progress updates
setUploads(prev => prev.map(u => u.id === item.id ? { ...u, status: 'done', progress: 100 } : u))
} catch (e) {
setUploads(prev => prev.map(u => u.id === item.id ? { ...u, status: 'error', error: String(e) } : u))
}
}
function remove(id: string) {
const item = uploads.find(u => u.id === id)
if (item) URL.revokeObjectURL(item.preview)
setUploads(prev => prev.filter(u => u.id !== id))
}
return (
<div>
<DropZone onFiles={addFiles} />
<div className="mt-4 space-y-2">
{uploads.map(item => (
<UploadRow key={item.id} item={item} onRemove={() => remove(item.id)} />
))}
</div>
</div>
)
}
S3 Presigned URL Upload
// Client uploads directly to S3 — server only issues a signed URL
async function uploadToS3(file: File) {
// 1. Get presigned URL from your API
const { url, key } = await fetch('/api/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, contentType: file.type }),
}).then(r => r.json())
// 2. Upload directly to S3 with the presigned URL
await fetch(url, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
})
return `https://your-bucket.s3.amazonaws.com/${key}`
}
// API route: /api/upload-url
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
const s3 = new S3Client({ region: 'us-east-1' })
export async function POST(req: Request) {
const { filename, contentType } = await req.json()
const key = `uploads/${Date.now()}-${filename}`
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
ContentType: contentType,
})
const url = await getSignedUrl(s3, command, { expiresIn: 300 })
return Response.json({ url, key })
}
Next.js Server Action Upload
// app/actions/upload.ts
'use server'
import { writeFile } from 'fs/promises'
import path from 'path'
export async function uploadAction(formData: FormData) {
const file = formData.get('file') as File
if (!file || file.size === 0) throw new Error('No file provided')
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const filename = `${Date.now()}-${file.name}`
const filepath = path.join(process.cwd(), 'public/uploads', filename)
await writeFile(filepath, buffer)
return { url: `/uploads/${filename}` }
}
// Component
'use client'
import { uploadAction } from '@/actions/upload'
import { useActionState } from 'react'
function UploadForm() {
const [state, action, isPending] = useActionState(
async (_prev: unknown, formData: FormData) => {
return await uploadAction(formData)
},
null
)
return (
<form action={action}>
<input type="file" name="file" accept="image/*" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Uploading...' : 'Upload'}
</button>
{state?.url && <p>Uploaded: <a href={state.url}>{state.url}</a></p>}
</form>
)
}