Skip to main content

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:

Copy prompt for your coding agent

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?​

The URL is declared once: path parameters are typed and encoded from the path template, and the rest become the query string. Calls don't change.
Before
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' });
After
const getComments = new RestEndpoint({
path: '/users/:userId/posts/:postId/comments', // ②
searchParams: {} as { sort?: 'newest' | 'top' },
});

getComments({ userId: user.id, postId: post.id, sort: 'newest' });
  1. ①Each parameter is typed, encoded and placed by hand, and nothing checks the template against the type.
  2. ②userId and postId are 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 a path and a schema.
  • 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 install --save @data-client/rest
Run in your terminal

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/
Run in your terminal
Each axios call becomes a call to an endpoint. Path params, params and the body become its arguments, and it resolves to the body, so .data goes away.
Before
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;
}
After
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.
Before
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;
After
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()}`,
};
}
}
  1. ①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:

ApiEndpoint.ts
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():

One resource() replaces a module of CRUD functions, and schema types every response.
Before
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}`);
After
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,
});
  1. ①Gives getList, get, getList.push, update, partialUpdate and delete, 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,
});
▶Usage
import { PostResource } from './PostResource';
PostResource.get({ id: '1' });
Request
GET https://api.example.com/posts/1
content-type: application/json
x-api-key: my-key
Response200 OK
{
"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.

warning

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 in process(), 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 schema unset 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.

Endpoints resolve to the parsed body, so .data goes away, and parameters become one object.
Before
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: '' });
After
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.
Before
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);
}
}
After
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:

PatternFinds
TODO\(data-client\)what the codemod left
import.*from ['"]axios['"]import statements
axios\.createinstance creation
axios\.(get|post|put|patch|delete)direct calls
\.interceptors\.(request|response)\.useinterceptors
isAxiosErrorerror handling
cancelToken|CancelTokencancellation (deprecated)
onUploadProgress|onDownloadProgressprogress callbacks

Migrate each match with the reference below. When none are left, uninstall axios, then run the type checker and tests:

npm uninstall axios
Run in your terminal

Axios options reference​

Axios@data-client/rest
baseURLurlPrefix
headers configgetHeaders()
interceptors.requestgetHeaders() / getRequestInit()
interceptors.responseparseResponse() / process()
transformRequestgetRequestInit()
transformResponseprocess()
isAxiosError / error.responseNetworkError with .status and .response
cancelToken / signalsignal
timeoutAbortSignal.timeout() in getRequestInit()
responseType: 'blob' / 'arraybuffer'content: 'blob' / 'arrayBuffer'
params / paramsSerializersearchParams / searchToString()
auth: { username, password }getHeaders() with btoa()
validateStatusCustom fetchResponse()
xsrfCookieName / xsrfHeaderNamegetHeaders()
onUploadProgressCustom fetchResponse() with XMLHttpRequest

Cancellation​

Both axios's signal and the deprecated CancelToken map to an AbortController signal:

Before
const source = axios.CancelToken.source();
axios.get('/users/1', { cancelToken: source.token });
source.cancel();
After
const getUser = new ApiEndpoint({ path: '/users/:id' });
const controller = new AbortController();
getUser.extend({ signal: controller.signal })({ id: '1' }); // ①
controller.abort();
  1. ①extend() gives this one request its own signal; a signal set when the endpoint is created would abort all of its requests.

See the abort guide for more patterns.

Timeout​

Before
axios.get('/users', { timeout: 5000 });
After
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 };
}
}
  1. ①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.
  2. ②Keeps extend({ signal }) cancellation working: either signal aborts the request.

Binary responses​

Before
axios.get('/files/1', { responseType: 'blob' });
After
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​

Before
axios.get('/users', {
params: { ids: [1, 2, 3] },
paramsSerializer: params =>
qs.stringify(params, { arrayFormat: 'repeat' }),
});
After
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​

Before
axios.get('/api', { auth: { username: 'user', password: 'pass' } });
After
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:

Before
axios.get('/api', { validateStatus: status => status < 500 });
After
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​

Before
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.

Before
axios.post('/upload', formData, {
onUploadProgress: e => console.log(e.loaded / e.total),
});
UploadEndpoint.ts
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),
});