Data flow
Every screen is the same question: how does server data reach the pixel, and how does user intent get back to the server. If every screen answers that its own way, the app has N architectures.
This page fixes one path, in both directions.
The read path
flowchart LR
BE[("Backend")] -->|raw JSON| AC["apiClient<br/>(infra)"]
AC -->|unknown| SVC["service<br/>+ parseResponse"]
SVC -->|domain type| HOOK["useQuery<br/>(cache)"]
HOOK -->|data| PAGE["page"]
PAGE -->|props| UI["component"]
Five stops, each with exactly one job:
| Stop | Input | Output | Job |
|---|---|---|---|
apiClient |
path + params | unknown |
URL, headers, bearer, 401, request id |
service |
unknown |
domain type | validate and map DTO → domain |
useQuery |
function + key | data/error |
cache, dedupe, revalidation, loading |
| page | data |
props | orchestrate, read the URL |
| component | props | DOM | render, emit events |
The one rule with no exception
A component does not call fetch, axios, or apiClient. Not even "just
this once". A component that fetches is untestable without a server, unreusable
in another context, and invisible to the cache.
unknown in, narrow type out
apiClient returns unknown on purpose:
const raw = await api.get<unknown>("/orders", { params: { page } });
return parseResponse(orderListSchema, raw, "listOrders");
Why not api.get<Order[]> directly? Because get<Order[]> is your promise,
not a check. If the backend renames total_amount to amount, TypeScript stays
happy and the app breaks in production, on a .toFixed() of undefined, three
screens later.
parseResponse turns that into an error at the edge, with context:
[listOrders] invalid response: total_amount — Required
Validate at the edge, trust the inside
That's the whole point. One validation at the boundary buys you the right to
write the rest of the app without if (order?.total_amount != null) on every
line. Without it, defensive checks leak into every component. See
Strong typing.
A DTO and a domain type are not the same thing
The backend speaks snake_case, sends dates as ISO strings, sends money as cents.
None of that needs to leak into the app:
// src/features/orders/orders.schema.ts
import { z } from "zod";
/** Wire format, exactly as the backend sends it. */
const orderDtoSchema = z.object({
id: z.string().uuid(),
code: z.string(),
status: z.enum(["pending", "paid", "shipped", "delivered", "cancelled"]),
total_cents: z.number().int(),
created_at: z.string().datetime(),
});
/**
* Domain shape used everywhere inside the app: camelCase, real Date, money in
* a single unit. The transform is the only place that knows the wire format.
*/
export const orderSchema = orderDtoSchema.transform((dto) => ({
id: dto.id,
code: dto.code,
status: dto.status,
totalCents: dto.total_cents,
createdAt: new Date(dto.created_at),
}));
export const orderListSchema = z.array(orderSchema);
export type Order = z.infer<typeof orderSchema>;
export type OrderStatus = Order["status"];
Concrete wins:
createdAtis already aDate— no component callsnew Date(...).snake_casedies in the schema; the app iscamelCasethroughout.- Changing a backend field means editing one file.
Don't transform what you don't need to
If the DTO is already the shape you want, don't invent a transform just to
have a layer. Mapping without purpose is ceremony,
and ceremony is cost without return.
The write path
flowchart LR
UI["component"] -->|onPay id| PAGE["page"]
PAGE -->|mutate| MUT["useMutation"]
MUT --> SVC["service"]
SVC --> AC["apiClient"] --> BE[("Backend")]
MUT -.->|invalidateQueries| HOOK["read cache"]
The component does not decide what happens — it announces intent
(onPay(id)). The mutation hook knows the effect:
// src/features/orders/use-pay-order.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "tempest-react-sdk";
import { payOrder } from "./orders.service";
import { orderKeys } from "./use-orders";
/**
* Pay an order and refresh every cached order query. Invalidating `all`
* (instead of a single page key) is intentional: paying changes counters and
* list ordering, so any cached page may now be stale.
*/
export function usePayOrder() {
const queryClient = useQueryClient();
const toast = useToast();
return useMutation({
mutationFn: payOrder,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: orderKeys.all });
toast.success("Order paid.");
},
});
}
orderKeys.all comes for free from createQueryKeys — it is the
broadest key of the domain.
A hand-built key is a bug waiting to happen
["orders", "list", page] written in the query and ["order", "list", page]
written in the invalidation is not a compile error — it's a screen that never
updates. Centralizing in createQueryKeys closes that door.
Handle errors once, not on every screen
The HTTP client throws TempestApiError with status, detail, code and
requestId. Three levels of handling, and each error lands in exactly one:
| Level | Where | Handles |
|---|---|---|
| Global | createApiClient({ onUnauthorized }) |
401 → logout/refresh |
| Feature | onError of useMutation |
business rules (code === "STOCK_EMPTY") |
| Screen | <ErrorState> / isError from useQuery |
"it didn't work, try again" |
import { isApiError } from "tempest-react-sdk";
onError: (error: unknown) => {
if (isApiError(error) && error.code === "STOCK_EMPTY") {
toast.error("No stock for that order.");
return;
}
throw error;
},
The trailing throw error is not sloppiness: an error the feature can't handle
rises to the ErrorBoundary instead of becoming a generic
toast that hides the problem.
An empty catch {} is the worst line of code there is
A silent catch trades a visible error for silently wrong behaviour. If you don't know what to do with the error, don't catch it — let the ErrorBoundary and the logger do their job.
Shortcut: CRUD without a service
Predictable REST resource, no transformation and no rules? The Data Provider already is the service:
import { useList } from "tempest-react-sdk";
const { data, isLoading } = useList<Customer>("customers", {
pagination: { page: 1, pageSize: 20 },
sort: { field: "name", order: "asc" },
});
Writing a customers.service.ts that only forwards those arguments is a
pass-through. Write the service when one of
three things exists: validation with a transform, endpoint composition, or a
business rule.
Offline: same mutation, with an outbox
In a PWA the write may happen without network. The design doesn't change — the hook does:
import { useOfflineMutation } from "tempest-react-sdk";
import { orderKeys } from "./use-orders";
import { ordersSync } from "./orders.sync";
import type { Order } from "./orders.schema";
/** Pay an order even offline: the write goes to the outbox and syncs later. */
export function usePayOrderOffline(page: number) {
return useOfflineMutation<string, Order[], { paid: true }>({
sync: ordersSync,
queryKey: orderKeys.list(page),
toEntry: (id) => ({ op: "update", recordId: id, payload: { paid: true } }),
applyOptimistic: (current = [], id) =>
current.map((o) => (o.id === id ? { ...o, status: "paid" } : o)),
});
}
The mutation stays in the feature, the UI still only emits intent. Details in PWA & Offline-First and Offline Sync.
Recap
- Read:
apiClient→service(+parseResponse) →useQuery→ page → UI. - Write: UI emits intent → feature mutation → service → invalidation.
unknownin, domain type out. Validating at the edge pays for the rest of the app.- DTO ≠ domain:
snake_case, ISO and cents die in the schema. - Errors have three levels, each with an owner. Never an empty
catch {}. - Trivial CRUD: use the Data Provider and write no service.
Next: Where each state lives.