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.

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>
  )
}