TanStack Query Example
Use typed Axios with TanStack Query — api.contracts, api.op, exec, and React Query options from OpenAPI.
This is the long form of The Query Layer: every file, in order, with the jokes kept mostly in the margins. Steal the structure. Rename “business” to whatever your domain actually is.
Not on TanStack? See Framework Examples for Node, plain React, Svelte, Vue, and Angular.
Project structure
One domain folder, four files (plus shared base + keys). Repeat until the app exists.
Query keys factory
String arrays work until invalidation becomes folklore. Prefix everything from one place:
export function createQueryKeys(entity: string) {
const root = [entity] as const;
return {
root,
list: [...root, "list"] as const,
listWithParams: (params?: Record<string, unknown>) =>
[...root, "list", params] as const,
get: (id: string) => [...root, id] as const,
create: [...root, "create"] as const,
update: [...root, "update"] as const,
delete: [...root, "delete"] as const,
};
}DTOs
Import named types from api.contracts.ts. Do not re-derive them with ServerModel or by indexing into list responses when a XDto already exists.
| Kind | Use |
|---|---|
| Entities | XDto (e.g. BusinessDto) |
| Request payloads | Create/Update/…Body |
| Query params | ListXQueryParams |
import type {
BusinessDto,
CreateBusinessBody,
UpdateBusinessBody,
UserDto,
} from "@/services/api/_generated/api.contracts";
import { createQueryKeys } from "@/lib/query-keys.factory";
// Alias — shorter local name, same server shape
export type Business = BusinessDto;
export type { CreateBusinessBody, UpdateBusinessBody };
export const businessQueryKeys = createQueryKeys("business");
// ── Client-side shapes built from contracts ─────────────────────────
export type UserWithSelection = UserDto & {
selected: boolean;
displayLabel: string;
};
export type UserCard = Pick<UserDto, "id" | "email" | "firstName" | "lastName">;
export type EditableUser = Omit<UserDto, "id" | "createdAt" | "updatedAt">;
export type UserProfileUI = UserDto & {
fullName: string;
initials: string;
};
export const toUserProfileUI = (user: UserDto): UserProfileUI => ({
...user,
fullName: `${user.firstName} ${user.lastName}`,
initials: `${user.firstName[0]}${user.lastName[0]}`.toUpperCase(),
});| Pattern | When |
|---|---|
| Alias | Same shape, shorter name |
UserDto & { … } | Client-only fields |
Pick / Omit | Cards and forms |
| Transformer | Derived display values |
Base query
Singleton + exec. One place to swap logging, metrics, or the client import path.
import { api } from "@/services/api/api.client";
import type { ApiError, Result } from "@/services/api/api.error";
export class QueryError extends Error {
readonly code: string;
readonly status: number | null;
constructor(apiError: ApiError) {
super(apiError.message);
this.name = "QueryError";
this.code = apiError.code;
this.status = apiError.status;
}
}
class BaseQuery {
protected readonly api = api;
protected readonly op = api.op;
private static instances = new Map<Function, BaseQuery>();
protected constructor() {}
protected async exec<T>(promise: Promise<Result<T>>): Promise<T> {
const { data, error } = await promise;
if (error) throw new QueryError(error);
return data;
}
static getInstance<T extends BaseQuery>(
this: Function & { prototype: T },
): T {
if (!BaseQuery.instances.has(this)) {
const Ctor = this as unknown as new () => T;
BaseQuery.instances.set(this, new Ctor());
}
return BaseQuery.instances.get(this) as T;
}
}
export default BaseQuery;Query class
import type {
CreateBusinessBody,
UpdateBusinessBody,
} from "@/services/api/_generated/api.contracts";
import BaseQuery from "../base.query";
class BusinessQuery extends BaseQuery {
readonly create = async (payload: CreateBusinessBody) =>
this.exec(this.op.createBusiness(payload));
readonly list = async () => this.exec(this.op.listBusinesses());
readonly get = async (id: string) =>
this.exec(this.op.getBusiness({ id }));
readonly update = async (payload: {
id: string;
updateData: UpdateBusinessBody;
}) =>
this.exec(this.op.updateBusiness({ id: payload.id }, payload.updateData));
readonly delete = async (id: string) =>
this.exec(this.op.deleteBusiness({ id }));
}
export default BusinessQuery.getInstance();Query options
import { mutationOptions, queryOptions } from "@tanstack/react-query";
import type { CreateBusinessBody } from "@/services/api/_generated/api.contracts";
import { businessQueryKeys } from "./interfaces/business.dto";
import businessQuery from "./business.query";
export class BusinessOptions {
static create() {
return mutationOptions({
mutationKey: businessQueryKeys.create,
mutationFn: (payload: CreateBusinessBody) => businessQuery.create(payload),
});
}
static list() {
return queryOptions({
queryKey: businessQueryKeys.list,
queryFn: () => businessQuery.list(),
});
}
static get(id: string) {
return queryOptions({
queryKey: businessQueryKeys.get(id),
queryFn: () => businessQuery.get(id),
enabled: !!id,
});
}
}Same options for hooks and queryClient.ensureQueryData in route loaders. That is the whole point.
Hooks
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { businessQueryKeys } from "./interfaces/business.dto";
import { BusinessOptions } from "./business.options";
const useCreateBusiness = () => {
const queryClient = useQueryClient();
const { mutate: createBusiness, isPending: creatingBusiness, ...rest } =
useMutation({
...BusinessOptions.create(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: businessQueryKeys.root });
},
});
return { createBusiness, creatingBusiness, ...rest };
};
const useBusinesses = () => {
const { data: businesses, isLoading: loadingBusinesses, ...rest } =
useQuery(BusinessOptions.list());
return { businesses, loadingBusinesses, ...rest };
};
const useBusiness = (id: string) => {
const { data: business, isLoading: loadingBusiness, ...rest } =
useQuery(BusinessOptions.get(id));
return { business, loadingBusiness, ...rest };
};
export { useBusiness, useBusinesses, useCreateBusiness };In a component
import { useBusiness } from "@/queries/business/use-business";
function BusinessCard({ businessId }: { businessId: string }) {
const { business, loadingBusiness } = useBusiness(businessId);
if (loadingBusiness) return <div>Loading...</div>;
return (
<div>
<h2>{business?.name}</h2>
<p>{business?.description}</p>
</div>
);
}No Axios. No Result. No tears.
In a route loader
import { queryClient } from "@/components/providers/query.provider";
import { BusinessOptions } from "@/queries/business/business.options";
import { createFileRoute, Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/business/$businessId")({
component: RouteComponent,
loader: async ({ params }) =>
queryClient.ensureQueryData(BusinessOptions.get(params.businessId)),
});
function RouteComponent() {
const business = Route.useLoaderData();
return (
<div>
<h1>{business?.name}</h1>
<Outlet />
</div>
);
}Scripts that make this livable
{
"scripts": {
"api:fetch": "chowbea-axios fetch",
"api:watch": "chowbea-axios watch",
"dev:all": "concurrently \"bun api:watch\" \"vite\""
}
}Change the OpenAPI spec → types regenerate → TypeScript yells in the right files. That is the product.