Skip to main content

Fetch API

The Fetch API is the native browser interface for making HTTP requests. No jQuery, no axios needed for most use cases.

Basic Usage

// GET
const res = await fetch('https://api.github.com/users/octocat');
const user = await res.json();

// Check status before using response
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}

A Typed Fetch Helper

Build this once per project and use it everywhere:

src/lib/api.ts
class HttpError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = 'HttpError';
}
}

async function apiFetch<T>(
url: string,
options: RequestInit = {}
): Promise<T> {
const res = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});

if (!res.ok) {
let message = res.statusText;
try {
const body = await res.json();
message = body.message ?? message;
} catch {}
throw new HttpError(res.status, message);
}

return res.json() as Promise<T>;
}

export const api = {
get: <T>(url: string, init?: RequestInit) =>
apiFetch<T>(url, { ...init, method: 'GET' }),

post: <T>(url: string, body: unknown, init?: RequestInit) =>
apiFetch<T>(url, {
...init,
method: 'POST',
body: JSON.stringify(body),
}),

put: <T>(url: string, body: unknown, init?: RequestInit) =>
apiFetch<T>(url, {
...init,
method: 'PUT',
body: JSON.stringify(body),
}),

patch: <T>(url: string, body: unknown, init?: RequestInit) =>
apiFetch<T>(url, {
...init,
method: 'PATCH',
body: JSON.stringify(body),
}),

delete: <T>(url: string, init?: RequestInit) =>
apiFetch<T>(url, { ...init, method: 'DELETE' }),
};
Usage
import { api } from '@/lib/api';

interface User { id: number; name: string; email: string; }

// GET
const user = await api.get<User>('/api/users/1');

// POST
const newUser = await api.post<User>('/api/users', { name: 'Alice', email: 'alice@example.com' });

// Error handling
try {
const data = await api.get<User[]>('/api/users');
} catch (err) {
if (err instanceof HttpError && err.status === 404) {
showNotFound();
} else {
showError('Something went wrong');
}
}

Loading States Pattern

interface FetchState<T> {
data: T | null;
loading: boolean;
error: string | null;
}

async function loadUsers(): Promise<void> {
const state: FetchState<User[]> = { data: null, loading: true, error: null };
renderState(state);

try {
state.data = await api.get<User[]>('/api/users');
state.loading = false;
} catch (err) {
state.error = err instanceof Error ? err.message : 'Failed to load users';
state.loading = false;
}

renderState(state);
}

function renderState(state: FetchState<User[]>) {
const container = document.querySelector('#users')!;

if (state.loading) {
container.innerHTML = '<div class="spinner">Loading...</div>';
} else if (state.error) {
container.innerHTML = `<div class="error">${state.error}</div>`;
} else if (state.data) {
container.innerHTML = state.data.map(renderUser).join('');
}
}

Cancelling Requests

// Search with debounce + cancel previous request
let currentController: AbortController | null = null;

async function search(query: string): Promise<void> {
// Cancel previous request if still pending
currentController?.abort();
currentController = new AbortController();

try {
const results = await api.get<Result[]>(
`/api/search?q=${encodeURIComponent(query)}`,
{ signal: currentController.signal }
);
renderResults(results);
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return; // expected
showError(err);
}
}

// Debounce
const debouncedSearch = debounce(search, 300);
searchInput.addEventListener('input', (e) => {
debouncedSearch((e.target as HTMLInputElement).value);
});