🗺️ Mapa Arquitetural do Tópico

flowchart LR
    REACT["⚛️ React<br/>TanStack Query (cache do servidor)"]
    REACT -->|"fetch /api/... (proxy do Vite em dev)"| EXPRESS["🚂 Express + TypeScript<br/>router → Zod → handler de erros"]
    EXPRESS --> PRISMA["🔷 Prisma Client<br/>(um por processo)"]
    PRISMA --> PG[("🐘 PostgreSQL")]

    style REACT fill:#e1f5fe,stroke:#03a9f4,stroke-width:2px
    style EXPRESS fill:#fff3e0,stroke:#ff9800,stroke-width:2px
    style PRISMA fill:#e8f5e9,stroke:#4caf50,stroke-width:1px
    style PG fill:#ede7f6,stroke:#673ab7,stroke-width:1px

🧩 1. O problema: uma linguagem de ponta a ponta, mas sem regras

PERN é PostgreSQL, Express, React e Node.js. A promessa é usar TypeScript dos dois lados: o mesmo tipo Tarefa descreve o que o banco guarda, o que a API devolve e o que a tela mostra (o contrato compartilhado do Cap. 01 e o schema Zod do Cap. 04).

O preço é que as duas pontas são minimalistas e não opinam:

  O que vem pronto O que você precisa decidir
Express Rotas, middlewares, req/res Pastas, validação, tratamento de erros, como falar com o banco
React Componentes e estado local Como buscar dados, cache, carregamento, erro, recarregar depois de uma mudança

Este capítulo mostra as decisões que fazem uma API Express e um cliente React não quebrarem em produção. O NestJS do Cap. 06 é a resposta “com opinião” ao mesmo problema.

🚂 2. Express com TypeScript: router, validação e erros

Uma API Express organizada tem três peças: um router por recurso, a validação na entrada e um único handler de erros no fim.

const NovaTarefa = z.object({
  titulo: z.string().min(3),
  prioridade: z.enum(["BAIXA", "MEDIA", "ALTA"]).default("MEDIA"),
});

export class ErroHttp extends Error {
  constructor(public status: number, message: string) { super(message); }
}

// Express 4 não repassa promessas rejeitadas para o handler de erros.
export const assincrono =
  (fn: (req: Request, res: Response) => Promise<unknown>) =>
  (req: Request, res: Response, next: NextFunction) => { fn(req, res).catch(next); };

export const tarefasRouter = express.Router();

tarefasRouter.post("/", assincrono(async (req, res) => {
  const dados = NovaTarefa.parse(req.body);           // ZodError vai para o handler de erros
  const tarefa = { id: proximoId++, concluida: false, ...dados };
  tarefas.set(tarefa.id, tarefa);
  res.status(201).location(`/api/tarefas/${tarefa.id}`).json(tarefa);
}));

tarefasRouter.patch("/:id/concluir", assincrono(async (req, res) => {
  const tarefa = tarefas.get(Number(req.params.id));
  if (!tarefa) throw new ErroHttp(404, "tarefa não encontrada");
  tarefa.concluida = true;
  res.json(tarefa);
}));

O handler de erros tem quatro parâmetros (é assim que o Express o reconhece) e é registrado depois das rotas:

export function tratarErros(err: unknown, _req: Request, res: Response, _next: NextFunction) {
  if (err instanceof z.ZodError) {
    res.status(400).json({ erro: "dados inválidos", campos: err.flatten().fieldErrors });
  } else if (err instanceof ErroHttp) {
    res.status(err.status).json({ erro: err.message });
  } else {
    console.error(err);
    res.status(500).json({ erro: "erro interno" });   // nunca o stack trace para o cliente
  }
}

const app = express();
app.use(express.json());                 // sem isto, req.body chega undefined
app.use("/api/tarefas", tarefasRouter);
app.use(tratarErros);

