Skip to main content

πŸ“‘ API Requests with Axios

Every interactive app eventually needs to talk to a server: load a list, submit a form, refresh a dashboard. Axios is the go-to HTTP client that makes those calls concise and consistent. This lesson takes you from a first GET request to production-grade patterns β€” a shared instance, interceptors, cancellation, and a reusable data-fetching hook.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Make GET, POST, PUT, and DELETE requests with Axios using async/await
  • Explain the practical differences between Axios and the Fetch API
  • Create a reusable Axios instance with a base URL and default headers
  • Use request and response interceptors to attach tokens and handle errors globally
  • Cancel in-flight requests with AbortController to prevent race conditions and leaks
  • Package fetching logic into a reusable custom React hook

Estimated Time: 45–60 minutes  β€’  Difficulty: Intermediate

Hands-on: Build a debounced search box that cancels stale requests as the user types.

In This Lesson

Why Axios?

Axios is a promise-based HTTP client that runs in both the browser and Node.js. It has stayed popular for years because it trims away the repetitive boilerplate that raw HTTP calls demand: it parses JSON for you, treats HTTP error statuses as real errors, supports request timeouts and cancellation out of the box, and lets you register interceptors that run on every request or response.

πŸ’‘ Analogy: If sending an HTTP request were like mailing a package, the built-in fetch is the basic postal service β€” it gets there, but you handle the paperwork. Axios is the premium courier: tracking, insurance, automatic customs forms (JSON parsing), and a single desk (interceptors) that stamps every parcel the same way.

Install it into your React project:

npm install axios

Axios vs. the Fetch API

fetch is built into every modern browser and is perfectly capable. Axios isn't "better" so much as more convenient for app-scale code. The two differences that bite most often are JSON handling and error handling.

ConcernAxiosFetch API
JSON parsingAutomatic β€” res.data is readyManual β€” call await res.json()
HTTP error (4xx/5xx)Rejects the promise β†’ hits catchResolves normally β€” you must check res.ok
TimeoutsBuilt-in timeout optionManual with AbortController
InterceptorsBuilt-inNot available β€” wrap it yourself
Bundle sizeExtra dependencyZero β€” already in the browser

See the error-handling gap concretely. With fetch, a 404 or 500 does not throw:

// fetch: a failing status still "succeeds" β€” you must check res.ok yourself
const res = await fetch('/api/users');
if (!res.ok) {
  throw new Error(`Request failed: ${res.status}`);
}
const data = await res.json();

// axios: a failing status rejects, landing in catch automatically
try {
  const { data } = await axios.get('/api/users');
} catch (err) {
  console.error(err.response?.status);   // e.g. 404
}

πŸ’‘ When to reach for which

For a tiny script or a single call, fetch is fine and adds nothing to your bundle. For a full app with auth tokens, consistent error handling, and many endpoints, Axios's interceptors and defaults earn their keep. This lesson uses Axios; the concepts transfer directly if you later standardize on fetch.

The Four Basic Requests

The four verbs you'll use constantly map to reading and changing data. Each returns a promise resolving to a response object whose data property holds the parsed body.

import axios from 'axios';

const BASE = 'https://api.example.com';

// GET β€” read a collection
async function getUsers() {
  const { data } = await axios.get(`${BASE}/users`);
  return data;
}

// POST β€” create a new record
async function createUser(user) {
  const { data } = await axios.post(`${BASE}/users`, user);
  return data;
}

// PUT β€” replace/update an existing record
async function updateUser(id, user) {
  const { data } = await axios.put(`${BASE}/users/${id}`, user);
  return data;
}

// DELETE β€” remove a record
async function deleteUser(id) {
  await axios.delete(`${BASE}/users/${id}`);
}

πŸ“– The response object

Every successful Axios call resolves to an object with these fields:

{
  data,        // the parsed response body β€” what you usually want
  status,      // HTTP status code, e.g. 200
  statusText,  // e.g. 'OK'
  headers,     // response headers
  config       // the request configuration that produced this response
}

Because of this, you'll destructure const { data } = await axios.get(...) almost every time.

Handling errors precisely

When a request fails, Axios gives you an error object with three distinct shapes. Checking them tells you where things broke:

try {
  const { data } = await axios.get('/api/data');
} catch (err) {
  if (err.response) {
    // Server responded with a non-2xx status
    console.error('Status:', err.response.status);
    console.error('Body:', err.response.data);
  } else if (err.request) {
    // Request sent, but no response (server down, network offline)
    console.error('No response received');
  } else {
    // Something went wrong building the request
    console.error('Setup error:', err.message);
  }
}
flowchart TD A[Axios request] --> B{Response received?} B -->|"2xx"| C[Success: use res.data] B -->|"non-2xx"| D[err.response set] B -->|No response| E[err.request set] A -->|Bad config| F[err.message set]

