With the server-side Multer configuration and the React FormData upload pattern in place, this final lesson assembles the complete avatar and cover image upload features for the MERN Blog. You will build a reusable FileDropZone component that accepts both click-to-select and drag-and-drop, an ImageUploader component that shows a preview, progress bar, and upload button, and wire them into the Profile Settings page and the Create/Edit Post form โ completing the file upload story from user interaction to MongoDB update.
The FileDropZone Component
// src/components/ui/FileDropZone.jsx
import { useState, useCallback, useRef } from 'react';
import PropTypes from 'prop-types';
function FileDropZone({ onFileSelect, accept = 'image/*', maxSizeMB = 2, children }) {
const [isDragging, setIsDragging] = useState(false);
const [dragError, setDragError] = useState('');
const inputRef = useRef(null);
const validateFile = (file) => {
const acceptedTypes = accept
.split(',')
.map(t => t.trim())
.filter(t => t !== 'image/*')
.concat(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
if (!file.type.startsWith('image/')) return 'Only image files are accepted';
if (file.size > maxSizeMB * 1024 * 1024) return `File must be smaller than ${maxSizeMB} MB`;
return null; // valid
};
const handleDrop = useCallback((e) => {
e.preventDefault();
setIsDragging(false);
setDragError('');
const file = e.dataTransfer.files[0];
if (!file) return;
const err = validateFile(file);
if (err) { setDragError(err); return; }
onFileSelect(file);
}, [onFileSelect, maxSizeMB]);
const handleDragOver = (e) => { e.preventDefault(); setIsDragging(true); };
const handleDragLeave = () => setIsDragging(false);
const handleInputChange = (e) => {
const file = e.target.files[0];
if (!file) return;
const err = validateFile(file);
if (err) { setDragError(err); return; }
setDragError('');
onFileSelect(file);
};
return (
<div
className={`file-drop-zone${isDragging ? ' file-drop-zone--dragging' : ''}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => inputRef.current?.click()}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && inputRef.current?.click()}
aria-label="Click or drag an image here to upload"
>
<input
ref={inputRef}
type="file"
accept={accept}
onChange={handleInputChange}
className="file-drop-zone__input"
tabIndex={-1}
/>
{children || (
<div className="file-drop-zone__prompt">
<span className="file-drop-zone__icon">๐</span>
<p>Click or drag an image here</p>
<p className="file-drop-zone__hint">JPEG, PNG, WebP โ max {maxSizeMB} MB</p>
</div>
)}
{dragError && <p className="field-error">{dragError}</p>}
</div>
);
}
FileDropZone.propTypes = {
onFileSelect: PropTypes.func.isRequired,
accept: PropTypes.string,
maxSizeMB: PropTypes.number,
children: PropTypes.node,
};
export default FileDropZone;
e.preventDefault() in both the dragover and drop handlers โ without it, the browser opens the file directly instead of passing it to your handler. The e.dataTransfer.files array on the drop event contains the dropped files, identical in structure to input.files. The same validation and FormData logic applies regardless of whether the file was dragged or clicked.isDragging state controls a CSS class that changes the border colour and background. Also consider adding onDragEnter in addition to onDragOver โ onDragEnter fires when the drag first enters the element, onDragOver fires continuously while hovering (which you need for preventDefault but may fire excessively).onDragLeave event fires when the cursor leaves any child element of the drop zone, not just the outer boundary. This causes the dragging state to flicker. A common fix is to use a dragenter counter: increment on enter, decrement on leave, and only toggle isDragging when the counter reaches zero. Alternatively, use a transparent overlay div that captures the drag events without the child element problem.The ImageUploader Component
// src/components/ui/ImageUploader.jsx
import { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import FileDropZone from './FileDropZone';
import api from '@/services/api';
function ImageUploader({
currentUrl,
uploadEndpoint,
fieldName = 'image',
maxSizeMB = 2,
aspectRatio = '1 / 1',
onUploadSuccess,
label = 'Upload Image',
}) {
const [file, setFile] = useState(null);
const [preview, setPreview] = useState(currentUrl || null);
const [progress, setProgress] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// Create and revoke object URL for preview
useEffect(() => {
if (!file) { setPreview(currentUrl || null); return; }
const url = URL.createObjectURL(file);
setPreview(url);
return () => URL.revokeObjectURL(url);
}, [file, currentUrl]);
const handleUpload = async () => {
if (!file) { setError('Please select an image first'); return; }
const fd = new FormData();
fd.append(fieldName, file); // fieldName must match Multer .single(fieldName)
setLoading(true);
setProgress(0);
setError('');
try {
const res = await api.post(uploadEndpoint, fd, {
onUploadProgress: (e) =>
setProgress(Math.round((e.loaded * 100) / e.total)),
});
onUploadSuccess(res.data.data);
setFile(null); // clear selected file after success
setProgress(0);
} catch (err) {
setError(err.response?.data?.message || 'Upload failed');
} finally {
setLoading(false);
}
};
return (
<div className="image-uploader">
<FileDropZone
onFileSelect={setFile}
maxSizeMB={maxSizeMB}
accept="image/jpeg,image/png,image/webp,image/gif"
>
{preview ? (
<img
src={preview}
alt="Preview"
className="image-uploader__preview"
style={{ aspectRatio }}
/>
) : (
<div className="image-uploader__placeholder">
<span>๐ท</span>
<p>{label}</p>
</div>
)}
</FileDropZone>
{/* Upload progress */}
{loading && (
<div className="upload-progress">
<div className="upload-progress__bar" style={{ width: `${progress}%` }}
role="progressbar" aria-valuenow={progress} aria-valuemax={100} />
<span className="upload-progress__label">{progress}%</span>
</div>
)}
{error && <p className="field-error">{error}</p>}
{/* Only show Upload button when a new file is selected */}
{file && !loading && (
<div className="image-uploader__actions">
<button type="button" onClick={() => setFile(null)} className="btn btn--ghost btn--sm">
Cancel
</button>
<button type="button" onClick={handleUpload} className="btn btn--primary btn--sm">
Upload
</button>
</div>
)}
</div>
);
}
ImageUploader.propTypes = {
currentUrl: PropTypes.string,
uploadEndpoint: PropTypes.string.isRequired,
fieldName: PropTypes.string,
maxSizeMB: PropTypes.number,
aspectRatio: PropTypes.string,
onUploadSuccess: PropTypes.func.isRequired,
label: PropTypes.string,
};
export default ImageUploader;
Using ImageUploader in Profile Settings
// src/pages/ProfileSettingsPage.jsx
import { useAuth } from '@/context/AuthContext';
import ImageUploader from '@/components/ui/ImageUploader';
function ProfileSettingsPage() {
const { user, updateUser } = useAuth();
const handleAvatarUpload = (data) => {
// data = { avatar: 'https://res.cloudinary.com/...', user: {...} }
updateUser({ avatar: data.avatar }); // update AuthContext
};
return (
<div className="profile-settings">
<h1>Profile Settings</h1>
<section>
<h2>Profile Photo</h2>
<ImageUploader
currentUrl={user?.avatar}
uploadEndpoint="/users/avatar"
fieldName="avatar"
maxSizeMB={2}
aspectRatio="1 / 1"
onUploadSuccess={handleAvatarUpload}
label="Click or drag to upload avatar"
/>
</section>
</div>
);
}
// Using ImageUploader in the Create Post form
function CreatePostPage() {
const [coverImageUrl, setCoverImageUrl] = useState('');
return (
<form>
{/* ... other fields ... */}
<div className="form-group">
<label>Cover Image</label>
<ImageUploader
currentUrl={coverImageUrl}
uploadEndpoint="/posts/cover"
fieldName="coverImage"
maxSizeMB={5}
aspectRatio="1200 / 630" {/* Open Graph ratio */}
onUploadSuccess={(data) => setCoverImageUrl(data.coverImage)}
label="Upload cover image (1200 ร 630 recommended)"
/>
{coverImageUrl && (
<input type="hidden" name="coverImage" value={coverImageUrl} />
)}
</div>
</form>
);
}
Common Mistakes
Mistake 1 โ Not cleaning up drag event listeners
โ Wrong โ drag state stuck on “dragging” when user drags off the window:
// If the user drags a file into the browser then out via the window edge,
// onDragLeave may not fire on the drop zone โ isDragging stays true forever
โ Correct โ also listen for dragleave on the window:
useEffect(() => {
const handleWindowDragLeave = () => setIsDragging(false);
window.addEventListener('dragleave', handleWindowDragLeave);
return () => window.removeEventListener('dragleave', handleWindowDragLeave);
}, []);
Mistake 2 โ Showing the upload button before a file is selected
โ Wrong โ Upload button always visible, enabled even with no file:
<button onClick={handleUpload}>Upload</button>
// Clicking with no file โ "Please select an image" error
โ Correct โ only show the Upload button when a file is staged:
{file && <button onClick={handleUpload}>Upload</button>} // โ
Mistake 3 โ Not updating AuthContext after avatar upload
โ Wrong โ avatar updated in MongoDB but Header still shows the old avatar:
const handleAvatarUpload = (data) => {
// New avatar on Cloudinary โ but AuthContext.user.avatar is still old URL
// Header will show old avatar until page refresh
};
โ Correct โ call updateUser from AuthContext after successful upload:
const handleAvatarUpload = (data) => {
updateUser({ avatar: data.avatar }); // โ updates AuthContext โ Header re-renders
};
Quick Reference
| Task | Code |
|---|---|
| Drag-and-drop file | e.dataTransfer.files[0] in the drop handler |
| Prevent browser file open | e.preventDefault() in dragover AND drop |
| Dragging indicator | Toggle class via isDragging state |
| Preview on select | URL.createObjectURL(file) โ revoke in cleanup |
| Upload + progress | api.post(endpoint, fd, { onUploadProgress }) |
| Update AuthContext | updateUser({ avatar: data.avatar }) |
| Avatar ratio | aspectRatio="1 / 1" |
| Cover OG ratio | aspectRatio="1200 / 630" |