Real apps talk to servers. In this part, we’ll build a clean, typed HTTP layer using Axios and wire it to our UI. We’ll explain every piece and add comments right in the code so it’s copy‑paste ready.
🎯 Goals
- One shared Axios client with sensible defaults
- Typed request/response/error shapes
- DTO ↔ Domain mapping (convert server shapes to UI‑friendly types)
- Cancellation with
AbortController - A tiny retry helper (with exponential backoff)
- A commented TaskList that fetches & toggles tasks
- Bonus: a minimal mock for local testing
0) Install (if needed)
pnpm add axios
pnpm add -D axios-mock-adapter # optional, for local API mocks
1) Shared Axios client (one place for baseURL, headers, errors)
// src/lib/api/http.ts
import axios from 'axios';
// A simple, typed error shape we’ll use across the app
export type ApiError = { status: number; message: string };
// Create a single Axios instance used everywhere
export const http = axios.create({
baseURL: import.meta.env.VITE_API_URL, // set in .env (e.g., http://localhost:3000)
timeout: 10_000, // avoid hanging forever (10s)
withCredentials: false, // set true if your API uses cookies
});
// Attach auth token (if you add auth later)
http.interceptors.request.use((config) => {
const token = localStorage.getItem('auth:token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Normalize errors so components don’t need to know Axios internals
http.interceptors.response.use(
(r) => r,
(err) => {
const status = err.response?.status ?? 0;
const message = err.response?.data?.message ?? err.message;
return Promise.reject({ status, message } as ApiError);
},
);
Why: You avoid repeating base URL, headers, and error parsing in each API call.
2) DTO ↔ Domain mapping (UI shouldn’t care about server quirks)
// src/lib/api/tasks.types.ts
// Server shape (DTO). Servers often use snake_case and string dates.
export type TaskDTO = {
id: string;
title: string;
done: boolean;
created_at: string; // ISO string like "2025-08-26T10:00:00Z"
};
// UI shape (Domain). We prefer camelCase and real Date objects.
export type Task = {
id: string;
title: string;
done: boolean;
createdAt: Date;
};
export const toDomain = (d: TaskDTO): Task => ({
id: d.id,
title: d.title,
done: d.done,
createdAt: new Date(d.created_at),
});
// For create/update we only send what server expects
export type TaskCreate = { title: string };
export type TaskPatch = Partial<{ title: string; done: boolean }>;
3) Typed API functions (list/get/create/toggle/delete) + cancellation
// src/lib/api/tasks.ts
import { http, type ApiError } from './http';
import type { TaskDTO, Task, TaskCreate, TaskPatch } from './tasks.types';
import { toDomain } from './tasks.types';
// List tasks (with basic pagination) — supports AbortController via opts.signal
export async function listTasks(opts?: {
page?: number;
pageSize?: number;
signal?: AbortSignal;
}): Promise<{ items: Task[]; total: number }> {
const res = await http.get<{ items: TaskDTO[]; total: number }>(
'/tasks',
{ params: { page: opts?.page ?? 1, pageSize: opts?.pageSize ?? 10 }, signal: opts?.signal },
);
return { items: res.data.items.map(toDomain), total: res.data.total };
}
export async function getTask(id: string, signal?: AbortSignal): Promise<Task> {
const res = await http.get<TaskDTO>(`/tasks/${id}`, { signal });
return toDomain(res.data);
}
export async function createTask(body: TaskCreate): Promise<Task> {
const res = await http.post<TaskDTO>('/tasks', body);
return toDomain(res.data);
}
export async function updateTask(id: string, patch: TaskPatch): Promise<Task> {
const res = await http.patch<TaskDTO>(`/tasks/${id}`, patch);
return toDomain(res.data);
}
export async function toggleTask(id: string): Promise<Task> {
const res = await http.patch<TaskDTO>(`/tasks/${id}/toggle`);
return toDomain(res.data);
}
export async function deleteTask(id: string): Promise<void> {
await http.delete(`/tasks/${id}`);
}
Components only import these functions — never Axios directly — which keeps UI code tidy and testable.
4) Retry helper (optional but handy)
// src/lib/api/retry.ts
export async function retry<T>(fn: () => Promise<T>, attempts = 3, baseMs = 300): Promise<T> {
let last: unknown;
for (let i = 0; i < attempts; i++) {
try { return await fn(); }
catch (e) {
last = e;
const wait = baseMs * 2 ** i + Math.random() * 100; // backoff + jitter
await new Promise((r) => setTimeout(r, wait));
}
}
throw last; // bubble the last error
}
Use it anywhere:
import { retry } from './retry';
import { listTasks } from './tasks';
const { items } = await retry(() => listTasks({ page: 1 }));
In Part 8, TanStack Query will provide built‑in retries and caching.
5) A simple data‑fetching hook with cancellation
// src/features/tasks/useTasksFetch.tsx
import { useEffect, useState } from 'react';
import { listTasks, toggleTask, type Task } from '@/lib/api/tasks';
import type { ApiError } from '@/lib/api/http';
export function useTasksFetch(page = 1) {
const [items, setItems] = useState<Task[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
useEffect(() => {
const ctrl = new AbortController(); // cancel if component unmounts or page changes
setLoading(true);
listTasks({ page, pageSize: 10, signal: ctrl.signal })
.then(({ items, total }) => { setItems(items); setTotal(total); })
.catch((e) => { if ((e as ApiError).message) setError(e as ApiError); })
.finally(() => setLoading(false));
return () => ctrl.abort();
}, [page]);
// convenience action with local optimistic update
const flip = async (id: string) => {
setItems((prev) => prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
try { const updated = await toggleTask(id);
setItems((prev) => prev.map((t) => (t.id === id ? updated : t)));
} catch (e) {
// rollback on error
setItems((prev) => prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
throw e;
}
};
return { items, total, loading, error, flip } as const;
}
6) UI wiring (commented TaskList)
// src/features/tasks/TaskList.tsx
import { useState } from 'react';
import { useTasksFetch } from './useTasksFetch';
export function TaskList() {
const [page, setPage] = useState(1);
const { items, total, loading, error, flip } = useTasksFetch(page);
if (loading) return <p>Loading…</p>;
if (error) return <p className="text-red-600">❌ {error.message}</p>;
return (
<section>
<h2 className="text-xl font-semibold mb-2">Tasks</h2>
<ul className="space-y-2">
{items.map((t) => (
<li key={t.id} className="flex items-center gap-2">
<input type="checkbox" checked={t.done} onChange={() => void flip(t.id)} />
<span>{t.title}</span>
<span className="text-gray-500 text-sm ml-auto">
{t.createdAt.toLocaleDateString()}
</span>
</li>
))}
</ul>
<div className="mt-3 flex items-center gap-2">
<button
className="border rounded px-3 py-1"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>Prev</button>
<span className="text-sm">Page {page}</span>
<button
className="border rounded px-3 py-1"
onClick={() => setPage((p) => p + 1)}
disabled={items.length === 0 || items.length + (page - 1) * 10 >= total}
>Next</button>
</div>
</section>
);
}
7) (Optional) Local mocking for demos/tests
// src/lib/api/mock.ts (dev only)
import MockAdapter from 'axios-mock-adapter';
import { http } from './http';
export function installMock() {
const mock = new MockAdapter(http, { delayResponse: 300 });
let tasks = [
{ id: '1', title: 'Buy milk', done: false, created_at: new Date().toISOString() },
{ id: '2', title: 'Write blog', done: true, created_at: new Date().toISOString() },
];
mock.onGet('/tasks').reply((config) => {
const page = Number((config.params?.page ?? 1));
const pageSize = Number((config.params?.pageSize ?? 10));
const start = (page - 1) * pageSize;
const items = tasks.slice(start, start + pageSize);
return [200, { items, total: tasks.length }];
});
mock.onPost('/tasks').reply((config) => {
const body = JSON.parse(config.data);
const t = { id: crypto.randomUUID(), title: body.title, done: false, created_at: new Date().toISOString() };
tasks.unshift(t);
return [201, t];
});
mock.onPatch(/\/tasks\/[^/]+\/toggle/).reply((config) => {
const id = config.url!.split('/').pop()!; // naive parse for demo
tasks = tasks.map((t) => (t.id === id ? { ...t, done: !t.done } : t));
const updated = tasks.find((t) => t.id === id)!;
return [200, updated];
});
}
Call installMock() once during development (e.g., in main.tsx) to run the app without a real backend.
8) Common pitfalls & fixes
- Leaking requests: always abort in
useEffectcleanup. - UI uses server shapes: map DTO → Domain early; keep dates/enums nice in the UI.
- Sprinkling Axios in components: keep all HTTP in
/lib/apifunctions. - Forgetting timeouts: set a reasonable
timeoutso you can show helpful errors.
✅ Wrap‑Up
You now have a solid, beginner‑friendly HTTP layer:
- Central Axios client with typed
ApiError - Clean DTO ↔ Domain mapping
- Cancellable fetches + a small retry helper
- A commented TaskList that fetches and toggles items
👉 Next up: Part 8 — Server State with TanStack Query for caching, retries, background refetch, and optimistic updates — all fully typed.
Comments
Post a Comment