Com isso, cada rota só descreve o caminho feliz. Rodando esse código, um POST com {"titulo":"ab"} devolve 400 {"erro":"dados inválidos","campos":{"titulo":["String must contain at least 3 character(s)"]}}, e PATCH /api/tarefas/99/concluir devolve 404 {"erro":"tarefa não encontrada"}, sem nenhum try/catch nas rotas.

Por que o assincrono? No Express 4, um throw dentro de uma rota async não chega ao handler de erros: vira uma promessa rejeitada sem tratamento, e no Node.js 22 isso derruba o processo inteiro, não só aquela requisição. O Express 5 passou a repassar promessas rejeitadas sozinho; no 4, o embrulho (ou try/catch + next(err)) é obrigatório.

🐘 3. PostgreSQL com Prisma dentro da rota

O Prisma gera um cliente tipado a partir do schema.prisma. A rota pede exatamente o que a tela usa:

// UM cliente para o processo inteiro: cada PrismaClient abre o seu pool de conexões.
export const prisma = new PrismaClient();

router.get("/", async (_req, res, next) => {
  try {
    const pendentes = await prisma.tarefa.findMany({
      where: { concluida: false },
      orderBy: { criadaEm: "desc" },
      select: { id: true, titulo: true, prioridade: true },   // só as colunas que a tela usa
    });
    res.json(pendentes);
  } catch (e) {
    next(e);
  }
});

O select não é detalhe: sem ele, a API devolve todas as colunas, inclusive as que não deveriam sair do servidor. Migrações, relações, transações e a comparação com TypeORM e Drizzle ficam para o Cap. 07.

⚛️ 4. React com TanStack Query: estado do servidor não é estado da tela

Buscar dados com useEffect + useState parece simples, mas cada tela reimplementa a mesma coisa: estado de carregamento, de erro, recarregar depois de criar ou excluir, evitar a requisição repetida quando duas telas pedem o mesmo dado. O TanStack Query trata dados do servidor como um cache identificado por uma chave:

export function ListaTarefas() {
  const queryClient = useQueryClient();

  const { data, isPending, error } = useQuery({
    queryKey: ["tarefas"],                 // a chave identifica o cache
    queryFn: () => api<Tarefa[]>("/tarefas"),
    staleTime: 30_000,                     // por 30 s, outra tela reaproveita sem nova requisição
  });

  const concluir = useMutation({
    mutationFn: (id: number) => api<Tarefa>(`/tarefas/${id}/concluir`, { method: "PATCH" }),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tarefas"] }),  // recarrega a lista
  });

  if (isPending) return <p>Carregando…</p>;
  if (error) return <p>Erro: {error.message}</p>;
  return (
    <ul>
      {data.map((t) => (
        <li key={t.id}>
          {t.titulo}
          {!t.concluida && (
            <button onClick={() => concluir.mutate(t.id)} disabled={concluir.isPending}>Concluir</button>
          )}
        </li>
      ))}
    </ul>
  );
}

A função api é um fetch com um detalhe que todo mundo esquece: fetch não rejeita em 4xx ou 5xx. Ela precisa conferir resp.ok e lançar o erro, senão o TanStack Query trata um 404 como sucesso:

async function api<T>(caminho: string, init?: RequestInit): Promise<T> {
  const resp = await fetch(`/api${caminho}`, { headers: { "Content-Type": "application/json" }, ...init });
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
  return resp.json() as Promise<T>;
}

Estado do servidor × estado do cliente. O TanStack Query é para o que mora na API (a lista de tarefas). O que só existe na tela (aba aberta, carrinho ainda não enviado, texto digitado) continua em useState ou num hook próprio.

4.1 Frontend e API em portas diferentes: proxy e CORS

Em desenvolvimento, o Vite roda na porta 5173 e o Express na 3000. Em vez de liberar CORS, o mais simples é o proxy do Vite, que faz o navegador enxergar uma origem só:

// vite.config.ts
export default defineConfig({
  plugins: [react()],
  server: { proxy: { "/api": "http://localhost:3000" } },   // /api/... vai para o Express
});

