Quick start
Build a type-safe list + create flow with use-q in five minutes.
This guide walks through a complete, runnable example: define a small schema, create a client, mount the provider, and use useQ / useM from a component.
We'll use a fictional API with three resources — facilities, posts, and comments — and a single facilityId path parameter. The same example is reused across the docs.
Install the packages
pnpm add @use-q/api-client @use-q/api-client-react @tanstack/react-query react react-domSee Installation for npm/yarn equivalents and peer deps.
Define a schema
Create src/api/schema.ts. Each route is typed with satisfies RouteDefinition<TParams, TSearch, TBody, TResponse> — the four type arguments carry path params, search params, request body, and response shape:
import type { RouteDefinition } from "@use-q/api-client";
export interface Post {
id: string;
facilityId: string;
title: string;
body: string;
createdAt: string;
}
export interface CreatePostInput {
title: string;
body: string;
}
export const schema = {
listPosts: {
method: "GET",
path: "/facilities/{facilityId}/posts",
tags: ["posts"],
} satisfies RouteDefinition<{ facilityId: string }, { search?: string }, never, Post[]>,
getPost: {
method: "GET",
path: "/facilities/{facilityId}/posts/{postId}",
tags: ({ params }) => [{ type: "post", id: params.postId }],
} satisfies RouteDefinition<{ facilityId: string; postId: string }, never, never, Post>,
createPost: {
method: "POST",
path: "/facilities/{facilityId}/posts",
invalidatesTags: ["posts"],
} satisfies RouteDefinition<{ facilityId: string }, never, CreatePostInput, Post>,
} as const;Create the client
In src/api/client.ts, instantiate the client once and re-export the hooks you'll use across the app:
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 ?? "https://api.example.com",
headers: () => ({
Authorization: `Bearer ${localStorage.getItem("token") ?? ""}`,
}),
});
export const { useQ, useM, useInfiniteQ, useQClient, queryClient } = api;Wrap your app in QueryClientProvider
createApiClient returns the underlying queryClient — use it directly so every hook shares the same cache:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClientProvider } from "@tanstack/react-query";
import { api } from "./api/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={api.queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);Read with useQ
useQ takes the route id, an input object (params + searchParams), and optionally any TanStack useQuery options:
import { useQ } from "./api/client";
export function PostList({ facilityId }: { facilityId: string }) {
const { data, isLoading, error } = useQ("listPosts", {
params: { facilityId },
searchParams: { search: "" },
});
if (isLoading) return <p>Loading posts…</p>;
if (error) return <p>Failed to load: {error.message}</p>;
return (
<ul>
{data?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}Write with useM
useM takes the route id (plus optional mutation options); path params, body, and search params are all passed as variables to mutate():
import { useM } from "./api/client";
export function NewPostForm({ facilityId }: { facilityId: string }) {
const createPost = useM("createPost");
return (
<form
onSubmit={(e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
createPost.mutate({
params: { facilityId },
body: {
title: String(form.get("title")),
body: String(form.get("body")),
},
});
}}
>
<input name="title" placeholder="Title" required />
<textarea name="body" placeholder="Body" required />
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? "Saving…" : "Publish"}
</button>
</form>
);
}Because createPost.invalidatesTags matches listPosts.tags, the list refetches automatically once the mutation settles — no manual invalidation required. Form fields stay in the form; the mutation only submits (React Query and Forms).
What just happened?
- The
schemaobject is the single source of truth. TypeScript infers all path params, search params, body, and response types from it. createApiClientbuilt aQueryClient, aTagRegistry, and a bundle of hooks bound to your schema.useQ("listPosts", …)produced a query key of the form["api", "GET", "/facilities/abc/posts", { search: "" }]— deterministic and never hand-written.useM("createPost")ran the mutation, then asked the registry for every query whose tags matched and invalidated them inonSettled.
Want to make the create flow feel instant? Add optimistic updates — see the Optimistic Updates guide.
Next steps
- Practical React Query — why these defaults, with links to TkDodo's series.
- Schema definition — a full tour of every
RouteDefinitionfield. useQ— every option, includingselect,enabled, andrefetchInterval.useM— optimistic updates and tag chaining.useQClient— manual cache control.