Migrating from Axios
@data-client/rest replaces axios calls with typed endpoint definitions. Components still call them directly,
so how the app loads and stores data doesn't change; only the call shape does. A codemod converts the calls; the
steps below cover the rest.
Migrate with your coding agent
Paste this into your agent's chat:
The REST setup skill follows this guide: it runs the codemod, then works through the steps the codemod can't do. Review its diff like a teammate's.
Why migrate?
path template, and the rest become the query string. Calls don't change.const getComments = ({
userId,
postId,
sort,
}: {
userId: string;
postId: string;
sort?: 'newest' | 'top';
}) =>
axios.get( // ①
`/users/${encodeURIComponent(userId)}/posts/${encodeURIComponent(postId)}/comments`,
{ params: { sort } },
);
getComments({ userId: user.id, postId: post.id, sort: 'newest' });
const getComments = new RestEndpoint({
path: '/users/:userId/posts/:postId/comments', // ②
searchParams: {} as { sort?: 'newest' | 'top' },
});
getComments({ userId: user.id, postId: post.id, sort: 'newest' });
- ①Each parameter is typed, encoded and placed by hand, and nothing checks the template against the type.
- ②
userIdandpostIdare typed and encoded from the template, so a missing or misspelled one is a compile error.
- Typed end to end: path parameters, bodies and responses are typed from each definition.
- Less code:
resource()makes a full CRUD API from apathand aschema. - One place for request behavior: a base endpoint's methods replace interceptors and per-call config.
- Ready for the store: the same endpoints and schemas work with
useSuspense()and the normalized cache, whenever you adopt them.
Migrate step by step
These are the steps your agent follows. Commit before each one, so you can review its diff.
1. Install
- NPM
- Yarn
- pnpm
- esm.sh
npm install --save @data-client/rest
yarn add @data-client/rest
pnpm add @data-client/rest
<script type="importmap">
{
"imports": {
"@data-client/rest": "https://esm.sh/@data-client/rest"
}
}
</script>
2. Run the codemod
A jscodeshift codemod does the mechanical part. Commit first, then run it on your source folders, never the repo root:
npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/
params and the body become its arguments, and it resolves to the body, so .data goes away.import axios from 'axios';
const api = axios.create({ baseURL: 'https://api.example.com' });
export async function getPost(id: string) {
const res = await api.get(`/posts/${id}`);
return res.data;
}
export async function createPost(body: { title: string }) {
const { data } = await api.post('/posts', body);
return data;
}
import { RestEndpoint, RestGenerics } from '@data-client/rest';
class ApiEndpoint<O extends RestGenerics = any> extends RestEndpoint<O> {
urlPrefix = 'https://api.example.com';
}
export async function getPost(id: string) {
const res = await new ApiEndpoint({ path: '/posts/:id' })({ id });
return res;
}
export async function createPost(body: { title: string }) {
const data = await new ApiEndpoint({ path: '/posts', method: 'POST' })(
body,
);
return data;
}
What it can't convert, such as interceptors, timeout, isAxiosError() or a function that returns the axios
response to its callers, gets a // TODO(data-client): comment linking the section below that covers it. Search for
TODO(data-client) and resolve each one as you go through the next steps.
If the project wraps axios in its own class or function and never calls axios.get() or .post() directly, or only
calls axios(config), the codemod has little to do. Skip it and start at step 3.
3. Move interceptors into a base endpoint
Axios interceptors and instance config become methods on one RestEndpoint subclass that every endpoint extends. The
codemod creates this class from axios.create(); move the interceptors into it.
RestEndpoint parses JSON itself, so an interceptor that only unwraps response.data has nothing left to do.import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.example.com',
headers: { 'X-API-Key': 'my-key' },
});
api.interceptors.request.use(config => {
config.headers.Authorization = `Bearer ${getToken()}`;
return config;
});
api.interceptors.response.use(response => response.data);
export default api;
import { RestEndpoint, RestGenerics } from '@data-client/rest';
export default class ApiEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
urlPrefix = 'https://api.example.com';
getHeaders(headers: HeadersInit) { // ①
return {
...headers,
'X-API-Key': 'my-key',
Authorization: `Bearer ${getToken()}`,
};
}
}
- ①Runs before every request, like a request interceptor. It can be
async, to refresh a token first.
Response interceptors that change the body, such as converting snake_case keys, go in
process(); see snakes to camels.
When a response interceptor returned response.data, a caller's .data read a field of the body, not axios's response,
so keep it. The codemod marks these calls when the interceptor is in the same file; if a global
axios.interceptors.response lives in another file, check the .data it removed from calls there.
Server error messages
Axios codebases often show the user error.response.data.error or .message. Read it from the Response body once,
in the base class's fetchResponse(), so call sites get it from
error.message:
import {
NetworkError,
RestEndpoint,
RestGenerics,
} from '@data-client/rest';
export default class ApiEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
async fetchResponse(input: RequestInfo, init: RequestInit) {
try {
return await super.fetchResponse(input, init);
} catch (error) {
if (error instanceof NetworkError) {
const body = await error.response
.clone()
.json()
.catch(() => null);
// keep the NetworkError so `status` and `errorPolicy()` still work
error.message = body?.error ?? body?.message ?? error.message;
}
throw error;
}
}
}
Existing endpoint abstractions
A custom endpoint class that wraps axios (say, one with path, method and a toDynamicUrl() helper) can extend
RestEndpoint instead of being replaced. It keeps its backward-compatible methods and gains
url(), getRequestInit(),
fetchResponse() and parseResponse():
import { RestEndpoint, RestGenerics } from '@data-client/rest';
export class LegacyEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
urlPrefix = API_ROOT;
declare queryKey?: string;
/** @deprecated use url() */
toDynamicUrl = this.url;
}
const getUser = new LegacyEndpoint({
path: '/users/:id',
queryKey: 'user',
schema: User,
});
Pass extra members like queryKey as options rather than through a custom constructor, so
extend() (used by resource(), hookifyResource() and useCancelling()) keeps
working.
Context-based auth
When tokens come from React context (Okta, Auth0) rather than storage, getHeaders() can't
read them. Use hookifyResource() to add the headers through a
hook. See the
authentication guide for this and other patterns.
4. Define entities and resources
The codemod makes endpoints without a schema. Define an Entity for each type the API returns: its
fields, their types and its primary key. Replace each module's CRUD functions on one path with a
resource():
resource() replaces a module of CRUD functions, and schema types every response.import api from './api';
export interface Post {
id: string;
title: string;
body: string;
}
export const getPosts = () => api.get<Post[]>('/posts');
export const getPost = (id: string) => api.get<Post>(`/posts/${id}`);
export const createPost = (data: Omit<Post, 'id'>) =>
api.post<Post>('/posts', data);
export const updatePost = (id: string, data: Partial<Post>) =>
api.patch<Post>(`/posts/${id}`, data);
export const deletePost = (id: string) => api.delete(`/posts/${id}`);
import { Entity, resource } from '@data-client/rest';
import ApiEndpoint from './ApiEndpoint';
export class Post extends Entity {
id = '';
title = '';
body = '';
static key = 'Post';
}
export const PostResource = resource({ // ①
path: '/posts/:id',
schema: Post,
Endpoint: ApiEndpoint,
});
- ①Gives
getList,get,getList.push,update,partialUpdateanddelete, all typed.
Try the result against a mock API:
import { resource } from '@data-client/rest'; import ApiEndpoint from './ApiEndpoint'; import Post from './Post'; export const PostResource = resource({ path: '/posts/:id', schema: Post, Endpoint: ApiEndpoint, });
import { PostResource } from './PostResource'; PostResource.get({ id: '1' });
- x-api-key
- my-key
{
"id": "1",
"title": "Hello World",
"body": "First post"
}Nested paths like /projects/:projectId/tasks/:taskId get their own resource. Keep standalone new ApiEndpoint() for
operations that aren't CRUD, like search, custom actions and auth.
Don't define Entity classes and then leave schema unset on the endpoints: without schema, responses lose their
types, and the store can't normalize them when you adopt it.
Non-standard primary keys
Many APIs (MongoDB, for example) use _id instead of id. Override pk():
import { Entity } from '@data-client/rest';
export class User extends Entity {
_id = '';
name = '';
email = '';
static key = 'User';
pk() {
return this._id;
}
}
Body typing
Type the body of standalone POST, PUT and PATCH endpoints with body: {} as BodyType. Don't use
undefined as unknown as BodyType: RestEndpoint treats body: undefined as having
no body argument. resource() types its endpoints' bodies itself.
const createUser = new ApiEndpoint({
path: '/users',
method: 'POST',
body: {} as { name: string; email: string },
schema: User,
});
Coexisting with Zod or Yup
If the codebase already validates responses with Zod or Yup, choose one approach per type:
-
Zod in
process()(recommended): keep runtime validation by parsing inprocess(), and let the Entity handle normalization:const getUser = new ApiEndpoint({path: '/users/:id',schema: User,process(value: any) {return userSchema.parse(value);},}); -
Entity replaces Zod: move the field shape into the Entity class and remove the Zod schema. Entity fields give types, not runtime checks, so add
static validate()for any fields the server might send malformed. -
Zod only, no Entity: leave
schemaunset and parse manually. Only do this for endpoints that don't gain from normalization (auth tokens, one-off responses).
5. Update call sites
Components and services keep their own loading and state; only the call changes.
.data goes away, and parameters become one object.import { createPost, getPost, getPosts } from './api/posts';
const { data: posts } = await getPosts();
const { data: post } = await getPost('1');
const { data: created } = await createPost({ title: 'Hello', body: '' });
import { PostResource } from './api/posts';
const posts = await PostResource.getList();
const post = await PostResource.get({ id: '1' });
const created = await PostResource.getList.push({
title: 'Hello',
body: '',
});
Reading these endpoints through the store, with useSuspense() and the normalized cache, is a
separate step you can take later; see data dependencies.
6. Migrate error handling
NetworkError has the status and the raw Response, in place of axios's error.response.import axios from 'axios';
import { getPost } from './api/posts';
try {
const { data } = await getPost('1');
} catch (err) {
if (axios.isAxiosError(err)) {
console.log(err.response?.status);
console.log(err.response?.data);
}
}
import { NetworkError } from '@data-client/rest';
import { PostResource } from './api/posts';
try {
const post = await PostResource.get({ id: '1' });
} catch (err) {
if (err instanceof NetworkError) {
console.log(err.status);
console.log(err.response);
}
}
Read a server's error message from the body once, in the base endpoint; see server error messages.
7. Remove axios
Search for what still uses axios:
| Pattern | Finds |
|---|---|
TODO\(data-client\) | what the codemod left |
import.*from ['"]axios['"] | import statements |
axios\.create | instance creation |
axios\.(get|post|put|patch|delete) | direct calls |
\.interceptors\.(request|response)\.use | interceptors |
isAxiosError | error handling |
cancelToken|CancelToken | cancellation (deprecated) |
onUploadProgress|onDownloadProgress | progress callbacks |
Migrate each match with the reference below. When none are left, uninstall axios, then run the type checker and tests:
npm uninstall axios
Axios options reference
| Axios | @data-client/rest |
|---|---|
baseURL | urlPrefix |
headers config | getHeaders() |
interceptors.request | getHeaders() / getRequestInit() |
interceptors.response | parseResponse() / process() |
transformRequest | getRequestInit() |
transformResponse | process() |
isAxiosError / error.response | NetworkError with .status and .response |
cancelToken / signal | signal |
timeout | AbortSignal.timeout() in getRequestInit() |
responseType: 'blob' / 'arraybuffer' | content: 'blob' / 'arrayBuffer' |
params / paramsSerializer | searchParams / searchToString() |
auth: { username, password } | getHeaders() with btoa() |
validateStatus | Custom fetchResponse() |
xsrfCookieName / xsrfHeaderName | getHeaders() |
onUploadProgress | Custom fetchResponse() with XMLHttpRequest |
Cancellation
Both axios's signal and the deprecated CancelToken map to an
AbortController signal:
const source = axios.CancelToken.source();
axios.get('/users/1', { cancelToken: source.token });
source.cancel();
const getUser = new ApiEndpoint({ path: '/users/:id' });
const controller = new AbortController();
getUser.extend({ signal: controller.signal })({ id: '1' }); // ①
controller.abort();
- ①
extend()gives this one request its own signal; asignalset when the endpoint is created would abort all of its requests.
See the abort guide for more patterns.
Timeout
axios.get('/users', { timeout: 5000 });
export default class ApiEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
async getRequestInit( // ①
body?: RequestInit['body'] | Record<string, unknown>,
) {
const init = await super.getRequestInit(body);
const timeout = AbortSignal.timeout(5000);
const signal = init.signal // ②
? AbortSignal.any([init.signal, timeout])
: timeout;
return { ...init, signal };
}
}
- ①Runs for each request, so each one gets its own 5 seconds.
AbortSignal.timeout()passed to the constructor would start counting once, when the endpoint is created. - ②Keeps
extend({ signal })cancellation working: either signal aborts the request.
Binary responses
axios.get('/files/1', { responseType: 'blob' });
const downloadFile = new RestEndpoint({
path: '/files/:id',
content: 'blob',
});
content also takes 'arrayBuffer' and 'text'. See
file download to trigger a browser download.
Query serialization
axios.get('/users', {
params: { ids: [1, 2, 3] },
paramsSerializer: params =>
qs.stringify(params, { arrayFormat: 'repeat' }),
});
export default class ApiEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
searchToString(searchParams: Record<string, any>) {
return qs.stringify(searchParams, {
arrayFormat: 'repeat',
skipNulls: true,
});
}
}
axios leaves out null and undefined params, but the default searchToString() uses
URLSearchParams, which sends them as the strings
"null" and "undefined"; skipNulls above drops them. See searchToString().
Basic auth
axios.get('/api', { auth: { username: 'user', password: 'pass' } });
export default class BasicAuthEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
getHeaders(headers: HeadersInit) {
return {
...headers,
Authorization: `Basic ${btoa('user:pass')}`,
};
}
}
Accepting error statuses
fetchResponse() throws NetworkError for any status that
isn't ok. Override it to change what counts as an error:
axios.get('/api', { validateStatus: status => status < 500 });
import {
NetworkError,
RestEndpoint,
RestGenerics,
} from '@data-client/rest';
export default class LenientEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
async fetchResponse(input: RequestInfo, init: RequestInit) {
const response = await fetch(input, init);
if (response.status >= 500) throw new NetworkError(response);
return response;
}
}
CSRF headers
axios.create({
xsrfCookieName: 'csrftoken',
xsrfHeaderName: 'X-CSRFToken',
});
Read the cookie in getHeaders() for requests that aren't GET. See
Django Integration for the complete endpoint class.
Upload progress
fetch can't report upload progress, so use
XMLHttpRequest inside
fetchResponse(). The onProgress field is passed as an endpoint option, like
any other member.
axios.post('/upload', formData, {
onUploadProgress: e => console.log(e.loaded / e.total),
});
import {
NetworkError,
RestEndpoint,
RestGenerics,
} from '@data-client/rest';
export default class UploadEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
declare onProgress?: (progress: number) => void;
fetchResponse(input: RequestInfo, init: RequestInit) {
return new Promise<Response>((resolve, reject) => {
const xhr = new XMLHttpRequest();
const abort = () => xhr.abort();
const abortError = () =>
new DOMException('The operation was aborted.', 'AbortError');
if (init.signal?.aborted) return reject(abortError());
init.signal?.addEventListener('abort', abort, { once: true });
xhr.open(
init.method ?? 'POST',
typeof input === 'string' ? input : input.url,
);
new Headers(init.headers).forEach((value, key) =>
xhr.setRequestHeader(key, value),
);
xhr.onloadend = () =>
init.signal?.removeEventListener('abort', abort);
xhr.upload.onprogress = e => {
if (e.lengthComputable) this.onProgress?.(e.loaded / e.total);
};
xhr.onload = () => {
const headers = new Headers();
for (const line of xhr
.getAllResponseHeaders()
.trim()
.split(/\r?\n/)) {
const [key, ...rest] = line.split(': ');
if (key) headers.append(key, rest.join(': '));
}
// 204, 205 and 304 responses can't have a body
const body = [204, 205, 304].includes(xhr.status)
? null
: xhr.response;
const response = new Response(body, {
status: xhr.status,
statusText: xhr.statusText,
headers,
});
if (response.ok) resolve(response);
else reject(new NetworkError(response));
};
xhr.onerror = () => reject(new TypeError('Network request failed'));
xhr.onabort = () => reject(abortError());
xhr.send(init.body as XMLHttpRequestBodyInit | null);
});
}
}
const uploadFile = new UploadEndpoint({
path: '/upload',
method: 'POST',
body: {} as FormData,
onProgress: (progress: number) => console.log(progress),
});
Related guides
- Authentication: token and cookie auth patterns
- Aborting Fetch: cancellation and debouncing
- Transforming data on fetch: response transforms, field renaming, file downloads
- Django Integration: CSRF and cookie auth for Django