DEV Community

Cover image for HTTP Requests in React(fetch vs Axios and Tanstack Query)
Alifa Ara Heya
Alifa Ara Heya

Posted on

HTTP Requests in React(fetch vs Axios and Tanstack Query)

As a web developer, understanding how to communicate with a server is fundamental. In React, you'll frequently use HTTP requests to Create, Read, Update, and Delete (CRUD) data. This note will walk you through three common approaches: the built-in fetch API, the popular library Axios, and the powerful state management tool TanStack Query. We'll start with the foundational fetch API, explore the improvements offered by Axios, and then see how a library like TanStack Query can manage the entire data-fetching lifecycle for you.


1. The Native fetch API

The fetch API is a built-in browser function that provides a clean, promise-based interface for making network requests. While it's the native solution and requires no external dependencies, its raw nature means you're responsible for handling a lot of the boilerplate yourself.

Boilerplate for a GET Request with fetch

The standard approach involves using the useEffect and useState hooks to manage the request lifecycle. To properly handle asynchronous data, we need state variables to track the data itself, the loading status of the request, and any potential errors.

import React, { useState, useEffect } from 'react';

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

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch('https://api.example.com/items');
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        let result = await response.json();
        setData(result);
      } catch (e) {
        setError(e);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, []); // The empty dependency array ensures this runs once on mount

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      {data && data.map(item => <p key={item.id}>{item.name}</p>)}
    </div>
  );
}

export default FetchExample;

Enter fullscreen mode Exit fullscreen mode

The Foundation: The Native  raw `fetch` endraw  API

A Deeper Look at fetch's Promise Chain and Error Handling

One of the most important distinctions of the fetch API is how it handles errors. A fetch promise will only reject if a network error occurs (e.g., no internet connection). It will not reject for common HTTP errors like 404 Not Found or 500 Server Error. Instead, the promise will resolve successfully, and you must manually check the response.ok property or the response.status code to handle these cases. This is why the if (!response.ok) check is a crucial part of the boilerplate.

Full CRUD Operations with fetch

POST (Create):

fetch('https://api.example.com/items', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ name: 'New Item' }),
}).then(response => response.json())
  .then(data => console.log('Item created:', data))
  .catch(error => console.error('Error creating item:', error));
Enter fullscreen mode Exit fullscreen mode

PUT (Update):

fetch('https://api.example.com/items/123', {
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ name: 'Updated Item' }),
}).then(response => response.json())
  .then(data => console.log('Item updated:', data))
  .catch(error => console.error('Error updating item:', error));
Enter fullscreen mode Exit fullscreen mode

DELETE (Delete):

fetch('https://api.example.com/items/123', {
  method: 'DELETE',
}).then(response => {
    if (!response.ok) throw new Error('Deletion failed');
    console.log('Item deleted successfully');
  })
  .catch(error => console.error('Error deleting item:', error));
Enter fullscreen mode Exit fullscreen mode

As you can see, the manual handling of success and error cases for each request can lead to repetitive code, especially in a large application.


2. Axios: The Easier Alternative

Axios is a popular, promise-based HTTP client for both the browser and Node.js. It's often favored for its simplicity and built-in features that the native fetch API lacks. It provides a cleaner, more intuitive API, reducing the amount of boilerplate needed for common tasks.

The Upgrade: Axios

fetch vs. Axios

Feature fetch (Native) Axios (Library)
Parsing Manual response.json() call Automatic JSON data parsing
Error Handling Doesn't reject on HTTP errors (e.g., 404, 500) Rejects promises on HTTP errors, simplifying try/catch
Configuration Headers, body, etc. are configured manually Instance-level configuration (base URL, headers)
Interceptors No built-in interceptors Interceptors to handle requests/responses globally
Request & Response Data Response object and request body handling can be cumbersome Data is available directly in the response.data property

Boilerplate for a GET Request with Axios

