-
Notifications
You must be signed in to change notification settings - Fork 0
Migration Guide
// axios
const client = axios.create({
baseURL: "https://api.example.com",
timeout: 30000,
headers: { "X-App": "web" },
withCredentials: true,
});
// api-client
const api = createClient({
baseUrl: "https://api.example.com",
timeout: 30_000,
headers: { "X-App": "web" },
credentials: "include",
});// axios
const { data } = await client.get("/users", { params: { page: 1 } });
// api-client
const { data } = await api.get<User[]>("/users", { params: { page: 1 } });Both destructure data, but the surrounding envelope differs:
| axios | api-client |
|---|---|
res.data — the raw body |
res.data — the body, with { data } unwrapped |
res.status — a number |
res.statusCode — a number; res.status is a boolean
|
res.headers |
res.headers (lowercased keys) |
res.statusText |
res.message (from the body, not the status line) |
The
statusrename is the most common migration bug. In axiosstatus === 200; herestatus === true.
// axios
catch (e) {
if (axios.isAxiosError(e)) {
e.response?.status;
e.response?.data;
}
}
// api-client
catch (e) {
if (e instanceof ApiError) {
e.statusCode;
e.data;
e.errors; // field-level validation errors, extracted for you
}
}Global interceptors are Plugins (beforeRequest / afterResponse); much of what they're usually written for is built in or a single option.
| axios | api-client |
|---|---|
interceptors.request.use / interceptors.response.use
|
A plugin with beforeRequest / afterResponse
|
| Request interceptor adding a token | Built in — login() / setTokens()
|
| Request interceptor adding headers |
headers option, or per-request headers
|
| Request interceptor transforming the body | beforeFunc |
| Response interceptor transforming data | afterFunc |
| Response interceptor handling 401 + refresh | Built in — see Token Refresh |
| Response interceptor for global errors | onError |
transformRequest / transformResponse
|
beforeFunc / afterFunc
|
The classic axios refresh interceptor:
// axios — 40 lines of queue management, and a race condition if you get it wrong
client.interceptors.response.use(null, async (error) => {
if (error.response?.status === 401 && !error.config._retry) {
error.config._retry = true;
const { data } = await axios.post("/auth/refresh", { refresh });
localStorage.setItem("token", data.access);
error.config.headers.Authorization = `Bearer ${data.access}`;
return client(error.config);
}
return Promise.reject(error);
});// api-client
const api = createClient({ baseUrl, refreshUrl: "/auth/refresh" });// axios
const source = axios.CancelToken.source();
client.get("/users", { cancelToken: source.token });
source.cancel();
// api-client — the standard AbortController
const controller = new AbortController();
api.get("/users", { signal: controller.signal });
controller.abort();
// …or the built-in registry, with no controller to carry around
const api = createClient({ baseUrl, cancel: true });
api.get("/users");
api.cancel("/users"); // by URL pattern, key or group
api.cancel(); // everything in flightCloser to axios's source.cancel() for a group of requests: a scope.
const scope = api.cancelScope("users-page");
scope.get("/users");
scope.get("/users/roles");
scope.cancel();See Cancellation.
axios uses paramsSerializer for nested params. Here it's built in, and matches the qs bracket style by default.
// axios
client.get("/users", {
params: { filter: { active: true } },
paramsSerializer: (p) => qs.stringify(p, { arrayFormat: "repeat" }),
});
// api-client
api.get("/users", { params: { filter: { active: true } } });
// → /users?filter[active]=true// axios
client.post("/upload", form, { headers: { "Content-Type": "multipart/form-data" } });
// api-client — the header is dropped so the runtime sets the boundary
api.post("/upload", form);Manually setting
multipart/form-datawithout a boundary is a classic axios bug. This client removes such a header rather than let the request corrupt.
// axios
client.get("/users", { validateStatus: () => true });
// api-client
api.get("/users", { throwError: false });| axios | api-client |
|---|---|
baseURL |
baseUrl |
withCredentials: true |
credentials: "include" (or authMode: "cookie") |
xsrfCookieName |
xsrfCookieName (same name) |
xsrfHeaderName |
xsrfHeaderName (same name) |
paramsSerializer |
built in |
validateStatus |
throwError |
cancelToken |
signal, or the cancel option + api.cancel()
|
source.cancel() |
api.cancel(selector) / scope.cancel()
|
axios.isCancel(e) |
e.canceled |
responseType |
responseType ("auto" default: JSON, text, or a Blob for binary) |
maxRedirects |
redirect |
transformRequest |
beforeFunc |
transformResponse |
afterFunc |
isAxiosError(e) |
e instanceof ApiError |
e.response.status |
e.statusCode |
e.response.data |
e.data |
// before
async function getUsers() {
const token = localStorage.getItem("token");
const res = await fetch(`${BASE}/users?page=1`, {
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
});
if (res.status === 401) {
const t = await refreshToken(); // racy under concurrency
return getUsers(); // potentially infinite
}
if (!res.ok) throw new Error(await res.text());
return (await res.json()).data;
}
// after
const { data } = await api.get<User[]>("/users", { params: { page: 1 } });You also gain: refresh coalescing, timeouts, worker isolation, cross-tab sync, typed errors and nested param serialization.
throwError now defaults to true, so failed requests reject instead of resolving. Restore the old behaviour in one line:
export const api = createClient({ baseUrl: "…", throwError: false });Everything else is unchanged.
Replace status checks with try/catch:
// before
const res = await api.get("/users");
if (!res.status) return handle(res.message);
use(res.data);
// after
try {
const { data } = await api.get("/users");
use(data);
} catch (e) {
handle((e as ApiError).message);
}Flip the client default and opt individual call sites back out while you convert them:
export const api = createClient({ throwError: true });
// not yet converted
const res = await api.get("/legacy", { throwError: false });
if (!res.status) { /* old style */ }Then remove the flags one file at a time. A quick sweep to find what needs attention:
rg 'await api\.\w+\([^)]*\)' -n src | rg -v 'try|catch' | head -50
rg '\.status\b' -n src # boolean here, not a numberOnly the queryFn changes:
// before
queryFn: () => axios.get("/users").then((r) => r.data),
// after
queryFn: ({ signal }) => api.get<User[]>("/users", { signal }),
select: (res) => res.data,Using select keeps the envelope out of your components while still letting you reach statusCode and headers in the query cache when you need them.
Retry logic gets simpler:
// before
retry: (n, e) => (axios.isAxiosError(e) && e.response?.status! < 500 ? false : n < 3),
// after
retry: (n, e) => (e instanceof ApiError && e.statusCode < 500 ? false : n < 3),Next: Troubleshooting
Getting started
Requests
Authentication
Advanced
- Web Worker Isolation
- Multi-Tab Sync
- WebSockets and Socket.io
- Plugins
- Logging and Observability
- Security Model
Reference
Guides