Core Concepts
Builder Object

Builder Object

What is a Builder Object?

When you use createBuilder(register), you get back a builder object: your original register with two special methods added, $use and $get.

Think of it like this:

// Before: Your register
const register = {
  users: {
    list: () => fetch('/api/users')
  }
};
 
// After: Your builder
const api = createBuilder(register);
// Now has: api.$use, api.$get, api.users.$use, api.users.$get, etc.

The builder object has the same structure as your register, but with superpowers.

The Two Core Methods

$use - The Dual-Purpose Method

$use works differently depending on where you use it:

At the Root Level (Access Values)

const register = {
  users: {
    list: () => fetch('/api/users'),
    detail: (id: number) => fetch(`/api/users/${id}`)
  }
};
 
const api = createBuilder(register);
 
// Access the original functions
await api.$use.users.list();        // Calls fetch('/api/users')
await api.$use.users.detail(42);    // Calls fetch('/api/users/42')

Root $use = Access your original values

At Any Other Level (Generate Keys)

// Generate keys for cache systems
api.users.list.$use();       // ['users', 'list']
api.users.detail.$use(42);   // ['users', 'detail', 42]

Node $use = Generate array keys with arguments

$get - The Flexible Key Generator

$get creates keys by combining the path with optional extra segments:

const api = createBuilder({
  posts: {
    detail: (id: number) => fetch(`/api/posts/${id}`)
  }
});
 
// Basic usage - same as $use
api.posts.detail.$get();              // ['posts', 'detail']
 
// Add extra segments
api.posts.detail.$get('comments');    // ['posts', 'detail', 'comments']
api.posts.detail.$get('meta', 'tags'); // ['posts', 'detail', 'meta', 'tags']

$get = Generate keys with flexible segments

When to Use Each Method

MethodPurposeExample
Root $useCall your original functionsapi.$use.users.list()
Node $useGenerate keys with function argumentsapi.users.detail.$use(42)
$getGenerate keys with custom segmentsapi.users.$get('active')

Real-World Examples

Example 1: TanStack Query Integration

import { useQuery, useMutation } from '@tanstack/react-query';
 
const api = createBuilder({
  posts: {
    list: async () => {
      return fetch('/api/posts').then(r => r.json());
    },
    detail: async (id: number) => {
      return fetch(`/api/posts/${id}`).then(r => r.json());
    },
    create: async (data: Post) => {
      return fetch('/api/posts', {
        method: 'POST',
        body: JSON.stringify(data)
      }).then(r => r.json());
    }
  }
});
 
function PostList() {
  // $use at nodes = keys, $use at root = values
  const { data } = useQuery({
    queryKey: api.posts.list.$use(),      // Generate key
    queryFn: api.$use.posts.list          // Access function
  });
}
 
function PostDetail({ id }: { id: number }) {
  const { data } = useQuery({
    queryKey: api.posts.detail.$use(id),  // ['posts', 'detail', 42]
    queryFn: () => api.$use.posts.detail(id)
  });
}

Example 2: Using $get for Related Data

const api = createBuilder({
  users: {
    detail: (id: number) => fetch(`/api/users/${id}`).then(r => r.json()),
    posts: (id: number) => fetch(`/api/users/${id}/posts`).then(r => r.json())
  }
});
 
function UserProfile({ userId }: { userId: number }) {
  // Main user data
  const { data: user } = useQuery({
    queryKey: api.users.detail.$use(userId),
    queryFn: () => api.$use.users.detail(userId)
  });
 
  // Related posts - using $get to add 'posts' segment
  const { data: posts } = useQuery({
    queryKey: api.users.$get(userId, 'posts'),  // ['users', userId, 'posts']
    queryFn: () => api.$use.users.posts(userId)
  });
}

Example 3: Prefetching and Cache Manipulation

import { queryClient } from './queryClient';
 
const api = createBuilder({
  products: {
    list: () => fetch('/api/products').then(r => r.json()),
    detail: (id: number) => fetch(`/api/products/${id}`).then(r => r.json())
  }
});
 
async function prefetchProduct(id: number) {
  // Use root $use to call the function
  await queryClient.prefetchQuery({
    queryKey: api.products.detail.$use(id),
    queryFn: () => api.$use.products.detail(id)
  });
}
 
function invalidateProductCache() {
  // Use $get to target all product queries
  queryClient.invalidateQueries({
    queryKey: api.products.$get()  // ['products'] - invalidates all
  });
}
 
function invalidateSpecificProduct(id: number) {
  // Use $use to target specific product
  queryClient.invalidateQueries({
    queryKey: api.products.detail.$use(id)  // ['products', 'detail', 42]
  });
}

