Skip to content

API Layer Pattern

Organizing API calls with TanStack Query.

Structure

src/features/{feature}/api/
├── keys.ts       # Query key factories
├── queries.ts    # useQuery hooks
└── mutations.ts  # useMutation hooks

API folders can nest for sub-domains (e.g. src/features/admin/api/users/). All hooks call the generated SDK (src/api/generated/sdk.gen.ts, output of @hey-api/openapi-ts, configured in openapi-ts.config.ts) through the wrappers in src/api/http/apiCalls.ts.

Query key factories used by more than one feature (for cross-feature cache invalidation) live in the shared API layer instead: src/api/{domain}/keys.ts (account, budget, notes, groups, users). Feature-local keys (e.g. markers, geocode) stay inside the feature.

Query Keys

export const markerKeys = {
  all: ["markers"] as const,
  lists: () => [...markerKeys.all, "list"] as const,
  list: (filters?: { deleted?: boolean }) => [...markerKeys.lists(), filters] as const,
  details: () => [...markerKeys.all, "detail"] as const,
  detail: (id: string) => [...markerKeys.details(), id] as const,
  map: () => [...markerKeys.all, "map"] as const,
  sidebar: (params: { isDeleted: boolean; searchTerm?: string }) => [...markerKeys.all, "sidebar", params] as const,
};

Query Key Rules

  1. Stable references — Query keys must have stable identity across renders
  2. Use as const — Ensures type safety and immutability
  3. Avoid inline objects — Inline object literals create new references each render
// GOOD - spread primitives as separate key segments
monthlyReport: (month: number, year: number, accountId: string) =>
  [...keys.report(), "monthly", month, year, accountId] as const

// GOOD - memoized object with stable reference
const filters = useMemo(() => ({ month, year }), [month, year]);
useQuery({ queryKey: keys.list(filters), ... });

// BAD - inline object literal (new reference each render)
useQuery({ queryKey: [...keys.all, { month, year }], ... });

Objects in query keys work fine if they have stable identity (memoized, from state, or from key factory). The issue is inline object literals that create new references.

Query Hook

export const useMarkerDetail = (markerId: string, options?: Partial<UseQueryOptions<MarkerDetailResponse>>) => {
  return useQuery({
    queryKey: markerKeys.detail(markerId),
    queryFn: () => executeQuery<MarkerDetailResponse>(getApiV1MarkersByMarkerId({ path: { markerId } })),
    ...options,
  });
};

export const useMarkersForMap = () => {
  return useQuery({
    queryKey: markerKeys.map(),
    queryFn: () => executeQuery<MarkerLocationResponse[]>(getApiV1MarkersLocations()),
  });
};

Infinite lists use useInfiniteQuery with executeQueryInfinite, which parses the X-Pagination-* headers:

export const useMarkersForSidebar = (params: MarkerSidebarParams, enabled = true) => {
  return useInfiniteQuery({
    queryKey: markerKeys.sidebar(params),
    queryFn: ({ pageParam = 1 }) =>
      executeQueryInfinite<MarkerResponse>(
        getApiV1Markers({ query: { IsDeleted: params.isDeleted, PageNumber: pageParam } }),
      ),
    getNextPageParam: (lastPage) =>
      hasNextPage(lastPage.pagination) ? lastPage.pagination.pageNumber + 1 : undefined,
    initialPageParam: 1,
    enabled,
  });
};

Mutation Hook

export const useUpdateMarker = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ markerId, data }: { markerId: string; data: MarkerRequest }) =>
      executeQuery<MarkerDetailResponse>(putApiV1MarkersByMarkerId({ body: data, path: { markerId } })),
    onSuccess: (data, variables) => {
      queryClient.setQueryData(markerKeys.detail(variables.markerId), data);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: markerKeys.all });
    },
    meta: {
      invalidateOnConflict: [markerKeys.all],
      skipErrorToast: true,
    },
  });
};

