Usage
Managing State

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

  1. Organize by resource: Group related endpoints together

    const api = createBuilder({
      users: { list, detail, create, update, delete },
      posts: { list, detail, create, update, delete }
    });
  2. Use prefixes for versioning:

    const api = createBuilder(register, { prefix: ['api', 'v2'] });
    // All keys: ['api', 'v2', 'users', 'list', ...]
  3. 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 */ }
  4. 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