Master React Axios Tutorial: Build a DX-First API Client

We've all stared at our React app re-rendering 50 times for no reason while downing coffee, right? You open your network tab, and it looks like a Christmas tree of duplicate API requests. Your components are bloated with useEffect blocks, try/catch statements are scattered everywhere, and managing loading states feels like a full-time job.
If you are building modern web applications, handling React API calls shouldn't feel this chaotic. Code isn't just for computers—it's for our fellow developers. When we optimize for Developer Experience (DX), we inherently write safer, more performant applications.
Shall we solve this beautifully together? ✨
In this comprehensive React Axios tutorial, we are going to build a production-ready, highly optimized API client. We will move away from messy, scattered fetch calls and build a centralized, DX-first architecture using Axios interceptors and custom React hooks.
The Mental Model: The API Tollbooth
Before we write a single line of code, let's visualize how our data flows.
Imagine your React application is a bustling city, and your backend API is a neighboring town. Every time a component needs data, it sends a car (a request) down the highway.
If every component builds its own car and drives its own route (using inline fetch or axios.get), you get traffic jams. You have no central way to check if the driver has a valid license (auth tokens), or to handle a roadblock (global error handling).
Instead, we are going to build a Tollbooth (an Axios Instance with Interceptors).
Every request must pass through this tollbooth. Here, we automatically attach authorization headers. When the response comes back, it passes through the tollbooth again, where we handle errors globally before handing the clean data back to the component.
Finally, we will wrap this entire process in a Custom Hook (the delivery driver), so our UI components only ever have to ask: "Is my data here yet?"
Prerequisites
Before we dive into the code, ensure you have the following ready:
- A working React project (Next.js, Vite, or Create React App).
- Node.js installed on your machine.
- Axios installed in your project (
npm install axios).
Step 1: Setting Up the Axios Instance
The biggest mistake developers make is importing Axios directly into their components and hardcoding the base URL. This is a maintenance nightmare. If your API URL changes, you have to update it in 50 different files.
Instead, we create a dedicated instance. Create a file named apiClient.js (or .ts if you're using TypeScript).
// src/api/apiClient.js
import axios from 'axios';
// 💡 We create a custom instance to centralize our configuration
const apiClient = axios.create({
baseURL: process.env.REACT_APP_API_URL || 'https://api.example.com',
timeout: 10000, // Drop requests that take longer than 10 seconds
headers: {
'Content-Type': 'application/json',
},
});
export default apiClient;
Why this is better:
By setting a timeout, we prevent our app from hanging indefinitely if the backend server is unresponsive. By centralizing the baseURL, we can easily switch between development, staging, and production environments using environment variables.
Step 2: Building the "Tollbooth" (Interceptors)
Now, let's add our interceptors to the apiClient.js file.
Interceptors are functions that Axios calls automatically for every request and response. Think of recent security news—like the dnsmasq CVEs that required systemic patching. Security and predictability at the network layer are vital. Interceptors allow us to systematically enforce security (like Auth tokens) without relying on developers to remember them in every component.
// src/api/apiClient.js (continued)
// 🚗 Request Interceptor: The Outbound Tollbooth
apiClient.interceptors.request.use(
(config) => {
// Grab the token from local storage (or your state manager)
const token = localStorage.getItem('authToken');
if (token) {
config.headers.Authorization = Bearer ${token};
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 🛑 Response Interceptor: The Inbound Tollbooth
apiClient.interceptors.response.use(
(response) => {
// Any status code within the 2xx range triggers this function
return response.data; // 💡 DX Tip: Return data directly to avoid destructuring later!
},
(error) => {
// Any status code outside the 2xx range triggers this function
if (error.response?.status === 401) {
// Handle unauthorized access globally (e.g., redirect to login)
console.error('Unauthorized! Redirecting to login...');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
Why this is better:
Notice the DX win here! By returning response.data in the response interceptor, we no longer have to write const { data } = await apiClient.get(...) in our components. We just get the payload directly. This lets us go home earlier!
Step 3: Creating the DX-First Custom Hook (useApi)
This is where the magic happens. We are going to abstract away useState and useEffect so our components stay pristine.
We also need to handle a massive performance killer: Race Conditions. If a user clicks "Load Profile A" and then quickly clicks "Load Profile B", Profile A's request might resolve after Profile B's, overwriting the UI with the wrong data.
We solve this beautifully using an AbortController.
Create a file named useApi.js:
// src/hooks/useApi.js
import { useState, useEffect } from 'react';
import apiClient from '../api/apiClient';
export const useApi = (url, options = {}) => {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// 🚀 Create an AbortController to cancel stale requests
const controller = new AbortController();
const fetchData = async () => {
setIsLoading(true);
try {
const responseData = await apiClient.get(url, {
...options,
signal: controller.signal, // Attach the abort signal
});
setData(responseData);
setError(null);
} catch (err) {
// Ignore errors caused by request cancellation
if (err.name !== 'CanceledError') {
setError(err.message || 'Something went wrong');
}
} finally {
setIsLoading(false);
}
};
fetchData();
// 🧹 Cleanup function: abort the request if the component unmounts
// or if the URL changes before the previous request finishes.
return () => controller.abort();
}, [url]); // Re-run if the URL changes
return { data, isLoading, error };
};
Why this is better:
By returning a cleanup function () => controller.abort(), React will automatically cancel the pending Axios request if the component unmounts or if the url dependency changes. This eliminates memory leaks and race conditions entirely.
Step 4: Wiring It Up in a Component
Let's look at the incredible Developer Experience we've just created.
The "Before" (The Pain Point)
// ❌ The old, messy way
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
axios.get(https://api.example.com/users/${userId}, {
headers: { Authorization: Bearer ${localStorage.getItem('token')} }
})
.then(res => setUser(res.data))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <Spinner />;
if (error) return <Error message={error} />;
return <div>{user.name}</div>;
}
The "After" (The DX-First Way)
// ✅ The elegant, DX-first way
import { useApi } from '../hooks/useApi';
function UserProfile({ userId }) {
// Look how clean this is! ✨
const { data: user, isLoading, error } = useApi(/users/${userId});
if (isLoading) return <Spinner />;
if (error) return <Error message={error} />;
return <div>{user.name}</div>;
}
Performance vs DX: Why This Wins
Let's comprehensively evaluate what we've built from both angles:
Developer Experience (DX)
- Less Boilerplate: You no longer need to write
useStateoruseEffectfor every API call. You just call the hook. - Centralized Logic: If your authentication method changes from LocalStorage to HttpOnly Cookies, you only update one file (the interceptor), not 100 components.
- Mental Bandwidth: Developers don't have to worry about race conditions or manual error handling. They can focus purely on building beautiful UIs.
Performance Optimization
- Fewer Re-renders: By abstracting state management into the hook and handling errors cleanly, we prevent unnecessary component re-renders.
- Memory Leak Prevention: The
AbortControllerensures that if a user navigates away from a page before an API call finishes, the request is killed. The browser frees up network resources, and React doesn't try to update state on an unmounted component. - Smaller Bundle Size: Reusing a single Axios instance is more memory-efficient than instantiating new configurations across multiple files.
Verification
To confirm your setup is working perfectly:
1. Open your browser's DevTools and navigate to the Network tab.
2. Throttle your network speed to "Slow 3G".
3. Trigger an API call in your app, then quickly navigate to a different page.
4. You should see the network request status change to (canceled). This proves your AbortController is successfully preventing memory leaks!
Troubleshooting
Even with the best patterns, things can get tricky. Here are common pitfalls and how to fix them:
- Infinite Loops: If your component is re-rendering endlessly, check the
optionsobject you are passing touseApi. If you pass a new object inline likeuseApi('/data', { params: { id: 1 } }), React sees a new object reference on every render. Fix this by wrapping the options object in auseMemohook before passing it. - Interceptors Not Firing: Ensure you are importing your custom
apiClientand not the defaultaxioslibrary in your hooks. - CanceledError in Console: If you see
CanceledErrorin your console, don't panic! This means yourAbortControlleris working perfectly. OuruseApihook specifically ignores this error so it doesn't break your UI.
FAQ
Why use Axios instead of the native Fetch API?
While Fetch is great, Axios provides automatic JSON transformation, built-in interceptors, upload progress tracking, and easier timeout configurations out of the box. It requires significantly less boilerplate for complex applications.Can I use this pattern with POST or PUT requests?
Absolutely! While ouruseApi hook is optimized for GET requests, you can create a similar useMutation hook for POST/PUT requests, or simply import the apiClient directly into your event handlers for form submissions.
How do I handle refreshing expired Auth tokens?
You can handle this in the response interceptor. If you catch a 401 error, you can pause all pending requests, call your/refresh-token endpoint, update the token, and then retry the original requests. Axios interceptors make this queueing system possible.
Is this better than React Query or SWR?
React Query and SWR are incredible libraries that handle caching, pagination, and background syncing. If your app is highly data-intensive, you should use them! However, you will still want to pass your customapiClient (with its interceptors) into React Query's fetcher function to maintain centralized auth and error handling.
The Wrap-up
By taking the time to set up a centralized Axios instance, robust interceptors, and a custom React hook, you've completely transformed how your application communicates with the backend.
Your components are way leaner now, your network requests are safe from race conditions, and your fellow developers will thank you for the incredible DX.
Happy Coding! ✨🚀