Skip to main content

Migrating from Axios

@data-client/rest replaces axios with a declarative, type-safe approach to REST APIs.

AI-assisted migration​

Install the REST setup skill to automate the migration with your AI coding assistant. It auto-detects axios in your project and runs the codemod for deterministic transforms, then guides you through the manual steps that require judgment (interceptors, error handling, schema definitions, etc.).

npx skills add reactive/data-client \
--skill data-client-schema \
--skill data-client-rest-setup \
--skill data-client-rest

Then run skill /data-client-rest-setup to start the migration. It will detect axios and apply the appropriate migration sub-procedure automatically.

Why migrate?​

Type-safe paths​

With axios, API paths are opaque strings — typos and missing parameters are only caught at runtime:

// axios: no type checking — typo silently produces wrong URL
axios.get(`/users/${usrId}`);

With RestEndpoint, path parameters are inferred from the path template and enforced at compile time:

const getUser = new RestEndpoint({ path: '/users/:id', schema: User });
// TypeScript enforces { id: string } — typos are compile errors
getUser({ id: '1' });

This also means IDE autocomplete works for every path parameter.

Additional benefits​

  • Normalized cache — shared entities are deduplicated and updated everywhere automatically
  • Declarative data dependencies — components declare what data they need via useSuspense(), not how to fetch it
  • Optimistic updates — instant UI feedback before the server responds
  • Zero boilerplate — resource() generates a full CRUD API from a path and schema

Quick reference​

Axios@data-client/rest
baseURLurlPrefix
headers configgetHeaders()
interceptors.requestgetRequestInit() / getHeaders()
interceptors.responseparseResponse() / process()
timeoutAbortSignal.timeout() via signal
params / paramsSerializersearchParams / searchToString()
cancelToken / signalsignal (AbortController)
responseType: 'blob' / 'arraybuffer'content: 'blob' / 'arrayBuffer' — see file download
auth: { username, password }getHeaders() with btoa()
xsrfCookieName / xsrfHeaderNamegetHeaders() — see Django Integration
transformRequestgetRequestInit()
transformResponseprocess()
validateStatusCustom fetchResponse()
onUploadProgressCustom fetchResponse() using XMLHttpRequest
isAxiosError / error.responseNetworkError with .status and .response

Migration examples​

Basic GET​

api.ts
import axios from 'axios';

export const getUser = (id: string) =>
axios.get(`https://api.example.com/users/${id}`);
usage.ts
const { data } = await getUser('1');

Instance with base URL and headers​

api.ts
import axios from 'axios';

const api = axios.create({
baseURL: 'https://api.example.com',
headers: { 'X-API-Key': 'my-key' },
});

export const getPost = (id: string) => api.get(`/posts/${id}`);
export const createPost = (data: any) => api.post('/posts', data);

POST mutation​

api.ts
import axios from 'axios';

const api = axios.create({ baseURL: 'https://api.example.com' });

export const createPost = (data: { title: string; body: string }) =>
api.post('/posts', data);

Interceptors → lifecycle methods​

Axios interceptors map to RestEndpoint lifecycle methods:

api.ts
import axios from 'axios';

const api = axios.create({ baseURL: 'https://api.example.com' });

// Request interceptor — add auth token
api.interceptors.request.use(config => {
config.headers.Authorization = `Bearer ${getToken()}`;
return config;
});

// Response interceptor — unwrap .data
api.interceptors.response.use(
response => response.data,
error => Promise.reject(error),
);
tip

RestEndpoint already returns parsed JSON by default — no interceptor needed to unwrap response.data.

Response interceptors that transform the body, such as converting snake_case keys, belong in process(). See snakes to camels for a complete example.

Error handling​

import axios from 'axios';

try {
const { data } = await axios.get('/users/1');
} catch (err) {
if (axios.isAxiosError(err)) {
console.log(err.response?.status);
console.log(err.response?.data);
}
}

Server error messages​

Axios codebases commonly surface error.response.data.error or .message to the user. Read it from the Response body instead, once, in the base class's fetchResponse(), so call sites get it from error.message without parsing the body:

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;
}
}
}

Cancellation​

import axios from 'axios';

const controller = new AbortController();
axios.get('/users', { signal: controller.signal });
controller.abort();

Or with the deprecated CancelToken:

const source = axios.CancelToken.source();
axios.get('/users', { cancelToken: source.token });
source.cancel();

See the abort guide for more patterns.

Timeout​

Before (axios)
axios.get('/users', { timeout: 5000 });
After (data-client)
const getUsers = new RestEndpoint({
path: '/users',
signal: AbortSignal.timeout(5000),
});

Binary responses​

Before (axios)
axios.get('/files/1', { responseType: 'blob' });

Set content to 'blob', 'arrayBuffer' or 'text'. See file download for the full endpoint and triggering a browser download.

Query serialization​

