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.