Fetch and Axios
You can talk to APIs with the built-in fetch or the popular axios library. Both work; what matters is wrapping them in one small API module so components stay simple.
On this page · 6 min · Intermediate
On this page
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 6 min
- Difficulty
- Intermediate
- Updated
- Oct 28, 2026
Related tutorials
Learning objectives#
- Send GET, POST, PUT and DELETE with fetch
- Do the same with axios
- Compare their error handling and defaults
- Build a reusable API client with a base URL and auth header
Explanation#
fetch is built into browsers. For JSON you set Content-Type and JSON.stringify the body, call res.json() yourself, and check res.ok.
axios (npm install axios) parses JSON automatically, throws for any non-2xx status, supports interceptors (add a token to every request, handle 401 globally) and has a simple timeout option.
Either way, do not scatter fetch calls through components. Create an api.js module with functions like getProducts() and createProduct(data).
Configure the base URL from an environment variable: import.meta.env.VITE_API_URL in Vite.
Send auth tokens in the Authorization header; with cookies, use credentials: "include" (fetch) or withCredentials: true (axios).
Syntax#
// fetch
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(product),
});
if (!res.ok) throw new Error(res.status);
const created = await res.json();
// axios
const { data: created } = await axios.post('/api/products', product);
Code example: One API module, two implementations#
import axios from 'axios';
const BASE_URL = import.meta.env.VITE_API_URL ?? 'https://jsonplaceholder.typicode.com';
// ── fetch version ──────────────────────────────────────────
async function request(path, { method = 'GET', body, token } = {}) {
const res = await fetch(BASE_URL + path, {
method,
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`${method} ${path} failed: ${res.status}`);
return res.status === 204 ? null : res.json();
}
export const fetchApi = {
listTodos: () => request('/todos?_limit=5'),
createTodo: todo => request('/todos', { method: 'POST', body: todo }),
updateTodo: (id, todo) => request(`/todos/${id}`, { method: 'PUT', body: todo }),
deleteTodo: id => request(`/todos/${id}`, { method: 'DELETE' }),
};
// ── axios version ──────────────────────────────────────────
const http = axios.create({ baseURL: BASE_URL, timeout: 10000 });
http.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
export const axiosApi = {
listTodos: () => http.get('/todos', { params: { _limit: 5 } }).then(r => r.data),
createTodo: todo => http.post('/todos', todo).then(r => r.data),
updateTodo: (id, todo) => http.put(`/todos/${id}`, todo).then(r => r.data),
deleteTodo: id => http.delete(`/todos/${id}`),
};
// In a component:
// const todos = await fetchApi.listTodos();
// await axiosApi.createTodo({ title: 'Learn axios', completed: false });
Both objects expose the same four functions. fetchApi.listTodos() and axiosApi.listTodos() each resolve to an array of 5 todos. createTodo resolves to the created todo (the test API returns it with id 201). A 404 throws in both versions — fetchApi via the res.ok check, axios automatically.
This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- BASE_URL comes from VITE_API_URL so dev, staging and production can differ.
- The fetch helper adds JSON headers and the token only when needed, checks res.ok and handles 204 No Content.
- axios.create makes an instance with a base URL and timeout; an interceptor adds the token to every request.
- Components import api functions and never build URLs or headers themselves.
Real-world use case#
A React admin app keeps every endpoint in src/api/*.js. When the backend moves from /api/v1 to /api/v2, one file changes and no component is touched.
Common mistakes#
Without application/json, many servers (ASP.NET Core [FromBody], Express json()) see an empty or unparsed body.
It does not. Check res.ok. axios does throw.
The production build will still call localhost. Use environment variables.
Best practices#
- One API module per backend area
- Base URL from env variables
- Central auth header and error handling
- Return plain data to components
Remember this#
fetch: built-in, check res.ok, JSON.stringify yourself.
axios: auto JSON, throws on errors, interceptors.
Components call api.getX(), never raw URLs.
Practice exercise#
Your task: Add a getTodo(id) function to fetchApi and a component that loads todo 1 and shows its title.
Need a hint?
Show solution
import { useEffect, useState } from 'react';
const getTodo = async id => {
const res = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
if (!res.ok) throw new Error(res.status);
return res.json();
};
export default function TodoTitle() {
const [title, setTitle] = useState('Loading…');
useEffect(() => {
getTodo(1).then(t => setTitle(t.title)).catch(() => setTitle('Failed to load'));
}, []);
return <p>{title}</p>;
}
Mini quiz#
Which statement is true?
Want more? Practise all React MCQs · React interview questions
My notes#
Saved in this browser.