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.).
- Skills
- OpenSkills
- Claude Code
npx skills add reactive/data-client \
--skill data-client-schema \
--skill data-client-rest-setup \
--skill data-client-rest
npx openskills install reactive/data-client/.agents/skills/data-client-schema
npx openskills install reactive/data-client/.agents/skills/data-client-rest-setup
npx openskills install reactive/data-client/.agents/skills/data-client-rest
claude plugin marketplace add reactive/data-client
claude plugin install core@data-client
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 apathandschema
Quick reference
| Axios | @data-client/rest |
|---|---|
baseURL | urlPrefix |
headers config | getHeaders() |
interceptors.request | getRequestInit() / getHeaders() |
interceptors.response | parseResponse() / process() |
timeout | AbortSignal.timeout() via signal |
params / paramsSerializer | searchParams / searchToString() |
cancelToken / signal | signal (AbortController) |
responseType: 'blob' / 'arraybuffer' | content: 'blob' / 'arrayBuffer' — see file download |
auth: { username, password } | getHeaders() with btoa() |
xsrfCookieName / xsrfHeaderName | getHeaders() — see Django Integration |
transformRequest | getRequestInit() |
transformResponse | process() |
validateStatus | Custom fetchResponse() |
onUploadProgress | Custom fetchResponse() using XMLHttpRequest |
isAxiosError / error.response | NetworkError with .status and .response |
Migration examples
Basic GET
- Before (axios)
- After (data-client)
import axios from 'axios';
export const getUser = (id: string) =>
axios.get(`https://api.example.com/users/${id}`);
const { data } = await getUser('1');
import { RestEndpoint } from '@data-client/rest'; import User from './User'; export const getUser = new RestEndpoint({ urlPrefix: 'https://api.example.com', path: '/users/:id', schema: User, });
import { getUser } from './api'; getUser({ id: '1' });
GET https://api.example.com/users/1
content-type: application/json
{
"id": "1",
"username": "alice",
"email": "alice@example.com"
}
Instance with base URL and headers
- Before (axios)
- After (data-client)
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);
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', }; } }
import { PostResource } from './PostResource'; PostResource.get({ id: '1' });
GET https://api.example.com/posts/1
content-type: application/json
x-api-key: my-key
{
"id": "1",
"title": "Hello World",
"body": "First post"
}
POST mutation
- Before (axios)
- After (data-client)
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);
import { resource } from '@data-client/rest'; import Post from './Post'; export const PostResource = resource({ urlPrefix: 'https://api.example.com', path: '/posts/:id', schema: Post, });
import { PostResource } from './PostResource'; PostResource.getList.push({ title: 'New Post', body: 'Content', });
POST https://api.example.com/posts
content-type: application/json
Body: {"title":"New Post","body":"Content"}
{
"id": "2",
"title": "New Post",
"body": "Content"
}
Interceptors → lifecycle methods
Axios interceptors map to RestEndpoint lifecycle methods:
- Before (axios)
- After (data-client)
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),
);
import { RestEndpoint, RestGenerics } from '@data-client/rest';
export default class ApiEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
urlPrefix = 'https://api.example.com';
// Equivalent to request interceptor
getHeaders(headers: HeadersInit) {
return {
...headers,
Authorization: `Bearer ${getToken()}`,
};
}
// Equivalent to response interceptor (unwrap/transform)
process(value: any, ...args: any) {
return value;
}
}
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
- Before (axios)
- After (data-client)
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);
}
}
import { NetworkError } from '@data-client/rest';
try {
const user = await getUser({ id: '1' });
} catch (err) {
if (err instanceof NetworkError) {
console.log(err.status);
console.log(err.response);
}
}
NetworkError provides .status and .response (the raw Response object). For soft retries on server errors, see errorPolicy.
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:
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
- Before (axios)
- After (data-client)
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();
Both map to an AbortController signal. The useCancelling() hook automatically cancels in-flight requests when parameters change:
import { useSuspense } from '@data-client/react';
import { useCancelling } from '@data-client/react';
import { searchEndpoint } from './api/search';
import ResultsList from './ResultsList';
function SearchResults({ query }: { query: string }) {
const results = useSuspense(useCancelling(searchEndpoint), { q: query });
return <ResultsList results={results} />;
}
For manual cancellation, pass signal directly:
const controller = new AbortController();
const getUser = new RestEndpoint({
path: '/users/:id',
signal: controller.signal,
});
controller.abort();
See the abort guide for more patterns.
Timeout
axios.get('/users', { timeout: 5000 });
const getUsers = new RestEndpoint({
path: '/users',
signal: AbortSignal.timeout(5000),
});
Binary responses
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
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
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 non-ok status. 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 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.
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),
});
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'withimport { RestEndpoint } from '@data-client/rest' - Converts
axios.create({ baseURL, headers })into a baseRestEndpointsubclass withurlPrefixandgetHeaders() - Transforms
axios.get(),.post(),.put(),.patch(),.delete()intonew RestEndpoint({ path, method }) - Transforms calls on a created instance (
api.post()whereapi = axios.create(...)) intonew 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:
- Interceptors — see lifecycle methods
- Error handling (
isAxiosError,error.response) — see error handling - The rest of the quick reference — see the migration examples above
- Entity schema definitions and converting call sites to hooks — see below
Finding remaining axios usage
Search patterns for locating what still needs migrating:
| Pattern | Finds |
|---|---|
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 |
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 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 provide 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 benefit from normalization (auth tokens, one-off responses).
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
- Before (axios)
- After (data-client)
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>;
}
import { useSuspense } from '@data-client/react';
import { UserResource } from './UserResource';
function UserProfile({ id }: { id: string }) {
const user = useSuspense(UserResource.get, { id });
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.
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