Avatar and Cover Image Upload for the MERN Blog

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;
Note: The drag-and-drop API requires calling 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.
Tip: For better UX, show a visual indicator when the user drags a file over the drop zone โ€” the 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).
Warning: The 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"

🧠 Test Yourself

A user successfully uploads a new avatar. The MongoDB document is updated with the Cloudinary URL and the API returns success. But the user’s avatar in the Header still shows the old image. What needs to happen?