Introduction
When building web apps, making HTTP requests is something you'll do a lot. Whether you're using fetch
, Axios, or another library, having a simple way to handle these requests can make your code easier to manage.
The Problem
Here's how API calls often look in many codebases:
// Scattered throughout your app...
async function getUser(id: string) {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error("Failed to fetch user");
return response.json();
}
async function updateUser(id: string, data: UserData) {
const response = await fetch(`/api/users/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error("Failed to update user");
return response.json();
}
Issues:
Error handling is repeated
Changing from
fetch
to Axios needs updates everywhereHeaders and request settings are spread out
A Better Approach
Let's create a simple request function that handles these concerns:
type RequestConfig = {
method?: "GET" | "POST" | "PUT" | "DELETE";
headers?: Record<string, string>;
data?: unknown;
};
async function request<TResponse>(
url: string,
config: RequestConfig = {}
): Promise<TResponse> {
const { method = "GET", headers = {}, data } = config;
const response = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
...headers,
},
...(data && { body: JSON.stringify(data) }),
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json();
}
// Usage
interface User {
id: string;
name: string;
}
async function getUser(id: string) {
return request<User>(`/api/users/${id}`);
}
async function updateUser(id: string, data: Partial<User>) {
return request<User>(`/api/users/${id}`, {
method: "PUT",
data,
});
}
Want to Use Axios Instead?
Just swap the implementation, keeping the same interface:
import axios from "axios";
async function request<TResponse>(
url: string,
config: RequestConfig = {}
): Promise<TResponse> {
const { method = "GET", headers, data } = config;
const response = await axios({
url,
method,
headers,
data,
});
return response.data;
}
Your application code doesn't need to change at all!
Going Further
Need to handle authentication? Add retries? Just enhance your request function:
async function request<TResponse>(
url: string,
config: RequestConfig = {}
): Promise<TResponse> {
const { method = "GET", headers = {}, data } = config;
// Add auth token
const token = getAuthToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
try {
const response = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
...headers,
},
...(data && { body: JSON.stringify(data) }),
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json();
} catch (error) {
// Handle retries, logging, etc.
throw error;
}
}
The great thing about this approach is that all HTTP-related tasks are managed in one spot. Your application code remains clean and focused on the main business logic.
Remember
Good abstractions don't try to handle every possible case.
They handle the usual cases well and are flexible enough to extend when necessary.