createApiClient
createApiClient(schema, options) — turn a schema into hooks, utilities, and a typed cache.
createApiClient is the entry point for the React layer. Given a Schema, it returns a bundle of hooks and helpers that share one QueryClient, one fetcher, and one TagRegistry.
This is the query abstraction TkDodo argues for in Creating Query Abstractions and The Query Options API: one place owns queryKey + queryFn (and the types). You still use TanStack Query — you just don't re-create that layer in every app.
import { createApiClient } from "@use-q/api-client-react";
import { schema } from "./schema";
export const api = createApiClient(schema, {
baseUrl: "https://api.example.com",
});The schema is passed as a runtime value (not just a type parameter) because the hooks read tags, invalidatesTags, and pagination from it at runtime. Inference is automatic — no explicit generic needed.
Options
createApiClient accepts everything createFetcher does, plus an optional queryClient:
interface CreateApiClientOptions {
baseUrl: string;
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
fetch?: typeof fetch;
parseError?: (input: { response: Response; data: unknown }) => unknown;
onError?: (error: unknown) => void;
queryClient?: QueryClient;
}See Core options for the fetcher-side fields. The React-only queryClient is documented in Bring your own QueryClient.
Returned shape
interface ApiClient<TSchema extends Schema> {
// Hooks
useQ: /* useQ bound to TSchema */;
useM: /* useM bound to TSchema */;
useInfiniteQ: /* useInfiniteQ bound to TSchema */;
useSuspenseQ: /* useSuspenseQ bound to TSchema */;
useQClient: /* useQClient bound to TSchema */;
// Plumbing
fetcher: FetcherInstance;
queryClient: QueryClient;
queryKeys: QueryKeysFactory<TSchema>;
schema: TSchema;
isApiError: typeof isApiError;
// Internal — exposed for advanced cases
_tagRegistry: TagRegistry;
}Note that ApiErrorBoundary is not part of this object — it's a component exported from the package itself:
import { ApiErrorBoundary } from "@use-q/api-client-react";The hook references are stable across renders — they're created once in the closure, so it's safe to destructure them at module scope:
export const { useQ, useM, useInfiniteQ, useQClient } = api;Recommended pattern: src/api/client.ts
Create the client in one place and re-export hooks so consumers never see the bare api.useQ(...) form:
// src/api/client.ts
import { createApiClient } from "@use-q/api-client-react";
import { schema } from "./schema";
export const api = createApiClient(schema, {
baseUrl: import.meta.env.VITE_API_BASE_URL,
headers: () => ({
Authorization: `Bearer ${tokenStore.get() ?? ""}`,
}),
onError: (err) => {
if (api.isApiError(err) && err.status === 401) tokenStore.clear();
},
});
export const {
useQ,
useM,
useInfiniteQ,
useSuspenseQ,
useQClient,
queryClient,
} = api;Components then read hooks with zero ceremony:
import { useQ } from "@/api/client";
function PostList() {
const { data } = useQ("listPosts", { params: { facilityId: "f1" } });
return <ul>{data?.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}Wrapping your app
createApiClient constructs its own QueryClient, which you pass to QueryClientProvider:
import { QueryClientProvider } from "@tanstack/react-query";
import { api } from "@/api/client";
export function Root({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={api.queryClient}>
{children}
</QueryClientProvider>
);
}If you'd rather share a QueryClient with something else (e.g. another createApiClient instance, or a non-use-q query), pass it via options.queryClient:
import { QueryClient } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, refetchOnWindowFocus: false },
},
});
const api = createApiClient(schema, {
baseUrl: "https://api.example.com",
queryClient,
});See BYO QueryClient for the full discussion.
Normalizing error payloads with parseError
parseError receives the failed Response plus its already-parsed body and returns the value stored on ApiError.data. (Return an ApiError instance to replace the error entirely.)
interface ApiProblem {
type: string;
title: string;
detail: string;
}
export const api = createApiClient(schema, {
baseUrl: "https://api.example.com",
parseError: ({ response, data }): ApiProblem => {
const problem = data as Partial<ApiProblem> | null;
return {
type: problem?.type ?? "about:blank",
title: problem?.title ?? response.statusText,
detail: problem?.detail ?? "",
};
},
});Hooks surface failures as ApiError — narrow with isApiError and read the normalized payload from error.data. See Error handling.
Multiple clients
You can call createApiClient more than once per app — useful for unrelated APIs (e.g. internal API + public API, or distinct microservices):
export const internal = createApiClient(internalSchema, {
baseUrl: "https://internal.example.com",
queryClient,
});
export const public_ = createApiClient(publicSchema, {
baseUrl: "https://public.example.com",
queryClient,
});Each createApiClient instance owns its own TagRegistry. Sharing a QueryClient between clients still keeps tag invalidation scoped to the client that owns the tag — a createPost mutation in internal won't invalidate public_ queries (which is what you want).
Next
useQ— queryinguseM— mutatinguseQClient— manual cache control<ApiErrorBoundary>— suspense error handling