Uploading Files from React — File Input and FormData

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>
  );
}
Note: 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.
Tip: Style the file input with 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.
Warning: Client-side file validation (checking type and size before upload) improves UX by giving instant feedback, but it is not a security measure. Always re-validate on the server with Multer’s 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()

🧠 Test Yourself

You call fd.append('avatar', e.target.files) instead of fd.append('avatar', e.target.files[0]). The upload request reaches the server but Multer cannot find the file. Why?