chowbea-axios
TanStack Query

TanStack Query Example

A real-world example of using chowbea-axios with TanStack Query.

This section walks through how chowbea-axios is used with TanStack Query for data fetching and state management.

Project Structure

base.query.ts
cache-info.tsx
business.dto.ts
business.query.ts
business.options.ts
use-business.tsx
...other queries

Each domain (business, branch, auth, etc.) follows the same 4-file pattern inside src/queries/.


DTOs

Located in queries/business/interfaces/, DTOs (Data Transfer Objects) define the shape of data moving between your app and the API. Instead of manually typing these, we extract them directly from the OpenAPI schema using the ServerModel helper.

This ensures your types stay in sync with the backend. When the API changes, regenerating the client updates your types automatically.

queries/business/interfaces/business.dto.ts
import { ServerModel } from "@/services/api/api.helpers";

type Business = ServerModel<"BusinessDto">;
type CreateBusiness = ServerModel<"CreateBusinessDto">;
type UpdateBusiness = ServerModel<"UpdateBusinessDto">;

export type { Business, CreateBusiness, UpdateBusiness };

The string passed to ServerModel matches the schema name in your OpenAPI spec. This gives you full autocomplete and type checking throughout your app.


Base Query

Located at queries/base.query.ts, the base query class is the foundation for all domain queries. It provides shared access to the API client and implements a singleton pattern to ensure only one instance of each query class exists.

This centralizes your API access point. If you need to add logging, error tracking, or swap the API client, you do it once here.

queries/base.query.ts
import { api } from "@/services/api/api.client";

class BaseQuery {
  protected readonly api = api;
  protected readonly op = api.op;
  private static instances = new Map<Function, BaseQuery>();

  protected constructor() {}

  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;
  • api - the full chowbea-axios client for path-based calls
  • op - shorthand for operation-based calls (api.op)
  • getInstance() - returns the singleton instance of any subclass

Query Class

Located at queries/business/business.query.ts, the query class is where your actual API calls live. Each method wraps an operation from this.op, handles the Result pattern, and throws on error.

This layer acts as a clean abstraction over the raw API. Your TanStack Query options and hooks don't need to know about the Result pattern or error handling—they just call these methods.

queries/business/business.query.ts
import BaseQuery from "../base.query";
import { CreateBusiness, UpdateBusiness } from "./interfaces/business.dto";

class BusinessQuery extends BaseQuery {
  constructor() {
    super();
  }

  async create(payload: CreateBusiness) {
    const { data, error } = await this.op.createBusiness(payload);
    if (error) throw new Error(error.message);
    return data;
  }

  async list() {
    const { data, error } = await this.op.listBusinesses();
    if (error) throw new Error(error.message);
    return data;
  }

  async get(id: string) {
    const { data, error } = await this.op.getBusiness({ id });
    if (error) throw new Error(error.message);
    return data;
  }

  async update(payload: { id: string; updateData: UpdateBusiness }) {
    const { id, updateData } = payload;
    const { data, error } = await this.op.updateBusiness({ id }, updateData);
    if (error) throw new Error(error.message);
    return data;
  }

  async delete(id: string) {
    const { data, error } = await this.op.deleteBusiness({ id });
    if (error) throw new Error(error.message);
    return data;
  }
}

export default BusinessQuery.getInstance();

The class is exported as a singleton via getInstance(). Every import gets the same instance.


Query Options

Located at queries/business/business.options.ts, this file defines TanStack Query options using queryOptions() for reads and mutationOptions() for writes.

Separating options from hooks makes them reusable. You can use the same options in hooks, route loaders, or directly with queryClient.

queries/business/business.options.ts
import { mutationOptions, queryOptions } from "@tanstack/react-query";
import businessQuery from "./business.query";
import { CreateBusiness } from "./interfaces/business.dto";

export class BusinessOptions {
  static create() {
    return mutationOptions({
      mutationKey: ["business", "create"],
      mutationFn: (payload: CreateBusiness) => businessQuery.create(payload),
    });
  }

  static list() {
    return queryOptions({
      queryKey: ["business", "list"],
      queryFn: () => businessQuery.list(),
    });
  }

  static get(id: string) {
    return queryOptions({
      queryKey: ["business", id],
      queryFn: () => businessQuery.get(id),
    });
  }
}

Each static method returns a complete options object. The query keys are consistent and predictable, making cache invalidation straightforward.


Hooks

Located at queries/business/use-business.tsx, hooks wrap the options for use in React components. They destructure the TanStack Query return values into friendly names.

This is the interface your components use. The naming convention (useBusiness, useBusinesses, useCreateBusiness) makes usage intuitive.

queries/business/use-business.tsx
import { useMutation, useQuery } from "@tanstack/react-query";
import { BusinessOptions } from "./business.options";

const useCreateBusiness = () => {
  const { mutate: createBusiness, isPending: creatingBusiness, ...rest } =
    useMutation(BusinessOptions.create());
  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 };

Each hook returns an object with descriptive property names. Components don't need to know about data or isLoading—they get business and loadingBusiness.


Usage in Components

Components import and call hooks directly. The destructured values are ready to use.

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>
  );
}

The component doesn't know about API calls, query keys, or caching. It just asks for a business and gets one.


Usage in Route Loaders

Route loaders use the options directly with queryClient.ensureQueryData(). This prefetches data before the route renders, which is essential for SSR and improves perceived performance.

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 }) => {
    return queryClient.ensureQueryData(BusinessOptions.get(params.businessId));
  },
});

function RouteComponent() {
  const business = Route.useLoaderData();

  return (
    <div>
      <h1>{business?.name}</h1>
      <Outlet />
    </div>
  );
}

The loader uses BusinessOptions.get() directly—the same options your hooks use. This means the data is already in the cache when the component renders. ensureQueryData() returns cached data if fresh, or fetches if stale.

On this page