import React, { useState, useEffect } from 'react';
import axios from 'axios';

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

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await axios.get('https://api.example.com/items');
        setData(response.data);
      } catch (e) {
        setError(e);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, []);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      {data && data.map(item => <p key={item.id}>{item.name}</p>)}
    </div>
  );
}

export default AxiosExample;
Enter fullscreen mode Exit fullscreen mode

Going Deeper with Axios: Interceptors and Configuration

One of Axios's most powerful features is its use of interceptors. These are functions that let you inspect and modify every HTTP request and response before they are handled by then() or catch(). They are perfect for common tasks like adding an authentication token to every request or logging errors globally.

Example: A Request Interceptor for Authentication

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

const api = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 5000,
});

// Add a request interceptor
api.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('authToken');
    if (token) {
      config.headers['Authorization'] = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

export default api;
Enter fullscreen mode Exit fullscreen mode

Now, every time you use this api instance, it will automatically check for an authToken in local storage and add it to the headers. This centralizes your logic and prevents you from having to manually add the token to every single request.


3. TanStack Query: A Data Fetching Superpower

The Superpower: TanStack Query

While useEffect and useState work for simple requests, they quickly become complex for managing a real-world application. You have to handle:

  • Loading and error states for each component.
  • Caching data to avoid redundant requests.
  • Background refetching to keep data fresh.
  • Invalidating and updating the cache after a POST, PUT, or DELETE.
  • Handling multiple requests with race conditions.

This leads to a lot of boilerplate and potential bugs. This is where TanStack Query (formerly React Query) shines. It's a library that handles all these complexities for you declaratively. It's not a state management library for all your application's state, but rather for your server state.

The Problem: Native State Management for Caching & Refetching

// This is an example of the kind of boilerplate TanStack Query solves.
import React, { useState, useEffect } from 'react';

function NativeStateManagementExample() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [shouldRefetch, setShouldRefetch] = useState(false);

  useEffect(() => {
    setLoading(true);
    fetch('https://api.example.com/items')
      .then(res => {
        if (!res.ok) throw new Error('Network response was not ok');
        return res.json();
      })
      .then(json => setData(json))
      .catch(err => setError(err))
      .finally(() => setLoading(false));
  }, [shouldRefetch]);

  return (
    <div>
      <button onClick={() => setShouldRefetch(!shouldRefetch)}>Refresh Data</button>
      {loading && <div>Loading...</div>}
      {error && <div>Error: {error.message}</div>}
      {data && data.map(item => <p key={item.id}>{item.name}</p>)}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Solution: TanStack Query's useQuery Hook

// You'll need to set up a QueryClientProvider at the root of your app
import { useQuery } from '@tanstack/react-query';

const fetchItems = async () => {
  const res = await fetch('https://api.example.com/items');
  if (!res.ok) {
    throw new Error('Network response was not ok');
  }
  return res.json();
};

function TanStackQueryExample() {
  const { data, isLoading, isError, error, refetch } = useQuery({
    queryKey: ['items'],
    queryFn: fetchItems,
  });

  if (isLoading) return <div>Loading...</div>;
  if (isError) return <div>Error: {error.message}</div>;

  return (
    <div>
      <button onClick={() => refetch()}>Refresh Data</button>
      {data.map(item => <p key={item.id}>{item.name}</p>)}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Power of Caching and Stale-while-revalidate

At its core, TanStack Query is about caching. When a query is first performed, the data is cached under its unique queryKey. Subsequent requests for the same queryKey will instantly return the cached data while a new, "stale" request is performed in the background to ensure the data is up to date. This pattern is known as stale-while-revalidate and provides an excellent user experience by making the UI feel instantly responsive.

Handling Mutations with useMutation

For POST, PUT, and DELETE operations, TanStack Query provides the useMutation hook. Mutations are treated differently because they modify server-side data. useMutation gives you a function to trigger the request (mutate) and provides state variables like isPending and isSuccess to manage the UI. Most importantly, it has powerful callback functions like onSuccess and onError that allow you to invalidate and refetch related queries, ensuring your UI always reflects the latest server state.


4. Combining Axios and TanStack Query

The Ultimate Combination: Axios + TanStack Query

This is a powerful combination for building robust and developer-friendly applications. Think of it this way:

  • Axios handles the "how" of data fetching: a clean, configurable, and robust way to make the actual HTTP call. You define a single Axios instance, which centralizes your base URL, timeouts, and most importantly, request and response interceptors.
  • TanStack Query handles the "what" and "when" of data fetching: a declarative way to manage the entire data fetching and synchronization lifecycle. It manages loading states, caching, background refetching, and automatically updates the UI after mutations.

Example of an Integrated Solution

Axios Instance

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

const api = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 5000,
  headers: {
    'Content-Type': 'application/json',
    // 'Authorization': `Bearer ${token}` // You could add an auth header here
  },
});

export default api;
Enter fullscreen mode Exit fullscreen mode

React Component using TanStack Query + Axios

// components/CombinedExample.jsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../services/api'; // Our configured Axios instance
import React from 'react';

// A reusable function for fetching all items
const fetchItems = async () => {
  const { data } = await api.get('/items');
  return data;
};

// A reusable function for creating an item
const createItem = async (newItem) => {
  const { data } = await api.post('/items', newItem);
  return data;
};

// A reusable function for deleting an item
const deleteItem = async (itemId) => {
  await api.delete(`/items/${itemId}`);
  return itemId;
};

function CombinedExample() {
  const queryClient = useQueryClient();

  // Use TanStack Query to manage the 'GET' request for all items
  const { data, isLoading, isError, error } = useQuery({
    queryKey: ['items'],
    queryFn: fetchItems,
  });

  // Use a mutation to handle the 'POST' request
  const createMutation = useMutation({
    mutationFn: createItem,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['items'] });
    },
  });

  // Use a mutation to handle the 'DELETE' request
  const deleteMutation = useMutation({
    mutationFn: deleteItem,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['items'] });
    },
  });

  const handleCreate = () => {
    createMutation.mutate({ name: 'New Item From Mutation' });
  };

  const handleDelete = (id) => {
    deleteMutation.mutate(id);
  };

  if (isLoading) return <div>Loading...</div>;
  if (isError) return <div>Error: {error.message}</div>;

  return (
    <div className="p-4">
      <h2 className="text-2xl font-bold mb-4">Item List</h2>
      <button
        onClick={handleCreate}
        className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors"
      >
        Add New Item
      </button>
      {createMutation.isPending && <p className="text-blue-500 mt-2">Adding item...</p>}
      {deleteMutation.isPending && <p className="text-red-500 mt-2">Deleting item...</p>}
      <ul className="mt-4 space-y-2">
        {data.map(item => (
          <li key={item.id} className="flex justify-between items-center bg-gray-100 p-3 rounded-md shadow-sm">
            <span className="text-lg">{item.name}</span>
            <button
              onClick={() => handleDelete(item.id)}
              className="px-3 py-1 bg-red-500 text-white rounded-md text-sm hover:bg-red-600 transition-colors"
            >
              Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default CombinedExample;
Enter fullscreen mode Exit fullscreen mode

Final Thoughts

This final, comprehensive example shows the best of both worlds:

  • Axios provides a clean, configurable, and robust way to make the actual HTTP call. The separate api.js file is a best practice for managing this.
  • TanStack Query takes care of all the complex data-fetching state management, caching, and automatic UI updates.

This makes your code more readable, efficient, and less prone to bugs. You just tell it what to do (useQuery, useMutation), and it handles the rest.


✅ Summary

  • Use fetch for basics.

  • Use Axios for cleaner syntax & interceptors.

  • Use TanStack Query for caching, refetching & lifecycle management.

  • Combine Axios + TanStack Query for production-ready apps.

Top comments (0)