File Upload
AdvancedA file input cannot be controlled, uploads need progress that fetch cannot report, and at any real size the file should not pass through your API at all.
Overview
File upload is the form feature that breaks the usual patterns. The input is uncontrolled by design — you cannot set its value from state, for security reasons. Progress reporting needs XMLHttpRequest, because fetch still has no upload progress event. And routing large files through your own backend wastes bandwidth and hits request size limits, which is why production systems hand the browser a pre-signed URL and let it upload directly to storage. Validating type and size on the client is a courtesy; the server must check both again.
Reading and Previewing
Selection, client-side validation, and a preview that does not leak memory.
const [file, setFile] = useState(null)
const [preview, setPreview] = useState(null)
function onChange(e) {
const f = e.target.files?.[0]
if (!f) return
if (!['image/jpeg', 'image/png', 'image/webp'].includes(f.type))
return setError('Please choose a JPEG, PNG or WebP image')
if (f.size > 5 * 1024 * 1024)
return setError('Images must be under 5MB')
setFile(f)
setPreview(URL.createObjectURL(f)) // an object URL, not a data URL
}
// Object URLs must be revoked or the file stays in memory
useEffect(() => () => { if (preview) URL.revokeObjectURL(preview) }, [preview])
<input type="file" accept="image/*" onChange={onChange} />
// accept filters the OS picker; it does not enforce anything.
// The extension and the MIME type are both attacker-controlled —
// the server must verify the real content.Uploading With Progress
FormData for the body, XHR for the progress events.
function upload(file, onProgress) {
return new Promise((resolve, reject) => {
const form = new FormData()
form.append('file', file)
const xhr = new XMLHttpRequest()
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100))
}
xhr.onload = () => (xhr.status < 400 ? resolve(JSON.parse(xhr.response))
: reject(new Error(xhr.statusText)))
xhr.onerror = () => reject(new Error('Network error'))
xhr.open('POST', '/api/upload')
xhr.withCredentials = true
xhr.send(form) // no Content-Type header — the
}) // browser sets the boundary
}
// fetch cannot report upload progress. XHR remains the answer.
const [progress, setProgress] = useState(0)
<progress value={progress} max="100" />
// Allow cancellation — an accidental 200MB upload must be stoppable
xhr.abort()Direct-to-Storage
What production does: the browser uploads straight to S3 or Supabase, and your API never sees the bytes.
// 1. Ask your API for a short-lived signed URL
const { uploadUrl, publicUrl, fields } = await post('/api/uploads/sign', {
contentType: file.type,
size: file.size, // the server enforces the real limits
})
// 2. Upload directly to storage
await fetch(uploadUrl, { method: 'PUT', body: file,
headers: { 'Content-Type': file.type } })
// 3. Tell your API the file is there
await post('/api/problems', { coverUrl: publicUrl })
// Why: your server never handles the bytes, so no request size limit,
// no memory pressure, no doubled bandwidth cost, and the upload is
// as fast as the storage provider.
// The signing endpoint is the security boundary — it authenticates
// the user, constrains the content type, caps the size, and picks
// the key. Never let the client choose the storage path.
// Drag and drop, for completeness
onDrop={(e) => { e.preventDefault(); handleFiles(e.dataTransfer.files) }}
onDragOver={(e) => e.preventDefault()} // required, or drop never firesKey Points to Remember
- 1A file input is always uncontrolled — you cannot set its value from state
- 2Object URLs must be revoked on cleanup or the selected file stays in memory
- 3fetch cannot report upload progress; XMLHttpRequest's upload.onprogress can
- 4Client-side type and size checks are convenience only — the server must verify the real content
- 5Production uploads go directly to storage via a short-lived signed URL, with the signing endpoint as the security boundary
Interview Questions
Sign in to ask AriaWhy can a file input not be a controlled component?
How do you show upload progress, and why does fetch not suffice?
Why upload directly to storage instead of through your own API?
Ask Aria about File Upload
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.