Understanding the Structure

Here's what the builder object actually looks like internally:

const register = {
  location: (id: number) => `/location/${id}`,
  address: {
    country: 'Nigeria',
  },
};
 
const builder = createBuilder(register);
 
// The builder structure:
{
  // Root level - $use accesses original register
  $use: register,
  $get: () => [],
 
  // Node level - $use generates keys
  location: {
    $use: (id: number) => ['location', id],
    $get: (...segments) => ['location', ...segments],
  },
 
  address: {
    $use: () => ['address'],
    $get: (...segments) => ['address', ...segments],
 
    country: {
      $use: () => ['address', 'country'],
      $get: (...segments) => ['address', 'country', ...segments],
    },
  },
}

Every level of the builder has both $use and $get methods, making key generation available anywhere in your structure.

Best Practices

1. Use Consistent Patterns

// ✅ Good - consistent pattern
const { data } = useQuery({
  queryKey: api.users.list.$use(),
  queryFn: api.$use.users.list
});
 
// ❌ Confusing - mixing patterns
const { data } = useQuery({
  queryKey: ['users', 'list'],  // Manual key
  queryFn: api.$use.users.list  // Builder value
});

2. Leverage TypeScript

// TypeScript knows the exact structure
api.$use.users.list();      // ✅ TypeScript: Promise<User[]>
api.users.list.$use();      // ✅ TypeScript: ['users', 'list']
api.users.detail.$use(42);  // ✅ TypeScript: ['users', 'detail', number]

3. Use $get for Hierarchical Queries

const api = createBuilder({
  users: {
    detail: (id: number) => fetchUser(id)
  }
});
 
// Invalidate all user-related queries
queryClient.invalidateQueries({
  queryKey: api.users.$get()  // ['users']
});
 
// Invalidate specific user
queryClient.invalidateQueries({
  queryKey: api.users.detail.$use(42)  // ['users', 'detail', 42]
});
 
// Custom sub-queries
queryClient.prefetchQuery({
  queryKey: api.users.$get(42, 'settings'),  // ['users', 42, 'settings']
  queryFn: () => fetch(`/api/users/42/settings`).then(r => r.json())
});

Common Patterns

Pattern 1: List and Detail Queries

const api = createBuilder({
  products: {
    list: ({ category }: { category?: string } = {}) =>
      fetch(`/api/products${category ? `?category=${category}` : ''}`).then(r => r.json()),
    detail: (id: number) =>
      fetch(`/api/products/${id}`).then(r => r.json())
  }
});
 
// List all products
useQuery({
  queryKey: api.products.list.$use(),
  queryFn: api.$use.products.list
});
 
// List filtered products
useQuery({
  queryKey: api.products.list.$use({ category: 'electronics' }),
  queryFn: () => api.$use.products.list({ category: 'electronics' })
});
 
// Product detail
useQuery({
  queryKey: api.products.detail.$use(123),
  queryFn: () => api.$use.products.detail(123)
});

Pattern 2: Mutations with Invalidation

const api = createBuilder({
  todos: {
    list: () => fetch('/api/todos').then(r => r.json()),
    create: (todo: Todo) =>
      fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(todo)
      }).then(r => r.json())
  }
});
 
const mutation = useMutation({
  mutationFn: (todo: Todo) => api.$use.todos.create(todo),
  onSuccess: () => {
    // Invalidate all todos queries
    queryClient.invalidateQueries({
      queryKey: api.todos.$get()  // ['todos']
    });
  }
});

Pattern 3: Dependent Queries

const api = createBuilder({
  user: {
    profile: (id: number) => fetch(`/api/users/${id}`).then(r => r.json()),
    posts: (userId: number) => fetch(`/api/users/${userId}/posts`).then(r => r.json())
  }
});
 
function UserDashboard({ userId }: { userId: number }) {
  const { data: user } = useQuery({
    queryKey: api.user.profile.$use(userId),
    queryFn: () => api.$use.user.profile(userId)
  });
 
  const { data: posts } = useQuery({
    queryKey: api.user.posts.$use(userId),
    queryFn: () => api.$use.user.posts(userId),
    enabled: !!user  // Only fetch posts after user loads
  });
}

Summary

  • Builder Object = Your register + $use + $get methods at every level
  • Root $use = Access original values (api.$use.users.list())
  • Node $use = Generate keys with arguments (api.users.list.$use())
  • $get = Generate keys with custom segments (api.users.$get('active'))
  • Both methods are prefixed with $ to avoid conflicts with your keys

Next Steps