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¶
- Stable references — Query keys must have stable identity across renders
- Use
as const— Ensures type safety and immutability - 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/hooks — useOptimisticCreate/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.
Related¶
- Error Handling — Centralized error handling
- State Management — Zustand stores
- Forms — Form handling