React Integration

TanStack Query hooks on every endpoint — direct fetch and hooks on the same object. Zero codegen, full type safety.

$npm install @cleverbrush/client @tanstack/react-query

Requires @cleverbrush/schema, @cleverbrush/server, and react as peer dependencies.

Why @cleverbrush/client/react?

The Problem

Using TanStack Query with typed API clients means manually writing queryKey arrays, queryFn wrappers, and mutation boilerplate for every endpoint. Query keys are stringly-typed and fragile — rename an endpoint and your invalidation logic silently breaks.

The Solution

@cleverbrush/client/react wraps your defineApi() contract in a unified client where every endpoint is both a callable function (direct fetch) and an object with TanStack Query hooks. Query keys are hierarchical and deterministic — invalidate a single entry, an entire endpoint, or a whole group with one call. One import, zero boilerplate.

Quick Start

1. Create a Unified Client

Wrap your existing API contract with createClient:

import { createClient } from '@cleverbrush/client/react';
import { api } from 'shared/contract';

export const client = createClient(api, {
    baseUrl: 'https://api.example.com',
});

2. Use It — Direct Fetch or Hooks

// Direct fetch (same as @cleverbrush/client)
const todos = await client.todos.list();
const todo = await client.todos.get({ params: { id: 1 } });

// React Query hooks — on the same object
function TodoList() {
    const { data, isLoading } = client.todos.list.useQuery();

    if (isLoading) return <p>Loading…</p>;
    return (
        <ul>
            {data?.map(t => <li key={t.id}>{t.title}</li>)}
        </ul>
    );
}

Endpoint API

Every endpoint on the unified client is callable and provides these methods:

MethodDescription
(args?)Direct HTTP fetch — returns Promise<Response>
.stream(args?)NDJSON streaming — returns AsyncIterable<string>
useQuery(args?, options?)Standard TanStack Query hook with auto-generated key and fetch function
useSuspenseQuery(args?, options?)Suspense-enabled query — suspends the component until data is ready
useInfiniteQuery(argsFn, options)Infinite scrolling with a function that receives pageParam
useMutation(options?)Mutation hook — pass endpoint args via mutate(args)
queryKey(args?)Returns the query key array for cache operations
prefetch(queryClient, args?)Prefetches data into the TanStack query cache

Hierarchical Query Keys

Query keys follow a predictable hierarchy for fine-grained or bulk invalidation:

// Group key — invalidate ALL endpoints in a group
client.todos.queryKey()
// → ['@cleverbrush', 'todos']

// Endpoint key — invalidate all variants of one endpoint
client.todos.list.queryKey()
// → ['@cleverbrush', 'todos', 'list']

// Specific key — target one cache entry
client.todos.get.queryKey({ params: { id: 42 } })
// → ['@cleverbrush', 'todos', 'get', { params: { id: 42 } }]

Mutations & Cache Invalidation

import { useQueryClient } from '@tanstack/react-query';

function CreateTodo() {
    const queryClient = useQueryClient();
    const mutation = client.todos.create.useMutation({
        onSuccess: () => {
            // Invalidate all todo queries after creating
            queryClient.invalidateQueries({
                queryKey: client.todos.queryKey()
            });
        },
    });

    return (
        <button onClick={() => mutation.mutate({ body: { title: 'New' } })}>
            Add Todo
        </button>
    );
}

Suspense

import { Suspense } from 'react';

function TodoListSuspense() {
    const { data } = client.todos.list.useSuspenseQuery();
    return <ul>{data.map(t => <li key={t.id}>{t.title}</li>)}</ul>;
}

function App() {
    return (
        <Suspense fallback={<p>Loading…</p>}>
            <TodoListSuspense />
        </Suspense>
    );
}

Prefetching

Pre-populate the cache before a component mounts — great for hover-to-prefetch patterns:

const queryClient = useQueryClient();

<button
    onMouseEnter={() =>
        client.todos.get.prefetch(queryClient, { params: { id: 1 } })
    }
>
    View Todo
</button>

Error Handling

Errors are typed as WebError from @cleverbrush/client. Use the provided type guards:

import { isApiError, isTimeoutError } from '@cleverbrush/client';

function TodoList() {
    const { data, error } = client.todos.list.useQuery();

    if (isApiError(error)) {
        return <p>API error: {error.status} — {error.body?.message}</p>;
    }
    if (isTimeoutError(error)) {
        return <p>Request timed out after {error.timeout}ms</p>;
    }

    return <ul>{data?.map(t => <li key={t.id}>{t.title}</li>)}</ul>;
}

Key Utilities

For manual key construction outside of the proxy (e.g., in SSR loaders or utility functions):

import {
    buildQueryKey,
    buildGroupQueryKey,
    QUERY_KEY_PREFIX
} from '@cleverbrush/client/react';

buildGroupQueryKey('todos');
// → ['@cleverbrush', 'todos']

buildQueryKey('todos', 'list');
// → ['@cleverbrush', 'todos', 'list']

buildQueryKey('todos', 'get', { params: { id: 1 } });
// → ['@cleverbrush', 'todos', 'get', { params: { id: 1 } }]