A Reusable Axios Instance

Calling axios.get('https://api.example.com/...') and repeating the base URL everywhere is fragile. Instead, create one configured instance and import it wherever you need the API. This is the single most important habit for keeping API code maintainable.

// src/services/api.js
import axios from 'axios';

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || '/api',   // Vite env var
  timeout: 10000,                                      // 10s safety net
  headers: {
    'Content-Type': 'application/json',
    Accept: 'application/json'
  }
});

export default api;

⚠️ Vite reads env vars differently than CRA

In a Vite project, environment variables are exposed on import.meta.env and must be prefixed with VITE_ (e.g. VITE_API_URL) to be visible in the browser bundle. The old Create React App convention of process.env.REACT_APP_* does not apply here.

Now build thin, readable service modules on top of the instance:

// src/services/userService.js
import api from './api';

export const userService = {
  getAll: () => api.get('/users').then((r) => r.data),
  getById: (id) => api.get(`/users/${id}`).then((r) => r.data),
  create: (user) => api.post('/users', user).then((r) => r.data),
  update: (id, user) => api.put(`/users/${id}`, user).then((r) => r.data),
  remove: (id) => api.delete(`/users/${id}`).then((r) => r.data)
};

βœ… What you gain

One place to set the base URL, one place to add auth, easy to mock in tests, and components that read like plain function calls (userService.getAll()) instead of scattered HTTP details.

Interceptors

Interceptors are hooks that run automatically on every request before it's sent, or every response before your then/catch sees it. They're perfect for cross-cutting concerns you'd otherwise repeat in every call β€” most commonly, attaching an auth token and handling expired sessions.

sequenceDiagram participant C as Component participant RQ as Request Interceptor participant S as Server participant RS as Response Interceptor C->>RQ: api.get('/users') RQ->>RQ: Attach Bearer token RQ->>S: Modified request S-->>RS: Response alt 2xx RS-->>C: res.data else 401 Unauthorized RS->>RS: Clear token & redirect to login end

Request interceptor β€” attach the token

// src/services/api.js (continued)
api.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;                     // must return the config
  },
  (error) => Promise.reject(error)
);

Response interceptor β€” handle expired sessions

api.interceptors.response.use(
  (response) => response,              // pass successful responses through
  (error) => {
    if (error.response?.status === 401) {
      // Token invalid or expired β€” force a fresh login
      localStorage.removeItem('token');
      window.location.href = '/login';
    }
    return Promise.reject(error);      // let the caller still see the error
  }
);

πŸ’‘ Why this beats copy-paste

Without interceptors you'd add the Authorization header and 401 check to every service function. With them, one file governs auth for the whole app. Change your token scheme once, and every request follows.

Cancelling Requests

Cancellation matters more than beginners expect. Imagine a search box firing a request per keystroke: responses can arrive out of order, so a slow response for "re" might land after "react", overwriting the newer results with stale ones. Cancelling the previous request prevents this race β€” and prevents React from trying to update an unmounted component.

The modern, standard approach is the browser's AbortController (Axios's old CancelToken is deprecated):

const controller = new AbortController();

api.get('/users/search', {
  params: { q: 'react' },
  signal: controller.signal          // link the request to the controller
});

// Later β€” cancel it
controller.abort();

Inside a React effect, this pattern belongs in the cleanup function so a new search (or an unmount) cancels the previous request:

import { useState, useEffect } from 'react';
import axios from 'axios';
import api from '../services/api';

function SearchResults({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!query.trim()) {
      setResults([]);
      return;
    }
    const controller = new AbortController();

    api
      .get('/search', { params: { q: query }, signal: controller.signal })
      .then((res) => setResults(res.data))
      .catch((err) => {
        // Ignore the error that cancellation itself throws
        if (!axios.isCancel(err) && err.name !== 'CanceledError') {
          console.error(err);
        }
      });

    // Cleanup: cancel the in-flight request on the next run or unmount
    return () => controller.abort();
  }, [query]);

  return (
    <ul>
      {results.map((r) => (
        <li key={r.id}>{r.name}</li>
      ))}
    </ul>
  );
}

⚠️ Cancellation looks like an error

