chowbea-axios
Usage

The Query Layer

Contracts → api.op → exec → TanStack. The path that actually ships.

You’ve generated a typed client. Congratulations. Now don’t call api.op from random buttons like it’s 2019.

This page is the recommended app stack — the same shape used in production Education Hub code. Path-based api.get still works; this is the path you want when the app grows a second screen.

The stack

api.contracts.ts   → named types (UserDto, CreateUserBody, …)
api.op.*           → typed calls returning Result<T>
BaseQuery.exec     → unwrap to data or QueryError
*.options.ts       → queryOptions / mutationOptions
use-*.tsx          → hooks your components import

Each layer has one job. Skip a layer and you’ll rediscover why the layer existed.

1. Types from contracts

import type {
  UserDto,
  CreateUserBody,
  ListUsersQueryParams,
} from "@/services/api/_generated/api.contracts";

export type User = UserDto;

Not ServerModel<"UserDto">. Not ListUsersResponse["data"][number] when UserDto already exists. Import the thing with the name. See Type Helpers for the escape hatches.

2. Call with api.op

const { data, error } = await api.op.getUser({ id });

Operation IDs from your OpenAPI spec become methods. No operationId? No method. Fix the spec.

3. Unwrap once

protected async exec<T>(promise: Promise<Result<T>>): Promise<T> {
  const { data, error } = await promise;
  if (error) throw new QueryError(error);
  return data;
}

Components and hooks should not negotiate with Result. The query class does. Details in Error Handling.

4. Options + hooks

static getUser = (id: string) =>
  queryOptions({
    queryKey: userQueryKeys.get(id),
    queryFn: () => UserQuery.getUser(id),
    enabled: !!id,
  });

Same options object for hooks and route loaders (ensureQueryData). One cache key to rule them all.

Query keys without stringly-typed regret

Ad-hoc ["user", "list"] works until invalidation becomes archaeology. A tiny factory keeps prefixes honest:

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,
  };
}
queries/user/interfaces/user.dto.ts
import type { UserDto } from "@/services/api/_generated/api.contracts";
import { createQueryKeys } from "@/lib/query-keys.factory";

export type User = UserDto;
export const userQueryKeys = createQueryKeys("user");

Invalidate with queryClient.invalidateQueries({ queryKey: userQueryKeys.root }) and move on with your life.

Daily scripts

Wire the CLI into package.json so watch mode is a habit, not a quest:

{
  "scripts": {
    "api:fetch": "chowbea-axios fetch",
    "api:generate": "chowbea-axios generate",
    "api:watch": "chowbea-axios watch",
    "api:status": "chowbea-axios status",
    "dev:all": "concurrently \"bun api:watch\" \"bun dev\""
  }
}

dev:all = backend types stay warm while you break the UI. Ideal.

Entrypoint name

The generator writes api.client.ts. Some apps rename or re-export it as api.http.ts. That’s fine — just pick one import path and stick to it. The docs say api.client.ts because that’s what init creates.

Full walkthrough

Want every file spelled out? → TanStack Query Example.

Next Steps

On this page