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;
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));
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));
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));
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.
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;
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;
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
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>
);
}
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>
);
}
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
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;
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;
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)