Accelerating API Development with TanStack Query
In modern frontend development, working with APIs can be repetitive, error‑prone, and time‑consuming. TanStack Query already helps with caching, background refetching, and status management — but pairing it with an API factory can eliminate tons of boilerplate and make changes safer.
The Problem with Repetitive API Hooks
Without a centralized abstraction, each API call typically needs its own hook:
- Define
queryKey
andqueryFn
- Handle loading and error states
- Map/validate data manually
- Repeat retry, cache, and invalidation logic
This leads to copy‑pasted code and risky cross‑cutting changes.
The Factory Solution (Complete Example)
A factory function can generate ready‑to‑use hooks for CRUD operations, removing repetition and centralizing behavior.
Minimal apiService
(replace with axios/fetch in your app)
ts
123456789101112131415161718192021222324252627282930313233343536
// api-service.ts
export const apiService = {
async get(url: string, params?: Record<string, any>) {
const qs = params
? "?" + new URLSearchParams(params as Record<string, string>).toString()
: "";
const res = await fetch(url + qs, { credentials: "include" });
if (!res.ok) throw new Error(`GET ${url}: ${res.status}`);
return { data: await res.json() };
},
async post<T>(url: string, body: T) {
const res = await fetch(url, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`POST ${url}: ${res.status}`);
return { data: await res.json() };
},
async put<T>(url: string, body: T) {
const res = await fetch(url, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`PUT ${url}: ${res.status}`);
return { data: await res.json() };
},
async delete(url: string) {
const res = await fetch(url, { method: "DELETE", credentials: "include" });
if (!res.ok) throw new Error(`DELETE ${url}: ${res.status}`);
return { data: true };
},
};
The Factory
ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
// api-factory.ts
import { useQuery, useMutation, UseQueryOptions } from "@tanstack/react-query";
import { apiService } from "./api-service";
type QueryParams = Record<string, any> | undefined;
export function createApi<T>(opts: {
baseEndpoint: string;
entityKey: string;
}) {
const { baseEndpoint, entityKey } = opts;
return {
useGetListQuery: (params?: QueryParams, options?: UseQueryOptions<T[]>) =>
useQuery<T[]>({
queryKey: [entityKey, "list", params],
queryFn: async () => {
const res = await apiService.get(baseEndpoint, params);
return res.data as T[];
},
...(options as any),
}),
useGetByIDQuery: (id: number | string, options?: UseQueryOptions<T>) =>
useQuery<T>({
queryKey: [entityKey, "byId", id],
queryFn: async () => {
const res = await apiService.get(`${baseEndpoint}/${id}`);
return res.data as T;
},
enabled: id != null,
...(options as any),
}),
useCreateMutation: () =>
useMutation({
mutationFn: async (data: Partial<T>) => {
const res = await apiService.post(baseEndpoint, data);
return res.data as T;
},
}),
useUpdateMutation: () =>
useMutation({
mutationFn: async (input: { id: number | string } & Partial<T>) => {
const { id, ...data } = input;
const res = await apiService.put(`${baseEndpoint}/${id}`, data);
return res.data as T;
},
}),
useDeleteMutation: () =>
useMutation({
mutationFn: async (id: number | string) => {
await apiService.delete(`${baseEndpoint}/${id}`);
return id;
},
}),
};
}
Define an Entity API
ts
12345678910111213
// product-api.ts
import { createApi } from "./api-factory";
export type Product = {
id: number;
name: string;
price: number;
};
export const productApi = createApi<Product>({
baseEndpoint: "/products",
entityKey: "products",
});
Use in a Component
tsx
12345678910111213141516171819
// ProductList.tsx
import { productApi } from "./product-api";
export function ProductList() {
const { data, isLoading, isError } = productApi.useGetListQuery();
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Failed to load</div>;
return (
<ul>
{data?.map((p) => (
<li key={p.id}>
{p.name} — ${p.price}
</li>
))}
</ul>
);
}
Create / Update / Delete
tsx
12345678910111213141516171819202122232425262728
// ProductEditor.tsx
import { productApi } from "./product-api";
export function ProductEditor() {
const createProduct = productApi.useCreateMutation();
const updateProduct = productApi.useUpdateMutation();
const deleteProduct = productApi.useDeleteMutation();
return (
<div>
<button
onClick={() => createProduct.mutate({ name: "New", price: 100 })}
>
Create
</button>
<button
onClick={() => updateProduct.mutate({ id: 1, price: 150 })}
>
Update #1
</button>
<button onClick={() => deleteProduct.mutate(1)}>
Delete #1
</button>
</div>
);
}
Benefits of This Approach
- Less Boilerplate — One factory replaces dozens of repetitive hooks.
- Consistency — All queries share retry logic, caching, and data handling.
- Scalability — Adding new endpoints is quick and predictable.
- Centralized Maintenance — API changes require edits in one place.
When to Use It
The factory pattern shines in medium‑to‑large projects with many endpoints and frequent API changes. For very small/stable APIs, it might be optional overhead.