-
Notifications
You must be signed in to change notification settings - Fork 0
Requests
api.get<R>(url, config?)
api.post<R>(url, body?, config?)
api.put<R>(url, body?, config?)
api.patch<R>(url, body?, config?)
api.delete<R>(url, config?)All five resolve with Promise<IRes<R>>. R is the type of res.data after unwrapping and after your afterFunc — annotate it and everything downstream is typed.
const { data } = await api.get<User[]>("/users");
// ^? User[] | undefined
datais optional because a 204, an empty body, or a non-JSON response legitimately produces no payload.
GET and DELETE take no body argument. If you need a body on DELETE, most servers accept the identifier in the URL or query string instead:
await api.delete("/items", { params: { ids: [1, 2, 3] } });Four inputs combine, in this order:
addToUrl → addTemplateToUrl → baseUrl join → params
await api.get("/users/{id}/posts/{postId}", {
addTemplateToUrl: { id: 42, postId: 7 },
});
// → /users/42/posts/7Every occurrence of {key} is replaced. This is the clearest way to build resource URLs and it keeps the route readable in logs.
Values are URL-encoded as a single path segment, so user input can't change the route: { id: "1/../admin?x=" } becomes /users/1%2F..%2Fadmin%3Fx%3D, not /admin?x=. Substitution is single-pass: a value that itself contains {postId} is left as text.
await api.get("/users", { addToUrl: [42, "posts"] });
// → /users/42/posts/Each segment is URL-encoded, so "a/b" stays one segment (a%2Fb). Note the trailing slash — this style targets Django-REST-style APIs. If you don't want it, use addTemplateToUrl or plain string interpolation.
Falsy segments throw.
addToUrl: [userId]withuserId === undefinedraisesaddToUrl contains a falsy segment at index 0: [null].0is explicitly allowed, since it is a valid ID.
createClient({ baseUrl: "https://api.example.com/v1/" });
await api.get("/users"); // → https://api.example.com/v1/users
await api.get("users"); // → https://api.example.com/v1/usersTrailing slashes on the base and leading slashes on the path are normalized, never doubled or dropped.
An absolute URL always wins and bypasses the base entirely:
await api.get("https://cdn.example.com/manifest.json");The access token and CSRF header only go to trusted origins — the baseUrl origin, the page origin and authOrigins. A request to any other host, like the CDN above, goes out without them.
Override the base for a single call:
await api.get("/status", { baseUrl: "https://other.example.com" });await api.get("/users", { params: { page: 2, q: "ada" } });
// → /users?page=2&q=adaIf the URL already has a query string, params are appended with &.
The serializer is a dependency-free port of the qs bracket style.
{ name: "x", wallet: { balance: 0, tokens: ["BTC", "USDT"] } }
// → name=x&wallet[balance]=0&wallet[tokens]=BTC&wallet[tokens]=USDT
// (brackets are percent-encoded on the wire){ tags: ["a", "b", "c"] }
// → tags=a&tags=b&tags=c{ filters: [{ field: "age", op: "gt" }] }
// → filters[field]=age&filters[op]=gt{ since: new Date("2024-01-01") }
// → since=2024-01-01T00%3A00%3A00.000Znull, undefined and "" are omitted entirely — including inside nested objects and arrays.
{ page: 1, q: "", filter: { active: true, role: null } }
// → page=1&filter[active]=trueThis is deliberate: an empty search box should not send q=, which many backends treat as "match the empty string".
0andfalseare kept — they are meaningful values, not empty ones.
import { buildQueryString } from "@mrzr/api-client";
buildQueryString({ a: 1, b: { c: [1, 2] } });
// "a=1&b%5Bc%5D=1&b%5Bc%5D=2"Params<T> includes a typed ordering field for list endpoints:
await api.get<ListResponse<User>>("/users", {
params: { ordering: { createdAt: "desc" } },
});
// → /users?ordering[createdAt]=descBy default, a body is JSON.stringifyed and sent as application/json.
await api.post("/users", { name: "Ada", role: "admin" });These types are detected and passed to fetch untouched, never stringified:
FormData · File / Blob · ArrayBuffer · typed arrays (Uint8Array, DataView, …) · URLSearchParams · ReadableStream · string
See Uploads and Binary Bodies for content-type handling and the long-upload story.
Force raw passthrough for anything else:
await api.post("/raw", myThing, { stringifyBody: false });Default: 30 000 ms, per attempt.
createClient({ timeout: 10_000 }); // client-wide
await api.get("/slow", { timeout: 60_000 }); // this call
await api.post("/upload", form, { timeout: 0 }); // no timeoutA timeout produces statusCode: 408 with message "Request timed out" (or an ApiError with the same).
The budget is per attempt. A request that 401s, refreshes and retries gets a fresh timeout for the retry.
Two ways, and they compose.
Your own AbortSignal — combined with the internal timeout signal (via AbortSignal.any where available, with a manual fallback otherwise):
const controller = new AbortController();
const promise = api.get("/search", { params: { q }, signal: controller.signal });
controller.abort(); // → statusCode 0, canceled: true, "Request aborted"The built-in registry — opt in once, then cancel by URL pattern, scope or key, with no controller to carry around:
const api = createClient({ baseUrl, cancel: true });
api.cancel(); // everything in flight
api.cancel("/api/v1/products"); // the product screen's requests
api.cancel("search"); // by cancelKey or cancelGroup// modals and widgets
const scope = api.cancelScope("product-modal");
await scope.get("/api/v1/products/12");
scope.cancel();
// stale searches
await api.get("/search", { params: { q }, cancelKey: "search", takeLatest: true });Both paths set canceled: true and resolve rather than reject — even under the default throwError: true — so check res.canceled before using res.data. Neither fires onError: navigating away is not a failure the user should see. A timeout stays distinct at 408, and still throws.
In worker mode, cancelling posts an abort message to the worker, which aborts the real fetch — cancellation is not merely cosmetic.
React Query's queryFn receives a signal; wire it straight through:
queryFn: ({ signal }) => api.get<User[]>("/users", { signal }),Full guide: Cancellation.
These RequestInit fields are forwarded verbatim:
cache · integrity · keepalive · mode · redirect · referrer · referrerPolicy · window
await api.get("/data", { cache: "no-store", mode: "cors" });The list is an explicit whitelist, so app-level options never leak into fetch and future spec additions can't silently collide.
credentialsis not per-request — it is derived fromauthModeand settable client-wide, so auth behaviour stays consistent.body,method,headersandsignalare managed by the client (signalis merged, not replaced).
await api.post<User>("/users", input, {
beforeFunc: (body) => ({ ...body, tenant: currentTenant }), // outgoing
afterFunc: (data) => camelCaseKeys(data), // incoming
beforeSelectOptions: (data) => data.map(toOption), // incoming, runs first
});-
beforeFuncruns before serialization, so it can return aFormDataor aBlob. -
beforeSelectOptionsthenafterFuncrun after parsing and unwrapping. - Both incoming hooks run only on success.
- In worker mode these functions cannot be structured-cloned, so they are applied on the main thread —
beforeFuncbefore the body is posted in, the others after the result comes back. The observable behaviour is identical.
const { headers } = await api.get("/users");
headers?.["x-total-count"]; // keys are always lowercased
headers?.["content-type"];await api.get("/public/health", { skipAuth: true });No Authorization header, and no proactive refresh. Use it for public endpoints and for auth endpoints you call yourself.
Next: Request Config
Getting started
Requests
Authentication
Advanced
- Web Worker Isolation
- Multi-Tab Sync
- WebSockets and Socket.io
- Plugins
- Logging and Observability
- Security Model
Reference
Guides