chowbea-axios
TanStack Query

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

query-keys.factory.ts
api.client.ts
api.contracts.ts
base.query.ts
business.dto.ts
business.query.ts
business.options.ts
use-business.tsx

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:

lib/query-keys.factory.ts
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.

KindUse
EntitiesXDto (e.g. BusinessDto)
Request payloadsCreate/Update/…Body
Query paramsListXQueryParams
queries/business/interfaces/business.dto.ts
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(),
});
PatternWhen
AliasSame shape, shorter name
UserDto & { … }Client-only fields
Pick / OmitCards and forms
TransformerDerived display values

Base query

Singleton + exec. One place to swap logging, metrics, or the client import path.

queries/base.query.ts
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

queries/business/business.query.ts
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

queries/business/business.options.ts
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

queries/business/use-business.tsx
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

components/BusinessCard.tsx
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

routes/business/$businessId/route.tsx
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.

Next Steps

On this page