Building a Custom File Upload Component for React
The native file input works, but it’s ugly, inflexible, and offers zero feedback during uploads. Most developers reach for libraries like react-dropzone to solve this. But if you want full control—or just fewer dependencies—building a custom React file upload component isn’t hard once you understand the constraints.
This guide covers the essentials: accessible file selection, optional drag-and-drop functionality, validation, previews, progress tracking, and cancellation. All with modern functional components and hooks.
Key Takeaways
- File inputs are inherently uncontrolled in React due to browser security restrictions that prevent programmatic value assignment
- Always pair client-side validation with server-side validation since MIME types and the
acceptattribute can be bypassed - Use
XMLHttpRequestinstead offetchfor reliable upload progress tracking - Revoke object URLs created for file previews to prevent memory leaks
- Drag-and-drop should be a progressive enhancement, not a replacement for keyboard and click interactions
Why file inputs stay uncontrolled
Here’s something that trips up React developers: you cannot set the value of <input type="file"> programmatically. Browser security prevents it. This means file inputs are inherently uncontrolled—React can read from them but never write to them.
This matters for React 19’s Form Actions too. While useFormStatus can track pending state, Server Actions aren’t designed for large file uploads. You’ll still handle files client-side before sending them anywhere.
The foundation: accessible file selection
Start with the input itself. Hide it visually, but keep it accessible:
const FileUploader = () => {
const inputRef = useRef<HTMLInputElement>(null)
const [files, setFiles] = useState<File[]>([])
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(Array.from(e.target.files))
}
}
return (
<div>
<input
ref={inputRef}
type="file"
id="file-upload"
className="sr-only"
onChange={handleChange}
multiple
aria-describedby="file-upload-help"
/>
<label htmlFor="file-upload" className="upload-button">
Select files
</label>
<span id="file-upload-help" className="sr-only">
Accepted formats: images, PDFs up to 10MB
</span>
</div>
)
}
The accept attribute is advisory only—users can bypass it. Always validate server-side.
Adding drag and drop as progressive enhancement
Drag-and-drop file upload functionality improves UX but isn’t essential. Keep keyboard and click interactions as the primary path:
const [isDragging, setIsDragging] = useState(false)
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}
const handleDragLeave = () => setIsDragging(false)
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const droppedFiles = Array.from(e.dataTransfer.files)
setFiles(droppedFiles)
}
Note: DataTransferItem.getAsFileSystemHandle() offers directory access in modern browsers, but support remains limited. Stick to dataTransfer.files for broad compatibility.
Client-side validation
Validate before upload to improve the user experience:
const validateFile = (file: File): string | null => {
const maxSize = 10 * 1024 * 1024 // 10MB
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
if (file.size > maxSize) return 'File exceeds 10MB limit'
if (!allowedTypes.includes(file.type)) return 'Invalid file type'
return null
}
Remember: MIME types can be spoofed. Server-side validation is mandatory.
Discover how at OpenReplay.com.
File previews with proper cleanup
For image previews, URL.createObjectURL creates temporary URLs. Revoke them to prevent memory leaks:
const [preview, setPreview] = useState<string | null>(null)
useEffect(() => {
if (files[0]?.type.startsWith('image/')) {
const url = URL.createObjectURL(files[0])
setPreview(url)
return () => URL.revokeObjectURL(url)
}
}, [files])
Progress tracking and cancellation
Here’s the uncomfortable truth: fetch doesn’t support upload progress reliably. For real progress tracking, use XMLHttpRequest:
const [progress, setProgress] = useState(0)
const xhrRef = useRef<XMLHttpRequest | null>(null)
const upload = (file: File) => {
const xhr = new XMLHttpRequest()
xhrRef.current = xhr
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
setProgress(Math.round((e.loaded / e.total) * 100))
}
}
const formData = new FormData()
formData.append('file', file)
xhr.open('POST', '/api/upload')
xhr.send(formData)
}
const cancel = () => xhrRef.current?.abort()
For large files, consider direct-to-object-storage uploads (S3 presigned URLs) or chunked/multipart approaches. Sending multi-gigabyte files through your application server rarely ends well.
Error states
Track upload state explicitly:
type UploadState =
| { status: 'idle' }
| { status: 'uploading'; progress: number }
| { status: 'success'; url: string }
| { status: 'error'; message: string }
This discriminated union makes rendering straightforward and type-safe.
When to use a library instead
Building your own custom file upload React component makes sense when you need specific behavior or want to minimize bundle size. Libraries like react-dropzone handle edge cases you might miss—but they also add dependencies and opinions you might not want.
The core pattern is simple: hidden input, accessible label, optional drop zone, validation, and XHR for progress. Everything else is refinement.
Conclusion
A custom file upload component gives you precise control over behavior, styling, and bundle size. The key constraints are straightforward: file inputs must remain uncontrolled, client-side validation supplements but never replaces server-side checks, and XMLHttpRequest remains the reliable choice for progress tracking. Start with accessible foundations, layer in drag-and-drop as an optional improvement, and handle memory cleanup for previews. Whether you build custom or reach for a library depends on your specific needs—but now you understand what’s happening under the hood either way.
FAQs
Browser security prevents programmatic value assignment on file inputs. To reset, either call inputRef.current.value = '' using a ref (which works for clearing but not setting), or unmount and remount the input by changing its key prop. Both approaches effectively clear the selection while respecting browser restrictions.
Create an array of upload states, one per file. Each state object tracks its own progress, status, and any errors. Use the file name or a generated ID as the key. When uploading, map over files and create separate XMLHttpRequest instances, updating the corresponding state entry in each progress callback.
The dragLeave event fires when moving between parent and child elements. Fix this by checking if the related target is contained within the drop zone before setting isDragging to false, or use a counter that increments on dragEnter and decrements on dragLeave, only updating state when the counter reaches zero.
Use FormData for most cases. It handles binary data efficiently and supports the multipart/form-data encoding servers expect. Base64 encoding increases file size by roughly 33% and requires more processing on both ends. Only use base64 when your API specifically requires it or when embedding small files in JSON payloads.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.