The React side of file uploads involves three distinct moments: the user selects a file (browser File object available), the upload is in progress (show a progress bar), and the upload is complete (display the result). Building a polished file upload UI means handling all three moments — validating the file before sending it, showing real-time progress with Axios’s onUploadProgress, and updating the UI with the returned URL immediately. In this lesson you will build the complete React file upload flow for the MERN Blog’s avatar feature.
The File Input and File Object
import { useState, useRef } from 'react';
function AvatarUpload({ currentAvatar, onUploadComplete }) {
const [preview, setPreview] = useState(currentAvatar || null);
const [file, setFile] = useState(null);
const [error, setError] = useState('');
const inputRef = useRef(null);
const handleFileSelect = (e) => {
const selected = e.target.files[0];
if (!selected) return;
// ── Client-side validation ─────────────────────────────────────────────
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (!allowedTypes.includes(selected.type)) {
setError('Only JPEG, PNG, WebP, and GIF images are allowed');
return;
}
if (selected.size > 2 * 1024 * 1024) {
setError('Image must be smaller than 2 MB');
return;
}
setError('');
setFile(selected);
// ── Create a local preview URL ─────────────────────────────────────────
const objectUrl = URL.createObjectURL(selected);
setPreview(objectUrl);
// Clean up the object URL when the component unmounts
return () => URL.revokeObjectURL(objectUrl);
};
return (
<div className="avatar-upload">
{/* Preview */}
<img
src={preview || '/images/default-avatar.jpg'}
alt="Avatar preview"
className="avatar-upload__preview"
width={100}
height={100}
/>
{/* Hidden file input */}
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onChange={handleFileSelect}
className="avatar-upload__input"
aria-label="Upload avatar"
/>
{/* Visible trigger button */}
<button
type="button"
onClick={() => inputRef.current?.click()}
className="btn btn--secondary btn--sm"
>
Choose Image
</button>
{error && <p className="field-error">{error}</p>}
</div>
);
}
URL.createObjectURL(file) creates a temporary URL pointing to the file in the browser’s memory — it starts with blob:. This lets you show an image preview instantly without uploading anything. Always call URL.revokeObjectURL(url) when the preview is no longer needed to release the memory. Do this in the useEffect cleanup or when the component unmounts.display: none and trigger it programmatically with a ref (inputRef.current.click()) when the user clicks a styled button. This gives you full control over the upload button’s appearance while still using the browser’s native file picker — which handles the OS file browser, drag-and-drop support, and accessibility automatically.fileFilter and limits. A user could bypass client-side checks using browser dev tools or by calling the API directly.Uploading with Axios and Progress Tracking
import { useState } from 'react';
import api from '@/services/api';
function AvatarUpload({ onUploadComplete }) {
const [file, setFile] = useState(null);
const [preview, setPreview] = useState(null);
const [progress, setProgress] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleUpload = async () => {
if (!file) { setError('Please select an image first'); return; }
// Build FormData — the key MUST match Multer's .single('avatar')
const formData = new FormData();
formData.append('avatar', file);
setLoading(true);
setProgress(0);
setError('');
try {
const res = await api.post('/users/avatar', formData, {
// DO NOT set Content-Type manually — Axios handles it
onUploadProgress: (progressEvent) => {
const pct = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
setProgress(pct); // update progress bar as bytes transfer
},
});
// Upload complete — update the UI with the returned URL
onUploadComplete(res.data.data.avatar);
setProgress(100);
} catch (err) {
setError(err.response?.data?.message || 'Upload failed — please try again');
} finally {
setLoading(false);
}
};
return (
<div>
{preview && (
<img src={preview} alt="Preview" width={100} height={100}
style={{ borderRadius: '50%', objectFit: 'cover' }} />
)}
{/* Progress bar */}
{loading && (
<div className="upload-progress">
<div
className="upload-progress__bar"
style={{ width: `${progress}%` }}
role="progressbar"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
/>
<span>{progress}%</span>
</div>
)}
{error && <p className="field-error">{error}</p>}
<button
type="button"
onClick={handleUpload}
disabled={!file || loading}
className="btn btn--primary"
>
{loading ? `Uploading ${progress}%...` : 'Upload Avatar'}
</button>
</div>
);
}
FormData — Key Methods
const fd = new FormData();
// Append a file
fd.append('avatar', file); // file is a File object from input.files[0]
// Append text fields alongside the file
fd.append('userId', user._id);
fd.append('description', 'Profile photo');
// Inspect FormData (for debugging)
for (const [key, value] of fd.entries()) {
console.log(key, value);
}
// Delete a field
fd.delete('description');
// Check if a field exists
fd.has('avatar'); // true
Common Mistakes
Mistake 1 — Not revoking the object URL
❌ Wrong — memory leak from uncleaned blob URLs:
const url = URL.createObjectURL(file);
setPreview(url);
// URL never revoked — browser holds reference to file in memory indefinitely
✅ Correct — revoke when no longer needed:
useEffect(() => {
if (!file) return;
const url = URL.createObjectURL(file);
setPreview(url);
return () => URL.revokeObjectURL(url); // ✓ revoke on file change or unmount
}, [file]);
Mistake 2 — Appending the wrong value to FormData
❌ Wrong — appending the FileList instead of the File:
fd.append('avatar', e.target.files); // FileList object — not a File!
// Server receives empty or invalid data
✅ Correct — append the first file from the list:
fd.append('avatar', e.target.files[0]); // ✓ actual File object
Mistake 3 — Not disabling the upload button during upload
❌ Wrong — user can click Upload multiple times, creating duplicate requests:
<button onClick={handleUpload}>Upload</button>
✅ Correct — disable during loading:
<button onClick={handleUpload} disabled={loading || !file}>
{loading ? `Uploading ${progress}%...` : 'Upload'}
</button>
Quick Reference
| Task | Code |
|---|---|
| Get file from input | e.target.files[0] |
| Create preview URL | URL.createObjectURL(file) |
| Revoke preview URL | URL.revokeObjectURL(url) |
| Build FormData | const fd = new FormData(); fd.append('avatar', file) |
| Upload with progress | api.post('/users/avatar', fd, { onUploadProgress: e => setProgress(Math.round(e.loaded*100/e.total)) }) |
| Access returned URL | res.data.data.avatar |
| Trigger hidden input | inputRef.current.click() |