Before (axios)
axios.get('/users', {
params: { ids: [1, 2, 3] },
paramsSerializer: params =>
qs.stringify(params, { arrayFormat: 'repeat' }),
});

Override searchToString() to serialize with qs; see using the qs library.

Basic auth​

Before (axios)
axios.get('/api', { auth: { username: 'user', password: 'pass' } });
After (data-client)
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 non-ok status. Override it to change what counts as an error:

Before (axios)
axios.get('/api', { validateStatus: status => status < 500 });
After (data-client)
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)
axios.create({
xsrfCookieName: 'csrftoken',
xsrfHeaderName: 'X-CSRFToken',
});

Read the cookie in getHeaders() for non-GET requests. See Django Integration for the complete endpoint class.

Upload progress​

fetch cannot report upload progress, so use XMLHttpRequest inside fetchResponse(). The onProgress field is passed as an endpoint option, like any other member.

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

Codemod​

A standalone jscodeshift codemod handles the mechanical parts of migration. Run it yourself for non-AI workflows; the AI skill above runs it automatically as its first step.

npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/

The codemod automatically:

  • Replaces import axios from 'axios' with import { RestEndpoint } from '@data-client/rest'
  • Converts axios.create({ baseURL, headers }) into a base RestEndpoint subclass with urlPrefix and getHeaders()
  • Transforms axios.get(), .post(), .put(), .patch(), .delete() into new RestEndpoint({ path, method })
  • Transforms calls on a created instance (api.post() where api = axios.create(...)) into new CreatedClassName({ path, method })

The codemod has little to do when the project wraps axios in its own class or function and never calls axios.get()/.post() directly, or only calls axios(config) without a method name. In those cases, skip it and start with the manual steps.

The codemod does not handle:

Finding remaining axios usage​

Search patterns for locating what still needs migrating:

PatternFinds
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

After the codemod​

The codemod produces endpoints without schemas. Defining Entity schemas and wiring them to endpoints enables normalization and caching — the core value of Reactive Data Client.

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;
}
}

Group CRUD endpoints with resource()​

When an axios module has separate getUsers, getUser, createUser, updateUser and deleteUser functions for one path, replace them with a single resource():

import { resource } from '@data-client/rest';
import ApiEndpoint from './ApiEndpoint';
import { User } from './User';

export const UserResource = resource({
path: '/users/:id',
schema: User,
Endpoint: ApiEndpoint,
});
// UserResource.getList, .get, .getList.push, .update, .partialUpdate, .delete

Nested paths like /projects/:projectId/tasks/:taskId get their own resource. Reserve standalone new ApiEndpoint() for non-CRUD operations (search, custom actions, auth).

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 provide 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 benefit from normalization (auth tokens, one-off responses).

warning

Don't define Entity classes and then leave schema unset on every endpoint — without schema, nothing is normalized and the migration gains little over axios.

Body typing​

Type the body of standalone POST/PUT/PATCH endpoints with body: {} as BodyType. Don't use undefined as unknown as BodyType: RestEndpoint treats body: undefined as having no body argument.

const createUser = new ApiEndpoint({
path: '/users',
method: 'POST',
body: {} as { name: string; email: string },
schema: User,
});

resource() types its CRUD endpoints automatically.

Convert call sites to hooks​

import { useEffect, useState } from 'react';
import api from './lib/api';
import { Spinner } from './Spinner';
import type { User } from './User';

function UserProfile({ id }: { id: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
api.get(`/users/${id}`).then(({ data }) => setUser(data));
}, [id]);
if (!user) return <Spinner />;
return <h1>{user.name}</h1>;
}

Loading and error states move to AsyncBoundary. See useSuspense() for details.

Context-based auth​

When tokens come from React context (Okta, Auth0) rather than storage, use hookifyResource() to inject headers through a hook. See the authentication guide for this and other patterns.

Gradual migration​

If the app uses TanStack Query or SWR and can't convert everything at once, keep those hooks temporarily but fetch through controller.fetch(). Calling an endpoint directly only runs its fetch; going through the Controller also normalizes the response into the shared cache, so data is consistent from day one:

import { useController } from '@data-client/react';
import { useQuery } from '@tanstack/react-query';
import ApiEndpoint from './ApiEndpoint';
import { Project } from './Project';

export const getProject = new ApiEndpoint({
path: '/projects/:id',
schema: Project,
});

export function useProject(id: string) {
const ctrl = useController();
return useQuery({
queryKey: ['project', id],
queryFn: () => ctrl.fetch(getProject, { id }),
});
}

Later, replace useProject(id) with useSuspense(getProject, { id }).

Existing endpoint abstractions​

Codebases that already have a custom endpoint class wrapping axios (say, one with path, method and a toDynamicUrl() helper) can extend RestEndpoint instead of replacing it, keeping backward-compatible methods while gaining 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.