Em produção, com domínios diferentes, a API libera apenas a origem do frontend:

app.use(cors({
  origin: process.env.WEB_ORIGIN ?? "http://localhost:5173",   // nunca "*" junto com credentials
  credentials: true,                                             // permite o cookie do refresh token
}));

⚖️ 5. Comparação

Backend em Node.js:

  Express Fastify NestJS (Cap. 06)
Estrutura Você decide Você decide, com plugins Módulos, controllers e serviços impostos
Validação Biblioteca à parte (Zod) JSON Schema embutido class-validator com DTOs
Erros em rotas async Embrulho no Express 4; automático no 5 Automático Automático (filtros de exceção)
Desempenho Bom O mais rápido dos três Igual ao adaptador por baixo (Express ou Fastify)
Quando usar APIs pequenas, aprender o básico APIs com muito tráfego Equipes grandes, domínio complexo

Busca de dados no React:

  useEffect + useState TanStack Query SWR
Cache entre telas ❌ cada tela busca de novo ✅ por queryKey ✅ por chave
Carregando, erro, recarregar À mão em cada tela Pronto Pronto
Mutações e invalidação À mão (setTasks(...)) useMutation + invalidateQueries mutate
Quando usar Uma tela, um dado, nada compartilhado Aplicações com várias telas sobre a mesma API Leitura simples, sobretudo com Next.js

⚠️ 6. Armadilhas comuns

Sintoma Causa Correção
O servidor cai inteiro quando o banco falha numa rota throw em rota async no Express 4, sem next(err) Embrulho assincrono (Seção 2) ou Express 5
req.body é undefined Falta app.use(express.json()), ou ele foi registrado depois das rotas Registre o parser antes das rotas
O handler de erros nunca é chamado Registrado antes das rotas, ou com 3 parâmetros 4 parâmetros e app.use(tratarErros) por último
“Too many connections” no PostgreSQL new PrismaClient() dentro da rota (ou recriado a cada hot reload) Um cliente por processo, exportado de um módulo
A lista não atualiza depois de concluir Mutação sem invalidateQueries onSuccess invalidando a mesma queryKey
Filtro mostra dados do filtro anterior queryKey: ["tarefas"] sem o parâmetro queryKey: ["tarefas", { status }]: a chave inclui tudo de que o resultado depende
Erro 404 tratado como sucesso fetch não rejeita em 4xx/5xx Conferir resp.ok e lançar
Chave de API visível no navegador Variável VITE_* com segredo: tudo que começa com VITE_ vai para o JavaScript público Segredos só no backend

🗂️ 7. No Catálogo de Projetos

O Catálogo ainda não tem um projeto PERN completo. Os mais próximos mostram as peças separadas, e cada um tem algo para observar:

  • fullstack_tarefas_07_spring_react: o cliente React (web-react/src/pages/HomePage.jsx) busca as tarefas com useEffect + useState e, depois de excluir ou concluir, atualiza a lista à mão com setTasks(prev => ...). É o “antes” da Seção 4: compare cada trecho com o useQuery/useMutation equivalente. O acesso à API fica em web-react/src/services/TaskService.js.
  • fullstack_tecloja_03_nest_react: o web-react/src/hooks/useCart.js é estado do cliente (o carrinho, com useState, useCallback e useMemo), o caso em que TanStack Query não se aplica. O backend é NestJS, que por padrão roda sobre o Express.
  • infra_nginx_01_nodejs: o Express mais simples possível (api-01/index.js, uma rota GET /), em duas instâncias atrás de um Nginx que balanceia a carga. Bom para ver o Express sem nenhuma camada, e o que falta nele (validação, erros, banco).
  • fullstack_pi_01_parkmanager a fullstack_pi_05_stockflow: frontends React com os dados iniciais fixos no próprio código (no PI 01, web-react/src/data.ts) e guardados no localStorage do navegador, sem API. Escrever a API Express que falta para um deles e trocar o estado local por TanStack Query é o desafio dos exercícios deste capítulo.