Working with Data Fetching Libraries
Builder shines when used with data fetching and caching libraries like TanStack Query (opens in a new tab) and SWR (opens in a new tab). It eliminates manual key construction, ensures consistency, and makes your API structure self-documenting.
The Problem Without Builder
Here's a typical scenario when managing cache keys manually:
// ❌ Manual key management - error-prone and scattered
function UserProfile({ userId }: { userId: number }) {
const { data: user } = useQuery({
queryKey: ['users', 'detail', userId], // Manually constructed
queryFn: () => fetchUser(userId)
});
const { data: posts } = useQuery({
queryKey: ['users', 'posts', userId], // Another manual key
queryFn: () => fetchUserPosts(userId)
});
// Issues:
// 1. Key definitions are spread across components
// 2. String errors won't be caught until runtime
// 3. Functions are defined separately from keys
// 4. Difficult to maintain when API changes
}The Builder Solution
With Builder, you define your entire API structure in one place:
import { createBuilder } from '@ibnlanre/builder';
// ✅ Single source of truth for your API
const api = createBuilder({
users: {
list: async (params?: { page?: number; limit?: number }) => {
const query = new URLSearchParams(params as any).toString();
return fetch(`/api/users?${query}`).then(r => r.json());
},
detail: async (id: number) => {
return fetch(`/api/users/${id}`).then(r => r.json());
},
posts: async (userId: number) => {
return fetch(`/api/users/${userId}/posts`).then(r => r.json());
},
update: async (id: number, data: Partial<User>) => {
return fetch(`/api/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}).then(r => r.json());
}
}
});Now use it anywhere with full type-safety:
function UserProfile({ userId }: { userId: number }) {
const { data: user } = useQuery({
queryKey: api.users.detail.$use(userId), // ['users', 'detail', 123]
queryFn: () => api.$use.users.detail(userId)
});
const { data: posts } = useQuery({
queryKey: api.users.posts.$use(userId), // ['users', 'posts', 123]
queryFn: () => api.$use.users.posts(userId)
});
// Benefits:
// ✅ Autocomplete for all API endpoints
// ✅ TypeScript ensures correct parameters
// ✅ Single place to update when API changes
// ✅ Keys are always consistent
}Complete Example
Let's build a real-world API structure:
import { createBuilder } from '@ibnlanre/builder';
import axios from 'axios';
type Product = {
id: number;
name: string;
price: number;
category: string;
};
type User = {
id: number;
name: string;
email: string;
};
const api = createBuilder({
products: {
list: async (category?: string) => {
const url = category
? `/api/products?category=${category}`
: '/api/products';
return axios.get<Product[]>(url).then(r => r.data);
},
detail: async (id: number) => {
return axios.get<Product>(`/api/products/${id}`).then(r => r.data);
},
create: async (data: Omit<Product, 'id'>) => {
return axios.post<Product>('/api/products', data).then(r => r.data);
},
delete: async (id: number) => {
return axios.delete(`/api/products/${id}`).then(r => r.data);
}
},
users: {
current: async () => {
return axios.get<User>('/api/users/me').then(r => r.data);
},
update: async (data: Partial<User>) => {
return axios.patch<User>('/api/users/me', data).then(r => r.data);
}
}
});Queries with TanStack Query
Basic Queries
Use $use() to generate keys and access functions:
import { useQuery } from '@tanstack/react-query';
function ProductList({ category }: { category?: string }) {
const { data, isLoading } = useQuery({
queryKey: api.products.list.$use(category),
// ^? ['products', 'list', 'electronics'] or ['products', 'list', undefined]
queryFn: () => api.$use.products.list(category)
});
if (isLoading) return <div>Loading...</div>;
return (
<div>
{data?.map(product => (
<div key={product.id}>{product.name}</div>
))}
</div>
);
}Dependent Queries
Builder makes dependent queries type-safe and clear:
function ProductDetail({ id }: { id: number }) {
// First query
const { data: product } = useQuery({
queryKey: api.products.detail.$use(id),
queryFn: () => api.$use.products.detail(id)
});
// Dependent query - only runs when product exists
const { data: relatedProducts } = useQuery({
queryKey: api.products.list.$use(product?.category),
queryFn: () => api.$use.products.list(product!.category),
enabled: !!product // Wait for product to load
});
return <div>{/* render product and related items */}</div>;
}Flexible Keys with $get
Sometimes you need to add extra parameters to your cache key:
function ProductList({ sortBy, filters }: Props) {
const { data } = useQuery({
// Add arbitrary cache-busting parameters
queryKey: api.products.list.$get(filters.category, sortBy, filters),
// ^? ['products', 'list', 'electronics', 'price-asc', { category: '...', ... }]
queryFn: () => api.$use.products.list(filters.category)
});
return <div>{/* render */}</div>;
}Mutations
Create, Update, Delete
Builder makes mutations consistent with queries:
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreateProduct() {
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation({
mutationKey: api.products.create.$use({ name: '', price: 0, category: '' }),
// ^? ['products', 'create', { name: '', price: 0, category: '' }]
mutationFn: (data: Omit<Product, 'id'>) => api.$use.products.create(data),
onSuccess: () => {
// Invalidate and refetch product list
queryClient.invalidateQueries({
queryKey: api.products.list.$get() // ['products', 'list']
});
}
});
return (
<form onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutate({
name: formData.get('name') as string,
price: Number(formData.get('price')),
category: formData.get('category') as string
});
}}>
{/* form fields */}
<button disabled={isPending}>Create Product</button>
</form>
);
}Optimistic Updates
Builder's consistent keys make optimistic updates straightforward:
const { mutate } = useMutation({
mutationFn: (id: number) => api.$use.products.delete(id),
onMutate: async (deletedId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({
queryKey: api.products.list.$get()
});
// Snapshot previous value
const previousProducts = queryClient.getQueryData(
api.products.list.$get()
);
// Optimistically update
queryClient.setQueryData(
api.products.list.$get(),
(old: Product[]) => old.filter(p => p.id !== deletedId)
);
return { previousProducts };
},
onError: (err, deletedId, context) => {
// Rollback on error
queryClient.setQueryData(
api.products.list.$get(),
context?.previousProducts
);
}
});Common Pitfalls Builder Prevents
❌ String Inconsistencies
// Without Builder - manual strings are fragile
useQuery({
queryKey: ['users', 'list'],
queryFn: fetchUsers
});
useQuery({
queryKey: ['user', 'list'], // Singular/plural mismatch!
queryFn: fetchUsers
});
useQuery({
queryKey: ['userList'], // Completely different structure!
queryFn: fetchUsers
});
// With Builder - guaranteed consistency
api.users.list.$use(); // ✅ Always ['users', 'list']❌ Wrong Parameters
// Without Builder - runtime error
useQuery({
queryKey: ['users', 'detail'],
queryFn: fetchUser
}); // Missing userId!
// With Builder - TypeScript catches it
api.users.detail.$use(); // ❌ Error: Expected 1 argument
api.users.detail.$use(123); // ✅ Correct❌ Fragile Invalidation Logic
// Without Builder - must manually track key formats
queryClient.invalidateQueries(['users', 'list']);
queryClient.invalidateQueries(['user', 'list']); // Wrong key!
// With Builder - always correct
queryClient.invalidateQueries({
queryKey: api.users.list.$get()
});Advanced: Type-Safe Parameters
Builder ensures parameters match between keys and functions:
const api = createBuilder({
todos: {
list: async (
state: 'active' | 'completed' | 'archived',
sorting?: 'dateCreated' | 'name'
) => {
return fetch(`/api/todos?state=${state}&sort=${sorting}`).then(r => r.json());
}
}
});
// TypeScript enforces correct usage
api.todos.list.$use('active'); // ✅ Valid
api.todos.list.$use('active', 'name'); // ✅ Valid
api.todos.list.$use('invalid'); // ❌ Type error
api.todos.list.$use(); // ❌ Missing required 'state'The $use() method mirrors your function signature, while $get() accepts arbitrary parameters for flexible cache keys.
Best Practices
-
Organize by resource: Group related endpoints together
const api = createBuilder({ users: { list, detail, create, update, delete }, posts: { list, detail, create, update, delete } }); -
Use prefixes for versioning:
const api = createBuilder(register, { prefix: ['api', 'v2'] }); // All keys: ['api', 'v2', 'users', 'list', ...] -
Keep functions simple: Let Builder handle keys, functions handle data
// ✅ Good detail: (id: number) => fetch(`/users/${id}`).then(r => r.json()) // ❌ Avoid mixing concerns detail: (id: number, cacheTime: number) => { /* cache logic */ } -
Leverage TypeScript: Define types for better autocomplete
type UserAPI = { list: (params?: FilterParams) => Promise<User[]>; detail: (id: number) => Promise<User>; }; const api = createBuilder<{ users: UserAPI }>({ users: { list, detail } });
Summary
Builder transforms data fetching from error-prone manual key management into a type-safe, self-documenting system:
- ✅ Single source of truth for all API endpoints
- ✅ Type-safe keys and function calls
- ✅ Autocomplete support in your IDE
- ✅ Consistent key formatting across your app
- ✅ Easy refactoring when APIs change
Next Steps
- Learn about constructing keys for more advanced patterns
- Explore type inference to leverage TypeScript fully
- Check out the React adapter for context-based usage