Ir para o conteúdo

Fluxo de dados

Toda tela é a mesma pergunta: como o dado do servidor chega no pixel, e como a intenção do usuário volta pro servidor. Se cada tela responde isso do seu jeito, o app tem N arquiteturas.

Esta página fixa um caminho, nos dois sentidos.

O caminho de leitura

flowchart LR
    BE[("Backend")] -->|JSON cru| AC["apiClient<br/>(infra)"]
    AC -->|unknown| SVC["service<br/>+ parseResponse"]
    SVC -->|tipo do domínio| HOOK["useQuery<br/>(cache)"]
    HOOK -->|data| PAGE["página"]
    PAGE -->|props| UI["componente"]

Cinco paradas, e cada uma tem exatamente um trabalho:

Parada Entrada Saída Trabalho
apiClient path + params unknown URL, headers, bearer, 401, request id
service unknown tipo do domínio validar e mapear DTO → domínio
useQuery função + key data/error cache, dedupe, revalidação, loading
página data props orquestrar, ler URL
componente props DOM renderizar, emitir eventos

A única regra que não tem exceção

Componente não chama fetch, axios, nem apiClient. Nem "só nesse caso". Um componente que busca dado é intestável sem servidor, irreusável em outro contexto e invisível pro cache.

unknown na entrada, tipo estreito na saída

O apiClient devolve unknown de propósito:

const raw = await api.get<unknown>("/orders", { params: { page } });
return parseResponse(orderListSchema, raw, "listOrders");

Por que não api.get<Order[]> direto? Porque get<Order[]> é uma promessa sua, não uma verificação. Se o backend renomear total_amount pra amount, o TypeScript continua feliz e o app quebra em produção, num .toFixed() de undefined, três telas depois.

O parseResponse transforma isso num erro na borda, com contexto:

[listOrders] resposta inválida: total_amount — Required

Valide na borda, confie no miolo

Esse é o ponto todo. Uma validação na fronteira compra o direito de escrever o resto do app sem if (order?.total_amount != null) em cada linha. Sem ela, a checagem defensiva vaza pra dentro de cada componente. Veja Tipagem forte.

DTO e tipo do domínio não são a mesma coisa

Backend fala snake_case, manda data como string ISO, manda dinheiro como centavos. Nada disso precisa vazar pra dentro do 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"];

Ganhos concretos:

  • createdAt já é Date — nenhum componente faz new Date(...).
  • snake_case morre no schema; o app é camelCase inteiro.
  • Trocar o backend de campo é editar um arquivo.

Não transforme o que você não precisa

Se o DTO já está no formato que você quer, não invente transform só pra ter uma camada. Mapeamento sem propósito é cerimônia, e cerimônia é custo sem retorno.

O caminho de escrita

flowchart LR
    UI["componente"] -->|onPay id| PAGE["página"]
    PAGE -->|mutate| MUT["useMutation"]
    MUT --> SVC["service"]
    SVC --> AC["apiClient"] --> BE[("Backend")]
    MUT -.->|invalidateQueries| HOOK["cache de leitura"]

O componente não decide o que acontece — ele avisa a intenção (onPay(id)). Quem sabe o efeito é o hook de mutation:

// 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("Pedido pago.");
    },
  });
}

O orderKeys.all vem de graça no createQueryKeys — é a key mais ampla do domínio.

Key montada à mão é bug esperando

["orders", "list", page] escrito na query e ["order", "list", page] escrito na invalidação não dão erro de compilação — dão uma tela que não atualiza. Centralizar em createQueryKeys fecha essa porta.

Tratando erro uma vez, não em toda tela

O cliente HTTP joga TempestApiError com status, detail, code e requestId. Três níveis de tratamento, e cada erro cai em um só:

Nível Onde Trata
Global createApiClient({ onUnauthorized }) 401 → logout/refresh
Da feature onError do useMutation regra de negócio (code === "STOCK_EMPTY")
Da tela <ErrorState> / isError do useQuery "não deu, tente de novo"
import { isApiError } from "tempest-react-sdk";

onError: (error: unknown) => {
  if (isApiError(error) && error.code === "STOCK_EMPTY") {
    toast.error("Sem estoque para esse pedido.");
    return;
  }
  throw error;
},

O throw error no final não é descuido: erro que a feature não sabe tratar sobe pro ErrorBoundary em vez de virar um toast genérico que esconde o problema.

catch {} vazio é a pior linha de código que existe

Um catch silencioso troca um erro visível por um comportamento errado silencioso. Se você não sabe o que fazer com o erro, não capture — deixe o ErrorBoundary e o logger fazerem o trabalho.

Atalho: CRUD sem serviço

Recurso REST previsível, sem transformação nem regra? O Data Provider já é o serviço:

import { useList } from "tempest-react-sdk";

const { data, isLoading } = useList<Customer>("customers", {
  pagination: { page: 1, pageSize: 20 },
  sort: { field: "name", order: "asc" },
});

Escrever um customers.service.ts que só repassa esses argumentos é pass-through. Escreva o serviço quando existir alguma das três coisas: validação com transform, composição de endpoints, ou regra de negócio.

Offline: a mesma mutation, com outbox

Em app PWA a escrita pode acontecer sem rede. Não muda o desenho — muda o hook:

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)),
  });
}

A mutation continua na feature, a UI continua só emitindo intenção. Detalhes em PWA & Offline-First e Offline Sync.

Recap

  • Leitura: apiClientservice (+parseResponse) → useQuery → página → UI.
  • Escrita: UI emite intenção → mutation na feature → serviço → invalidação.
  • unknown entra, tipo do domínio sai. Validar na borda paga o resto do app.
  • DTO ≠ domínio: snake_case, ISO e centavos morrem no schema.
  • Erro tem três níveis, cada um com um dono. catch {} vazio nunca.
  • CRUD trivial: use o Data Provider e não escreva serviço.

Próxima: Onde mora cada estado.