use-q

Command Palette

Search for a command to run...

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-dom

See 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 schema object is the single source of truth. TypeScript infers all path params, search params, body, and response types from it.
  • createApiClient built a QueryClient, a TagRegistry, 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 in onSettled.

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 RouteDefinition field.
  • useQ — every option, including select, enabled, and refetchInterval.
  • useM — optimistic updates and tag chaining.
  • useQClient — manual cache control.