8.0 KiB
Data Fetching
This document covers the full data-fetching stack: API client creation, authentication injection, query state management, and URL synchronization.
Layer Overview
useAuthApi()
└─ createApi({ headers: { Authorization: "Bearer <token>" } })
└─ new CustomersClient(...) ← one per domain (CrudClient subclass)
└─ ApiClient ← openapi-fetch wrapper, type-safe from OpenAPI schema
useDataTableQuery({ queryKey, client, queryOptions })
└─ React Query useQuery
└─ client.list({ page, per_page, sort_by, sort_order })
└─ nuqs useQueryStates ← URL ↔ pagination & sort params
useAuthApi — Authenticated API Factory
File: shared/useApi.ts
import { useAuthApi } from "@/shared/useApi"
const api = useAuthApi()
// api.customers, api.vehicles, api.employees, …
Reads the JWT token from the useAuthStore Zustand store and passes it as the Authorization: Bearer <token> header. Called inside any component or hook that needs to make authenticated requests.
Note:
createApi()is called on every render. If performance is a concern, wrap inuseMemo(see Enhancement Plan).
Server-Side Variant
For async server components or server actions:
import { getAuthApi } from "@/shared/api"
const api = await getAuthApi() // reads token from cookies (Next.js server-side)
CrudClient — Generic CRUD Base Class
File: packages/api/src/infra/crud-client.ts
All domain clients extend CrudClient. It provides four standard operations:
| Method | HTTP | Endpoint |
|---|---|---|
list(query?) |
GET |
indexRoute (e.g. /api/customers) |
create(payload) |
POST |
indexRoute |
update(id, payload) |
PUT |
byIdRoute (e.g. /api/customers/{id}) |
destroy(id) |
DELETE |
byIdRoute |
All methods are fully type-safe — parameter types, request body shapes, and response types are all derived from the OpenAPI schema via packages/api/types/index.ts.
Exported Type Utilities
// Extract the list response type from a client class
type CrudListResponse<C> // e.g. { data: Customer[], meta: { last_page, total, ... } }
// Extract a single item type from the list data array
type CrudListItem<C> // e.g. Customer
// Extract query params accepted by list()
type CrudListParams<C>
// Base interface: all list items have `id: number`
type BaseCrudItem = { id: number }
Example: Creating a Domain Client
// packages/api/src/clients/my-resource.ts
import { CrudClient } from "../infra/crud-client"
export const MY_ROUTES = {
INDEX: "/api/my-resources",
BY_ID: "/api/my-resources/{id}",
} as const
export class MyResourceClient extends CrudClient<
typeof MY_ROUTES.INDEX,
typeof MY_ROUTES.BY_ID
> {
constructor(baseUrl?: string, options?: ApiClientOptions) {
super(baseUrl, options, MY_ROUTES.INDEX, MY_ROUTES.BY_ID)
}
// Add domain-specific endpoints here:
async listCategories() {
return this.get("/api/my-resource-categories")
}
}
Then register it in packages/api/src/api.ts:
export function createApi(options?: ApiClientOptions) {
return {
// ...
myResources: new MyResourceClient(undefined, options),
}
}
ApiClient — Low-Level HTTP Client
File: packages/api/src/infra/client.ts
Wraps openapi-fetch. All requests are typed against paths from packages/api/types/index.ts, which is generated from the OpenAPI schema.
Error Handling
Failed requests throw an ApiError:
class ApiError extends Error {
status: number // HTTP status code
statusText: string
endpoint: string
method: string
payload?: {
message?: string
errors?: Record<string, string[]> // Laravel validation errors
}
get validationErrors(): Record<string, string[]> | undefined
}
ApiError in Form Context
In mutation onError handlers, check for validation errors and apply them to individual form fields:
onError: (err) => {
if (err instanceof ApiError && err.validationErrors) {
Object.entries(err.validationErrors).forEach(([field, messages]) => {
form.setError(field as any, { message: messages[0] })
})
}
}
useDataTableQuery — Paginated List + URL State
File: shared/data-view/table-view/use-data-table-query.ts
Wraps React Query + nuqs to keep the table's pagination and sort state synchronized with the URL.
const tableQuery = useDataTableQuery({
queryKey: ["customers"], // React Query cache key prefix
client, // Any object with a .list(query?) method
queryOptions, // Optional React Query overrides (staleTime, etc.)
})
Returns
| Key | Description |
|---|---|
data |
The raw API response (CrudListResponse<C>) |
isLoading |
True during initial fetch |
pagination |
{ page, pageSize, pageCount: 1, total: 0 } — pageCount/total come from data.meta |
sorting |
SortingState derived from URL params |
params |
Raw parsed URL params (page, per_page, sort_by, sort_order) |
setParams |
Direct URL param setter |
handleChange |
Normalized event handler for DataTable (see below) |
invalidateQuery |
Busts the cache (called after mutations) |
URL Query Parameters
| Param | Default | Description |
|---|---|---|
page |
1 |
Current page (1-based) |
per_page |
10 |
Rows per page |
sort_by |
null |
Column accessorKey to sort by |
sort_order |
null |
"asc" or "desc" |
handleChange Event Types
// Triggered by DataViewPagination (page navigation, rows per page)
{ type: "pagination", pagination: { page, pageSize, ... } }
// Triggered by ColumnHeader sort dropdown
{ type: "sorting", sorting: [{ id: "email", desc: false }] }
// → resets page to 1 automatically
DataTable — Table UI
File: shared/data-view/table-view/data-table.tsx
Thin wrapper around TanStack Table v8 with manual server-side pagination and sorting:
<DataTable
columns={columns}
data={data}
pagination={{ page, pageSize, pageCount, total }}
sorting={sorting}
onChange={handleChange}
isLoading={isLoading}
/>
While isLoading is true, the table renders pageSize skeleton rows instead of data.
ColumnHeader — Sortable Column Header
import { ColumnHeader } from "@/shared/data-view/table-view"
{
accessorKey: "email",
header: ({ column }) => <ColumnHeader column={column} title="Email" />,
}
Renders a sort dropdown (Asc / Desc / Clear) if the column canSort. Shows a plain <span> otherwise.
Auth Store
File: shared/stores/auth-store.ts
A Zustand store that holds the authenticated user state:
| Key | Type | Description |
|---|---|---|
token |
string | undefined |
JWT access token |
user |
AuthUser | undefined |
Authenticated user profile |
isAuthenticated |
boolean |
True when token + user are set |
login(token, user, expiresIn?) |
fn | Persists to cookie + sets store |
logout() |
fn | Calls API logout, clears cookie + store |
hydrate() |
fn | Reads cookies on app boot (call in root layout) |
Type System — OpenAPI-Derived Types
The entire API type surface is generated from packages/api/open-api/schema.yaml via scripts in packages/api/scripts/. The generated output is packages/api/types/index.ts.
Key exported type helpers from packages/api/src/infra/types.ts:
| Type | Description |
|---|---|
ApiPath |
Union of all known API paths |
ApiPathByMethod<M> |
All paths that support HTTP method M |
ApiQueryParams<Path, Method> |
Query parameter shape for a given path+method |
ApiRequestBody<Path, Method> |
Request body shape |
ApiResponse<Path, Method> |
Successful response shape |
ApiPathParams<Path, Method> |
URL path parameters (e.g. { id: string }) |
These types flow through CrudClient → useDataTableQuery → ResourcePage → feature page, providing end-to-end type safety without any manual typing.