⚙️ Dev & Engineering

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

Chloe Chen
Chloe Chen
Dev & Engineering Lead

Full-stack engineer obsessed with developer experience. Thinks code should be written for the humans who maintain it, not just the machines that run it.

React API callsAxios interceptorsReact custom hooksDeveloper Experience

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

React Component useApi Hook Axios Instance Req Interceptor Res Interceptor API

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.

Req 1 (User A) Resolves Late! (Race Condition) Req 2 (User B) Resolves Fast

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 useState or useEffect for 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 AbortController ensures 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 options object you are passing to useApi. If you pass a new object inline like useApi('/data', { params: { id: 1 } }), React sees a new object reference on every render. Fix this by wrapping the options object in a useMemo hook before passing it.
  • Interceptors Not Firing: Ensure you are importing your custom apiClient and not the default axios library in your hooks.
  • CanceledError in Console: If you see CanceledError in your console, don't panic! This means your AbortController is working perfectly. Our useApi hook 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 our useApi 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 custom apiClient (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! ✨🚀

📚 Sources

Related Posts

⚙️ Dev & Engineering
Clean React Architecture Patterns: Escaping the God-Object
May 11, 2026
⚙️ Dev & Engineering
Build Fast Structured APIs with Dependency Injection
May 6, 2026
⚙️ Dev & Engineering
Building a Resilient Customer Support Architecture ✨
Apr 29, 2026