Avatar Upload — Profile Image with Crop and Preview

The avatar upload flow has a specific challenge: after a successful upload, the new avatar URL needs to reach every component that shows the user’s avatar — the header, the profile page, every post card with the author’s photo. Since all of these read from the Zustand auth store, updating the store’s user.avatar_url field propagates the change everywhere simultaneously. The upload itself uses a standard multipart form-data POST that FastAPI’s UploadFile parameter handles (Chapter 30).

Avatar Upload with RTK Query Mutation

// src/store/apiSlice.js — add avatar endpoint
uploadAvatar: builder.mutation({
    query: (formData) => ({
        url:    "/users/me/avatar",
        method: "POST",
        body:   formData,         // FormData object — RTK Query handles multipart
        formData: true,           // tells RTK Query not to JSON-encode the body
    }),
    invalidatesTags: [{ type: "User", id: "ME" }],
}),
// src/pages/ProfilePage.jsx (partial)
import { useState, useCallback }        from "react";
import { useUploadAvatarMutation }      from "@/store/apiSlice";
import { useAuthStore }                 from "@/stores/authStore";
import { useToast }                     from "@/context/ToastContext";
import ImagePicker                      from "@/components/ui/ImagePicker";

function AvatarUploadSection() {
    const user                   = useAuthStore((s) => s.user);
    const setUser                = useAuthStore((s) => s.setUser);   // add this action
    const toast                  = useToast();
    const [selectedFile, setSelectedFile] = useState(null);
    const [uploadAvatar, { isLoading }]   = useUploadAvatarMutation();

    async function handleUpload() {
        if (!selectedFile) return;

        const formData = new FormData();
        formData.append("avatar", selectedFile);

        try {
            const updatedUser = await uploadAvatar(formData).unwrap();
            // Update Zustand store so all components see the new avatar immediately
            setUser(updatedUser);
            setSelectedFile(null);
            toast.success("Avatar updated!");
        } catch (err) {
            toast.error(
                err.status === 413 ? "Image too large (max 5 MB)"
                : err.status === 415 ? "Unsupported format — use JPEG, PNG or WebP"
                : "Upload failed — please try again"
            );
        }
    }

    return (
        <div className="flex items-start gap-6">
            {/* Current avatar */}
            <div className="flex-shrink-0">
                <img
                    src={user?.avatar_url ?? `https://ui-avatars.com/api/?name=${user?.name}`}
                    alt={user?.name}
                    className="w-20 h-20 rounded-full object-cover border-2 border-gray-200"
                    onError={(e) => {
                        e.target.src = `https://ui-avatars.com/api/?name=${user?.name}`;
                    }}
                />
            </div>

            {/* Picker + upload button */}
            <div className="space-y-3">
                <ImagePicker
                    label="Choose new avatar"
                    onChange={setSelectedFile}
                />
                {selectedFile && (
                    <button
                        type="button"
                        onClick={handleUpload}
                        disabled={isLoading}
                        className="px-4 py-2 bg-blue-600 text-white text-sm
                                   rounded-lg disabled:opacity-50"
                    >
                        {isLoading ? "Uploading…" : "Save Avatar"}
                    </button>
                )}
            </div>
        </div>
    );
}
Note: RTK Query’s fetchBaseQuery normally serialises the request body as JSON. When sending a FormData object, you must either set formData: true in the query config (RTK Query sets the correct multipart Content-Type automatically) or pass raw body: formData without a Content-Type header override — the browser sets the correct boundary in the Content-Type header when given a FormData body. Never manually set Content-Type: "multipart/form-data" without the boundary — the request will fail.
Tip: Add a setUser action to the Zustand auth store for cases where the user data changes from a server response: setUser: (user) => set({ user }). This is used after avatar upload and after profile updates to keep the store in sync with the server without requiring a full /api/users/me refetch. The RTK Query invalidatesTags: [{ type: "User", id: "ME" }] also triggers a refetch of any component using useGetCurrentUserQuery().
Warning: The FormData field name must match what FastAPI expects. If FastAPI’s endpoint uses avatar: UploadFile = File(...), the FormData field must be named "avatar": formData.append("avatar", file). If there is a mismatch (e.g., you use "file" but FastAPI expects "avatar"), FastAPI returns a 422 Unprocessable Entity with a “field required” error. Always verify field names against the FastAPI endpoint definition.

Optimistic Avatar Update

async function handleUpload() {
    if (!selectedFile) return;

    // ── Optimistic update — show preview immediately ───────────────────────────
    const previewUrl   = URL.createObjectURL(selectedFile);
    const previousUser = user;
    setUser({ ...user, avatar_url: previewUrl });   // instant visual update

    const formData = new FormData();
    formData.append("avatar", selectedFile);

    try {
        const updatedUser = await uploadAvatar(formData).unwrap();
        setUser(updatedUser);                   // replace preview with real URL
        URL.revokeObjectURL(previewUrl);         // free preview memory
    } catch (err) {
        setUser(previousUser);                  // rollback on failure
        URL.revokeObjectURL(previewUrl);
        toast.error("Upload failed");
    }
}

Common Mistakes

Mistake 1 — Setting Content-Type manually for multipart (breaks boundary)

❌ Wrong — manually set Content-Type removes the boundary parameter:

headers: { "Content-Type": "multipart/form-data" }
// Missing boundary! FastAPI cannot parse the body → 422

✅ Correct — let the browser set Content-Type by not specifying it.

Mistake 2 — FormData field name does not match FastAPI parameter

❌ Wrong:

formData.append("file", selectedFile);   // FastAPI expects "avatar"!

✅ Correct — match the field name to the FastAPI parameter name.

🧠 Test Yourself

After a successful avatar upload, the PostCard component still shows the old avatar. The header shows the new one. Why the discrepancy and how do you fix it?