So you read my last post about Axios vs Fetch and you're thinking "ok cool, but now what?"
Well…let me tell you something. Choosing your HTTP client was just the beginning.
The real productivity killer? It's when your project grows and your API calls become a mess. Trust me, I've been there.
You know what happens next right? Your APIs start changing, types drift, errors pop up everywhere, and suddenly you're spending more time debugging API calls than building features. Sound familiar?
Let's fix this once and for all…
The Problem Nobody Talks About
First thing first. Your project starts small. A few API calls here and there, maybe some user auth, simple data fetching. Everything works fine with basic fetch or axios calls.
But then reality hits hard:
- APIs change without warning (classic backend team move)
- Your TypeScript interfaces don't match what the API actually returns
- Error handling is all over the place
- New developers join and have no clue how your API layer works
- Runtime errors from type mismatches kill your confidence
Are you the type of developer who just keeps adding more try-catch blocks everywhere? Or do you want to solve this properly?
So lets do this…
Quick Reminder: Why Axios Still Wins for Productivity
Before we dive deep, let me remind you why this foundation matters:
Axios just makes your life easier. Period.
- Automatic JSON parsing (fetch makes you call .json() every time)
- Built-in error handling (no more checking response.ok manually)
- Interceptors for global logic (auth tokens, logging, whatever)
- Request cancellation that actually works
- Global config that applies everywhere
Yeah fetch is native and smaller, but unless you're building a landing page, axios saves you time. And time is money, right?
Building Something That Actually Scales
Now here's where it gets interesting. Most developers stop at "I'll use axios" and call it a day. But that's like buying a Ferrari and only driving in first gear.
Let me show you how to build an API client that will make your future self thank you…
Step 1: Define Your Types (And Actually Use Them)
Look, TypeScript without proper interfaces is just JavaScript with extra steps. Define your API contract first:
interface User {
id: number;
name: string;
email: string;
avatar?: string;
}
interface ApiResponse<T> {
data: T;
message: string;
status: number;
}
interface ApiError {
message: string;
status: number;
code?: string;
}
Simple? Yes. Powerful? Absolutely.
Step 2: Create Your Base Client (The Smart Way)
Here's where most people mess up. They create a new axios instance everywhere. Don't do that. Build one client that handles everything:
import axios from 'axios';
class ApiClient {
private client;
constructor(baseURL, headers = {}) {
this.client = axios.create({
baseURL,
headers: {
'Content-Type': 'application/json',
...headers,
},
timeout: 10000,
});
this.setupInterceptors();
}
private setupInterceptors() {
// Add auth token automatically
this.client.interceptors.request.use((config) => {
const token = localStorage.getItem('authToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle errors consistently
this.client.interceptors.response.use(
(response) => response,
(error) => {
const apiError = {
message: error.response?.data?.message || error.message,
status: error.response?.status || 500,
code: error.response?.data?.code,
};
return Promise.reject(apiError);
}
);
}
async get(url, config) {
try {
const response = await this.client.get(url, config);
return response.data;
} catch (error) {
throw error;
}
}
async post(url, data, config) {
try {
const response = await this.client.post(url, data, config);
return response.data;
} catch (error) {
throw error;
}
}
// add put, delete, etc...
}
See what I did there? One place for all your HTTP logic. Auth tokens? Handled. Error formatting? Handled. Timeouts? Handled.
Step 3: Organize by Services (Like a Pro)
Don't dump all your API calls in one file. That's amateur hour. Create services:
class UserService {
constructor(apiClient) {
this.api = apiClient;
}
async getUsers(page = 1, limit = 10) {
return await this.api.get(`/users?page=${page}&limit=${limit}`);
}
async getUserById(id) {
return await this.api.get(`/users/${id}`);
}
async createUser(userData) {
return await this.api.post('/users', userData);
}
async updateUser(id, userData) {
return await this.api.put(`/users/${id}`, userData);
}
async deleteUser(id) {
return await this.api.delete(`/users/${id}`);
}
}
Clean. Organized. Maintainable. Your team will love you.
Step 4: Put It All Together
Create a factory that gives you everything:
class ApiFactory {
constructor(baseURL, headers = {}) {
this.apiClient = new ApiClient(baseURL, headers);
this.userService = new UserService(this.apiClient);
}
get users() {
return this.userService;
}
// Add more services as you need them
}
// Usage in your app
const api = new ApiFactory(process.env.REACT_APP_API_BASE_URL);
export default api;
Now in your components:
const users = await api.users.getUsers();
const user = await api.users.getUserById(123);
Beautiful, right?
Pro Tips That Will Save Your Life
Runtime Validation (Because TypeScript Lies Sometimes)
TypeScript types disappear at runtime. Want real safety? Add runtime checks:
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
// In your service
async getUserById(id) {
const response = await this.api.get(`/users/${id}`);
// This will throw if the API returns garbage
return UserSchema.parse(response.data);
}
Auto-Generate Everything (The Lazy Developer's Dream)
Got an OpenAPI spec? Generate your entire client:
npm install @openapitools/openapi-generator-cli -g
openapi-generator-cli generate -i api-spec.yaml -g typescript-axios -o ./api-client
Boom. Types, methods, documentation. All generated. All type-safe. All maintained automatically.
Handle Loading States Like a Boss
class UserService {
private abortController = null;
async getUsers(page = 1) {
// Cancel previous request if user is clicking fast
if (this.abortController) {
this.abortController.abort();
}
this.abortController = new AbortController();
try {
return await this.api.get(`/users?page=${page}`, {
signal: this.abortController.signal
});
} finally {
this.abortController = null;
}
}
}
No more duplicate requests. No more race conditions. Clean UX.
When to Use What (The Real Talk)
Small project, just you coding?
Basic fetch with a thin wrapper is fine. Don't over-engineer.
Medium project, small team?
This typed client approach is perfect. You'll thank me later.
Large project, big team?
Generated clients all the way. Let the machines do the work.
Enterprise with changing APIs?
All of the above plus comprehensive testing and monitoring.
The Bottom Line
Look, choosing between axios and fetch was just step one. Building a maintainable, type-safe API layer? That's where the real productivity gains happen.
This might seem like a lot of setup initially, but here's what you get:
- Catch API problems at compile time
- Consistent error handling everywhere
- New team members understand your API instantly
- Refactoring becomes safe instead of scary
- Less debugging, more feature building
The tools are here. The patterns work. The question is: are you going to keep writing spaghetti API code or level up your game?
Your future self will thank you. Your team will thank you. Your users will thank you (because fewer bugs = better experience). I thank you xD
Just my thoughts for anyone dealing with API chaos. What is your approach? Let me know in the comments.
hm...I am thinking about diving into API caching strategies that can make your app 10x faster. Stay tuned
Top comments (2)
I always liked to make requests through Axios and I think there is no alternative yet
Ok, since the one other commenter here and the author don't seem to have seen something better, I'll be the light-bringer into this darkness that Axios really is.
Killing the Reasons Why Axios is the Winner
I'll go one at a time.
Automatic JSON parsing (fetch makes you call .json() every time)
I don't need a 2MB package for one line of code.
Built-in error handling (no more checking response.ok manually)
This is bad. I have blogged about this. Long story short: You're driving logic via thrown errors. In other words: You are using
try..catch
as a branching statement. The worst part: It is around a 40% performance hit in Chromium (don't know Firefox, Opera, etc.).Interceptors for global logic (auth tokens, logging, whatever)
It takes more code to write an Axios interceptor than it takes to do the same job using
fetch
.Request cancellation that actually works
Are you trying to imply that cancellation in
fetch
doesn't? Because you would be wrong. If you're not trying to imply this, then what is this all about?Global config that applies everywhere
I don't need a 2MB package to get this.
The ApiClient
Now to the
ApiClient
class. I copied the code in a VS Code window. It is 62 lines in size, and doesn't include shortcut functions for PUT, DELETE, PATCH or HEAD. Now let me show you code that does all that (except for error handling) using the dr-fetch NPM package:This is 19 lines of code. It is also not missing any shortcut verb functions. It provides GET, POST, PUT, PATCH, DELETE and HEAD shortcut methods. Furthermore, it pre-types HTTP 400 responses. TypeScript will tell you that inside
if (response.status === 400)
,response.body
will be an array of validation error objects. More on typing below.About the Error Handling
This is the code that doesn't have equivalency in the above counter-example:
This is a hot mess. Let's talk about it.
First and foremost, because Axios throws on non-OK responses, which is a very bad thing to do, we could potentially be getting response bodies in
response.data
that don't have amessage
property because, perhaps, not all non-OK HTTP responses carry a JSON with said property. For example, HTTP status code 503 (temporarily unavailable) commonly returns text, not JSON.The above also lacks TypeScript, which is surprising given the title of the article. And why isn't it typed? Because Axios doesn't have the ability to type response bodies depending on the HTTP status code of the response. However, and I don't know if you noticed,
dr-fetch
CAN type the body differently according to the HTTP response code.We can do things like this:
All the above gets full Intellisense by virtue of TypeScript's type narrowing mechanism.
Now back to your code snippet again: You write a response interceptor that is powered by a catch block inside Axios to create a new object that is thrown as well, incurring again in the expensive callstack unwinding operation. Basically, another 40+% performance hit, on top of the one that Axios makes you incur in the first place.
Finally, your shortcut functions show an awful thing to do: Catching errors just to throw them again.
Both
get
andpost
in your code dotry { ... } catch (error) { throw error; }
. Why? If you're not doing anything with the error, you don't add atry..catch
block. Yes, not exactly code in the "handle errors consistently" part of your code, but I suppose these are related and why I mention these here.This is why the error handling was omitted from the equivalent API client I presented: The code you presented has inherent flaws that Axios is incapable of coping with, alongside an unnecessary thing to do, which you're presenting as the epitome and pinnacle of how to fetch data "correctly".
But not only this: By being able to type the body depending on the HTTP response, we can create a better user interface: We could show the user validation error messages nicely, with a simple
else if (response.status === 400)
. With the error standardization thing you show, these validation error messages are effectively being discarded.Summarizing:
message
property not existing inresponse.data
.axios
, a package with thousands of lines of code, that can be beaten with 19 LOC usingdr-fetch
, a 421-LOC NPM package.Conclusion
With
axios
you write more code for less value. If there's one popular package that I personally think should be archived, isaxios
.I suppose that now comes the list of many, many other things Axios can do. Let's hear them, I suppose.