Documentation
Uploads
Send FormData, upload files, and track upload progress with a custom fetcher.
Uploading files with FormData
For common uploads, set bodyType: "form-data" and return a plain object from the body mapper.
micro-rq converts that object to FormData automatically.
The endpoint variables can include a File, Blob, metadata, and any IDs needed to build the path.
Key points
- Set
bodyTypeto"form-data"for the simple upload case. - Return a plain object from
body; files and blobs are appended automatically. - Manual
FormDatais still supported for advanced multipart cases. - Use endpoint headers only for metadata headers, not multipart boundaries.
- The response type is still the first endpoint generic.
1type UploadAvatarInput = {2 userId: string;3 file: File;4};5 6type UploadedAvatar = {7 url: string;8};9 10export const users = api.resource("users", {11 uploadAvatar: api.post<UploadedAvatar, UploadAvatarInput>(12 ({ userId }) => `/users/${userId}/avatar`,13 {14 bodyType: "form-data",15 body: ({ file }) => ({16 avatar: file,17 }),18 },19 ),20});1const uploadAvatar = useMutation({2 ...users.uploadAvatar.toMutation(),3 onSuccess: () => {4 queryClient.invalidateQueries({5 queryKey: users.detail.baseKey(),6 });7 },8});9 10uploadAvatar.mutate({11 userId: "user-1",12 file,13});Info
Manual FormData is still supported
You can still create FormData manually inside the body mapper when the multipart shape is advanced or needs custom field handling.
Use bodyType: "form-data" for the common case. Use manual FormData when you need full control.
1type UploadGalleryInput = {2 albumId: string;3 cover: File;4 photos: File[];5 caption?: string;6};7 8export const gallery = api.resource("gallery", {9 upload: api.post<UploadResult, UploadGalleryInput>(10 ({ albumId }) => `/albums/${albumId}/gallery`,11 {12 body: ({ cover, photos, caption }) => {13 const formData = new FormData();14 formData.append("cover", cover);15 16 for (const photo of photos) {17 formData.append("photos[]", photo);18 }19 20 if (caption) {21 formData.append("caption", caption);22 }23 24 return formData;25 },26 },27 ),28});Warning
Do not set multipart content-type manually
Do not add content-type: multipart/form-data yourself.
When the request body is FormData, the browser must add the multipart boundary. If you set the header manually, the server may receive an invalid upload body.
Upload progress
The default browser fetch API does not expose upload progress events.
If you need upload progress, keep API definitions outside UI and use a custom upload fetcher that writes progress into an app-level store.
Use api.extend() when the upload client should inherit the normal API config and only override name or fetcher.
The component should still use useMutation() normally. It can read progress from a small app hook such as useUploadProgress().
Key points
useUploadProgressis an app hook, not a micro-rq API.- Use
XMLHttpRequest.upload.onprogressinside the custom fetcher. - Store progress by upload key, endpoint name, or request ID.
- TanStack Query still owns mutation pending, success, and error state.
1// api/upload-progress.ts2import { useSyncExternalStore } from "react";3 4const progressByKey = new Map<string, number>();5const listeners = new Set<() => void>();6 7export function setUploadProgress(key: string, percent: number) {8 progressByKey.set(key, percent);9 listeners.forEach((listener) => listener());10}11 12export function resetUploadProgress(key: string) {13 progressByKey.delete(key);14 listeners.forEach((listener) => listener());15}16 17export function useUploadProgress(key: string) {18 return useSyncExternalStore(19 (listener) => {20 listeners.add(listener);21 return () => listeners.delete(listener);22 },23 () => progressByKey.get(key) ?? 0,24 () => 0,25 );26}1// api/upload-fetcher.ts2import { setUploadProgress } from "./upload-progress";3 4export function createUploadFetcher(progressKey: string): typeof fetch {5 return (input, init) =>6 new Promise<Response>((resolve, reject) => {7 const xhr = new XMLHttpRequest();8 xhr.open(init?.method ?? "GET", String(input));9 10 new Headers(init?.headers).forEach((value, key) => {11 xhr.setRequestHeader(key, value);12 });13 14 xhr.upload.onprogress = (event) => {15 if (event.lengthComputable) {16 setUploadProgress(progressKey, Math.round((event.loaded / event.total) * 100));17 }18 };19 20 xhr.onload = () => {21 resolve(22 new Response(xhr.responseText, {23 status: xhr.status,24 statusText: xhr.statusText,25 headers: {26 "content-type": xhr.getResponseHeader("content-type") ?? "text/plain",27 },28 }),29 );30 };31 32 xhr.onerror = () => reject(new TypeError("Upload failed"));33 xhr.send(init?.body ?? null);34 });35}1// api/uploads.ts2import { api } from "./api";3import { createUploadFetcher } from "./upload-fetcher";4 5export type UploadAvatarInput = {6 file: File;7};8 9export type UploadedAvatar = {10 url: string;11};12 13export const uploadApi = api.extend({14 name: "uploads",15 fetcher: createUploadFetcher("avatar"),16});17 18export const uploads = uploadApi.resource("uploads", {19 avatar: uploadApi.post<UploadedAvatar, UploadAvatarInput>(20 "/uploads/avatar",21 {22 bodyType: "form-data",23 body: ({ file }) => ({24 avatar: file,25 }),26 },27 ),28});1// components/AvatarUploader.tsx2import { useMutation } from "@tanstack/react-query";3import { resetUploadProgress, useUploadProgress } from "../api/upload-progress";4import { uploads } from "../api/uploads";5 6export function AvatarUploader() {7 const progress = useUploadProgress("avatar");8 9 const uploadAvatar = useMutation({10 ...uploads.avatar.toMutation(),11 onMutate: () => resetUploadProgress("avatar"),12 onSuccess: () => {13 // Upload completed. You can invalidate related queries here.14 },15 onError: () => resetUploadProgress("avatar"),16 });17 18 return (19 <form20 onSubmit={(event) => {21 event.preventDefault();22 23 const file = new FormData(event.currentTarget).get("file");24 25 if (file instanceof File) {26 uploadAvatar.mutate({ file });27 }28 }}29 >30 <input name="file" type="file" />31 <button disabled={uploadAvatar.isPending} type="submit">32 Upload33 </button>34 35 {uploadAvatar.isPending ? (36 <progress max={100} value={progress}>37 {progress}%38 </progress>39 ) : null}40 </form>41 );42}