Mutation Meta

Meta options are consumed by the centralized error handlers in src/api/queryClient.ts:

Meta Applies to Effect
errorMessage queries + mutations Fallback toast message when the error code has no translation
skipErrorToast queries + mutations Suppress the automatic error toast (component handles errors itself)
invalidateOnConflict mutations Query keys to invalidate when the server returns 409 Conflict

API Call Wrappers

src/api/http/apiCalls.ts wraps every generated SDK call. Each wrapper validates the response and throws HttpError (or a plain Error with ERROR_MESSAGE.NETWORK_ERROR when there is no HTTP status).

executeQuery<T>(responsePromise): Promise<T>
// Validates, throws on error/empty body, returns response.data as T

executeMutation(responsePromise): Promise<void>
// Validates only; for endpoints without a response body

executeQueryPaginated<T>(responsePromise, fallback): Promise<PaginatedTableResponse<T>>
// Returns { data, pageNumber, pageSize, totalCount, totalPages } from X-Pagination-* headers

executeQueryInfinite<T>(responsePromise): Promise<PaginatedResponse<T>>
// Returns { data, pagination } for useInfiniteQuery; throws if pagination headers are missing

Validation also dispatches EVENTS.FORBIDDEN on 403 responses for authenticated non-auth endpoints, and parses the Retry-After header into HttpError.retryAfter.

Error Handling

Error handling is centralized in src/api/queryClient.ts via QueryCache/MutationCache onError handlers (rate limits, network errors, 409 conflicts, Sentry capture, toasts). Individual hooks rarely need onError; use meta instead. See Error Handling.

Default query options: staleTime: CACHE.DEFAULT_STALE_MS, gcTime: CACHE.DEFAULT_GC_MS, refetchOnWindowFocus: false, refetchOnReconnect: true, retry: 1.

Optimistic Mutations

For instant UI feedback, use the optimistic mutation hooks from src/hooksuseOptimisticCreate/useOptimisticUpdate/useOptimisticDelete for single-list CRUD, useOptimisticPatch for any other single-query patch:

import { useOptimisticCreate, useOptimisticUpdate, useOptimisticDelete, useOptimisticPatch } from "@/hooks";

const createMutation = useOptimisticCreate<Request, Response, ListItem>({
  mutationFn: (data) => executeQuery(postApiV1Items({ body: data })),
  listQueryKey: itemKeys.list(),
  createTempItem: (data, tempId) => ({ id: tempId, ...data }),
  mapResponseToItem: (response) => response,
  getItemId: (item) => item.id,
});

const updateMutation = useOptimisticUpdate<Request, Response, ListItem>({
  mutationFn: ({ id, data }) => executeQuery(putApiV1ItemsById({ path: { id }, body: data })),
  listQueryKey: itemKeys.list(),
  getItemId: (item) => item.id,
  applyUpdate: (item, data) => ({ ...item, ...data }),
  mapResponseToItem: (response) => response,
});

const deleteMutation = useOptimisticDelete<ListItem>({
  mutationFn: (id) => executeMutation(deleteApiV1ItemsById({ path: { id } })),
  listQueryKey: itemKeys.list(),
  getItemId: (item) => item.id,
});

const reorderMutation = useOptimisticPatch<string[], void, ListItem[]>({
  mutationFn: (ids) => executeMutation(putApiV1ItemsOrder({ body: { ids } })),
  queryKey: () => itemKeys.list(),
  applyPatch: (items, ids) => reorderByIds(items, ids),
});

These hooks: - Apply changes to cache immediately - Automatically rollback on error - Show loading state via isPending - Invalidate queries on settled

Feature mutations with multi-cache updates (e.g. useCreateMarker, which updates both the map and detail caches) implement onMutate/onError/onSuccess manually instead, using updateAllMatchingQueries and restoreQueries from src/api/cacheUtils.ts where they operate on multiple array caches.

See Custom Hooks for full documentation.