When you abort a request, the promise rejects with a cancellation error. Always guard your catch with axios.isCancel(err) (or check err.name === 'CanceledError') so you don't show the user a scary "request failed" message for something you did on purpose.

A Custom useFetch Hook

The loading/error/data trio shows up in every component that fetches. Extract it into a custom hook once and reuse it everywhere β€” cancellation included.

// src/hooks/useFetch.js
import { useState, useEffect } from 'react';
import axios from 'axios';
import api from '../services/api';

export function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    api
      .get(url, { signal: controller.signal })
      .then((res) => setData(res.data))
      .catch((err) => {
        if (!axios.isCancel(err) && err.name !== 'CanceledError') {
          setError(err.response?.data?.message || err.message);
        }
      })
      .finally(() => setLoading(false));

    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}

Components become dramatically shorter β€” they describe the UI, not the plumbing:

// src/components/UserList.jsx
import { useFetch } from '../hooks/useFetch';

export default function UserList() {
  const { data: users, loading, error } = useFetch('/users');

  if (loading) return <p>Loading users…</p>;
  if (error) return <p role="alert">Error: {error}</p>;

  return (
    <ul>
      {users.map((u) => (
        <li key={u.id}>{u.name} ({u.email})</li>
      ))}
    </ul>
  );
}

πŸ’‘ Beyond hand-rolled hooks

For real apps, libraries like TanStack Query (React Query) or SWR build on exactly these ideas and add caching, background refetching, and deduplication. Writing useFetch yourself first makes those libraries feel obvious rather than magical.

Hands-on: Debounced Search

πŸ‹οΈ Build a live search that behaves

Objective: Create a search input that queries an API as the user types, debounces to avoid a request per keystroke, and cancels stale requests so results never arrive out of order.

Instructions:

  1. Create a Search component with a controlled input bound to a query state.
  2. In a useEffect keyed on query, start a setTimeout of ~400ms before firing the request (debounce).
  3. Create an AbortController and pass its signal to the Axios call.
  4. In the cleanup function, clear the timeout and abort the controller.
  5. Ignore cancellation errors; show real errors to the user.
πŸ’‘ Hint

The cleanup function runs before the next effect and on unmount β€” that's exactly when you want to both clearTimeout and controller.abort(). Debouncing (the timeout) reduces how many requests you start; cancellation (the abort) discards ones already in flight.

βœ… Sample solution
// src/components/Search.jsx
import { useState, useEffect } from 'react';
import axios from 'axios';
import api from '../services/api';

export default function Search() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  useEffect(() => {
    if (!query.trim()) {
      setResults([]);
      return;
    }
    const controller = new AbortController();

    const timer = setTimeout(async () => {
      setLoading(true);
      setError('');
      try {
        const res = await api.get('/search', {
          params: { q: query },
          signal: controller.signal
        });
        setResults(res.data);
      } catch (err) {
        if (!axios.isCancel(err) && err.name !== 'CanceledError') {
          setError('Search failed');
        }
      } finally {
        setLoading(false);
      }
    }, 400);

    return () => {
      clearTimeout(timer);        // cancel the pending debounce
      controller.abort();         // cancel the in-flight request
    };
  }, [query]);

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search…"
      />
      {loading && <p>Searching…</p>}
      {error && <p role="alert">{error}</p>}
      <ul>
        {results.map((r) => (
          <li key={r.id}>{r.name}</li>
        ))}
      </ul>
    </div>
  );
}

Type quickly and only the final query's results appear β€” no flicker, no stale overwrite.

🎯 Quick Quiz

Question 1: How does Axios differ from fetch when the server returns a 500 status?

Question 2: What is the main purpose of a request interceptor?

Question 3: Why cancel a previous request in a search-as-you-type box?

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Axios makes HTTP calls concise: it parses JSON automatically and rejects on error statuses, unlike fetch.
  • Destructure const { data } = await axios.get(...) β€” the body lives on response.data.
  • Create one configured Axios instance and build thin service modules on top of it.
  • Interceptors centralize cross-cutting logic like attaching tokens and handling 401s.
  • Cancel stale requests with AbortController and guard catch with axios.isCancel.
  • A custom useFetch hook reuses the loading/error/data pattern; libraries like TanStack Query extend it.

πŸ“š Further Reading

πŸš€ What's Next?

You can now move data cleanly between React and a server. Next we combine everything into a complete feature: Authentication Flow in the MERN Stack β€” JWTs, protected routes, token storage, and refresh.

πŸŽ‰ Great work!

Interceptors and cancellation are the patterns that separate toy fetch calls from production data layers.