Pular para o conteúdo principal

Aula 14: Formulários, envio de dados, paginação e cache

A aula 13 fez o cliente web ler a API: pelo servidor, com esqueleto e tela de falha, e pelo navegador, na busca por autor. Esta aula faz o caminho inverso: o cliente passa a escrever, cadastrando e editando livros por formulário. No caminho, duas pendências da aula 13 são resolvidas — a listagem deixa de trazer o acervo inteiro e passa a ser paginada, e a busca ganha uma terceira solução, com o termo na URL.

Escrever traz perguntas que a leitura não tinha. Os dados saem do navegador e precisam ser conferidos por alguém de confiança; a API pode recusá-los, e a recusa precisa voltar ao formulário sem que o usuário perca o que digitou; e, depois de gravar, o que estava guardado deixa de valer. São quatro decisões — o que vai na URL, onde a escrita é executada, o que o formulário mostra em cada desfecho e o que deixa de valer depois de gravar —, e são elas o conteúdo da aula.

Ao fim da aula, /livros é paginada e filtrada pela URL, /livros/novo cadastra um livro e /livros/[id]/editar altera um livro existente, com erros ao lado de cada campo, botão desabilitado durante o envio e a listagem atualizada logo depois de gravar.

O que vem depoisOnde
Login, sessão, rotas protegidas, autorização nas ações e publicaçãoAula 15 — Sessão e publicação
O mesmo serviço consumido por outro clienteMódulo 4 — Flutter

Objetivos​

Ao final desta aula, você deve ser capaz de:

  • Decidir quando o resultado de uma interação merece endereço próprio e implementar a leitura do estado da URL com searchParams e <Form>.
  • Implementar a paginação a partir do envelope da API e distinguir um endereço inválido de uma página que ainda não existe.
  • Explicar o que é uma Server Action, onde ela é executada e por que precisa conferir os dados mesmo com a validação do navegador ligada.
  • Implementar um formulário com useActionState e useFormStatus, tratando separadamente campo inválido, recusa da API, falha de comunicação e gravação.
  • Explicar por que o React reinicia o formulário depois de cada envio e aplicar defaultValue e key para que o usuário não perca o que digitou.
  • Comparar updateTag, revalidateTag e revalidatePath depois de uma escrita, e prever o que o usuário vê com cada um.
  • Diagnosticar quatro defeitos que não emitem erro: o campo de busca que não acompanha a URL, os seletores que voltam ao início, o redirect engolido por um catch e o livro gravado que não aparece na listagem.

Ambiente sugerido​

Pré-requisitos: Dados da API (aula 13), com o laboratório daquela aula concluído e rodando. Desta aula em diante, a API usada é a da aula 14: a mesma da aula 13, sem autenticação, com duas rotas de leitura a mais (GET /autores e GET /editoras) e um seed de doze livros. O passo 2 mostra o que mudou nela. Autenticação continua fora do escopo: os formulários desta aula escrevem em rotas abertas, e a aula 15 as fecha.

O laboratório é executável e parte do projeto da aula 13. Os trechos nas partes conceituais são somente leitura; trechos parciais do laboratório indicam onde devem ser inseridos.

Pacotes de checkpoint​

Cada checkpoint do roteiro tem um .zip com o frontend exatamente naquele ponto: código-fonte completo, sem node_modules/. Servem para quem perdeu parte da aula, quer comparar o próprio projeto com a referência, ou prefere conferir um trecho adiante sem digitar tudo o que vem antes. A API é sempre a da aula 14 (passo 2), assumida completa e no ar; os pacotes não mexem nela.

Para usar um checkpoint:

  1. Baixe e descompacte o .zip.
  2. Dentro da pasta, npm install.
  3. Copie .env.example para .env.local (os valores padrão já apontam para http://localhost:3000, a porta da API).
  4. Com a API no ar, npm run dev — o cliente sobe em http://localhost:3001.
CheckpointDepois doO que já funciona
aula-14-biblioteca-web-checkpoint-1.zipPasso 6busca e paginação pela URL; ainda sem cadastro, edição ou cache
aula-14-biblioteca-web-checkpoint-2.zipPasso 9/livros/novo cadastra pela Server Action; leituras ainda sem cache
aula-14-biblioteca-web-checkpoint-3.zipPasso 12leituras guardadas com revalidate/tags, invalidadas por updateTag ao gravar
aula-14-biblioteca-web-checkpoint-4.zipPasso 14/livros/[id]/editar — o laboratório completo
Não construiu a API do Módulo 2? Baixe-a pronta

O pacote biblioteca-api-sem-autenticacao.zip já inclui os três acréscimos do passo 2 — GET /autores, GET /editoras e o seed de doze livros — sem a autenticação da aula 10. Descompacte, siga o README.md do pacote e suba a API em http://localhost:3000 antes de iniciar o laboratório desta aula.

FerramentaVersãoObservação
Node.js22 LTS ou superiora mesma das aulas anteriores
Next.js16.xexemplos verificados com 16.3.4
React19.2.xverificado com 19.2.8
TypeScript5.xmínimo 5.1
API do Módulo 2a da aula 14, sem autenticaçãoem http://localhost:3000, com o seed aplicado
PostgreSQL16 ou superioro mesmo das aulas 7 a 13
NavegadorChrome, Edge, Firefox ou Safari recenteso console é usado no passo 14
Material de terceiros sobre cache no Next.js envelhece rápido

Três sinais de que um tutorial não vale para esta disciplina: revalidateTag chamada com um argumento só (no Next.js 16 a forma de um argumento está descontinuada); useFormState importado de react-dom (o nome atual é useActionState, importado de react); e a afirmação de que "toda escrita precisa de revalidatePath" (depende de haver algo guardado, como mostra a Parte 6). Confira sempre se a página se refere à versão 16.


Conteúdo​

O que as aulas anteriores deixaram em aberto​

Pergunta deixadaOnde é respondida
Aula 13, Parte 2: como paginar na interface, em vez de trazer tamanho=100?Parte 2
Aula 13, Parte 4: e a revalidação por prazo, next: { revalidate }?Parte 6
Aula 13, Parte 10: e a terceira solução, com o termo na URL?Parte 1
Aula 13, Parte 8: as escritas passam pelo preflight?Parte 8

Antes das oito partes: quatro peças que se repetem​

Da Parte 3 em diante, quatro nomes aparecem sem parar, e vale situar cada um antes de entrar neles:

PeçaO que éQuem a define
FormDatao que o navegador entrega no envio: pares nome/texto, sem tipoo HTML do formulário
DTO da APIo contrato que decide se os dados servem (CriarLivroDto, aula 6)a API
Estado do formuláriovalores, erros por campo e mensagem, guardados entre um envio e o próximouseActionState
Envio em andamentose o <form> ao redor está sendo enviado agorauseFormStatus

Nenhuma substitui a outra. O FormData chega bruto; a ação o converte e o confere contra as mesmas regras do DTO, antes de gastar uma chamada; a API aplica o contrato de fato; e o que ela responde vira o novo Estado — o que o formulário usa para não perder o que o usuário digitou. O useFormStatus fica fora desse ciclo: não lê nem escreve estado nenhum, só responde a uma pergunta ("este formulário está enviando?") para quem estiver dentro do <form>. A Parte 5 volta a essas quatro peças com uma figura do ciclo completo, depois que FormData e DTO já tiverem aparecido em código.


Parte 1 — O resultado merece endereço próprio?​

A aula 13 resolveu "mostrar os livros de um autor" duas vezes: filtrando em memória a lista que o servidor entregou, e consultando a API pelo navegador a cada tecla. Há uma terceira solução, e ela começa por uma pergunta: o resultado merece endereço próprio? Se sim, o termo vai para a URL — /livros?autor=graciliano — e quem o lê é o servidor.

Filtro do painel (aula 12)Busca pelo navegador (aula 13)Termo na URL (esta aula)
Quem consulta a APIo servidor, uma vez, ao abrir a páginao navegador, a cada teclao servidor, a cada envio
Requisições enquanto o usuário digita claricenenhumasetenenhuma; uma ao enviar
Considera o acervo inteironão: só o que veio na páginasimsim
O resultado pode ser guardado nos favoritos ou enviado a alguémnãonãosim
O botão "voltar" desfaz a buscanãonãosim
CORS e estados escritos à mãonãosimnão

A última coluna troca a reação a cada tecla por endereço próprio. Para uma busca no acervo, a troca vale a pena; para sugestões enquanto se digita, não. A pergunta entra no roteiro da Parte 10 da aula 13, entre a segunda e a terceira: se o resultado merece endereço próprio, o estado vai para a URL e o servidor o lê.

Lendo a URL no servidor​

Uma página recebe, além de params, a prop searchParams: os parâmetros de consulta da URL. Como params, ela é uma Promise no Next.js 16:

app/livros/page.tsx (trecho)
export default async function Page({ searchParams }: PageProps<"/livros">) {
const { autor, pagina } = await searchParams;

// `?autor=a&autor=b` chega como vetor. Só o texto simples é aceito.
const termo = typeof autor === "string" ? autor.trim() : "";

Cada valor chega como string, string[] ou undefined: a URL é escrita pelo usuário, e nada garante o formato. É a mesma lição do id da aula 11 — o que vem da URL é entrada, e precisa ser conferido.

Ler searchParams tem um efeito que a aula 13 antecipou na tabela da Parte 4: a página passa a depender da requisição, e o Next.js a monta a cada uma. Por isso o await connection() da listagem sai nesta aula: a dependência já está declarada por outra via.

O formulário que navega​

Para levar o termo à URL basta um formulário com method="get" — é o que a web faz desde sempre. O Next.js oferece o componente Form, de next/form, que faz o mesmo pelo roteador do framework, sem recarregar a página:

app/components/BuscaNoAcervo.tsx (trecho)
<Form action="/livros" className="flex items-end gap-2">
<label className="block flex-1 text-sm">
Autor
<input
key={termo}
name="autor"
type="search"
defaultValue={termo}
placeholder="Parte do nome do autor"
className="..."
/>
</label>
<button type="submit" className="...">
Buscar
</button>
</Form>

Ao enviar, os campos viram parâmetros: name="autor" com graciliano produz /livros?autor=graciliano. O HTML servido é um <form action="/livros"> comum. O componente não tem "use client": não guarda estado nem registra evento.

O campo é não controlado: tem defaultValue, e não value com onChange. Quem guarda o texto é o navegador, e o valor só importa no envio. O key={termo} existe por causa disso, e o passo 6 mostra o que acontece sem ele.

A navegação não mostra o esqueleto

Ao trocar de página ou de busca, o loading.tsx da aula 13 não aparece. A navegação dentro da mesma rota é uma transição do React: a tela antiga fica visível até a nova chegar, e a URL só muda no fim. Com o atraso de laboratório ligado, o clique em "Próxima" parece não ter efeito por um segundo e meio. O esqueleto aparece ao entrar no segmento vindo de outra rota, como na aula 13.

No seu projeto

A pergunta "isto merece endereço?" é do padrão; a resposta é do domínio. Liste os filtros e as ordenações das suas telas de listagem e marque os que alguém gostaria de guardar ou enviar a outra pessoa. Esses vão para a URL.


Parte 2 — Paginação pela URL​

O envelope de GET /livros, definido na aula 6, sempre teve o que a paginação precisa: dados, pagina, tamanho, total e totalDePaginas. A aula 13 só usava dados, pedindo tamanho=100 — o teto do DTO. Com doze livros no seed da aula 14 e páginas de cinco, o acervo passa a ter três páginas.

A função de acesso passa a receber o termo e a página, e a devolver o envelope inteiro:

lib/livros.ts (trecho)
export async function listarLivros({
autor,
pagina,
}: {
autor: string;
pagina: number;
}): Promise<Pagina<Livro>> {
await esperar(ATRASO_MS);

// URLSearchParams codifica o termo: "são" vira "s%C3%A3o", e um "&"
// digitado não quebra a consulta.
const consulta = new URLSearchParams({
pagina: String(pagina),
tamanho: String(TAMANHO_DA_PAGINA),
});
if (autor !== "") {
consulta.set("autor", autor);
}

A página da URL passa por uma conversão própria, com a mesma regra do id da aula 11:

app/livros/page.tsx (trecho)
function lerPagina(valor: string | string[] | undefined): number | null {
if (valor === undefined) {
return 1;
}
if (typeof valor !== "string" || !/^[1-9]\d*$/.test(valor)) {
return null;
}
const numero = Number(valor);
return Number.isSafeInteger(numero) ? numero : null;
}

Endereço inválido ou página que ainda não existe​

Dois endereços parecem o mesmo erro e não são:

EndereçoO que éO que a tela mostra
/livros?pagina=abcum endereço que não pode existir"Página não encontrada", via notFound()
/livros?pagina=99uma página que ainda não existe: passa a existir quando o acervo crescer"A página 99 não existe nesta consulta.", com um link para a última

A API trata o segundo caso como resposta normal: devolve dados vazio, com o total correto. É a página do cliente que decide o que mostrar, comparando o número pedido com totalDePaginas.

"Anterior" e "Próxima" são links comuns, montados por uma função que preserva o termo. Mudar de página não pode desfazer a busca; e buscar sempre volta à página 1, porque o formulário de busca não envia pagina:

app/components/Paginacao.tsx (trecho)
export function enderecoDaPagina(pagina: number, termo: string): string {
const consulta = new URLSearchParams();
if (termo !== "") {
consulta.set("autor", termo);
}
if (pagina > 1) {
consulta.set("pagina", String(pagina));
}
const texto = consulta.toString();
return texto === "" ? "/livros" : `/livros?${texto}`;
}

O que a paginação muda no painel da aula 12​

O painel passa a receber cinco livros, e duas decisões da aula 12 deixam de valer:

  • O filtro em memória sai. Ele filtraria só a página atual. A busca passou para a URL e é feita pela API, sobre o acervo inteiro.
  • A sacola passa a guardar os livros, e não os ids. Na aula 12, os títulos da sacola eram derivados da lista recebida por props. Com a paginação, um título reservado na página 1 não está na lista da página 2, e sumiria da sacola.

O estado da sacola sobrevive à troca de página e à busca: o painel continua na mesma posição da árvore, e o React o mantém montado. Ele se perde quando o usuário sai de /livros — para o detalhe, por exemplo. Guardar a sacola entre rotas é outro problema, e a resposta dele é a sessão da aula 15.


Parte 3 — Server Action: o formulário que escreve​

Até aqui, todo formulário levava dados para a URL. Para gravar, os dados precisam chegar à API num POST. A pergunta da aula 13 volta: de que lado a chamada acontece? O caminho padrão do Next.js é o servidor, por meio de uma Server Action.

Uma Server Action é uma função assíncrona marcada com a diretiva "use server". O código dela roda só no servidor. O navegador recebe apenas uma referência: um identificador que, quando o formulário é enviado, vira um POST para o servidor Next.js. A Figura 1 mostra o ciclo inteiro.

Diagrama de sequência com três linhas de vida verticais: Navegador, em localhost:3001; Servidor Next.js, também em localhost:3001; e API, em localhost:3000. O navegador envia POST /livros/novo ao servidor Next.js, com id da ação e FormData. No servidor, a caixa cadastrarLivro lê e confere os campos. Três desfechos aparecem em seguida, separados por linhas tracejadas e rotulados na margem esquerda. Campo inválido, em vermelho: o servidor devolve ao navegador o novo estado com erros por campo, e uma anotação diz que a API nem é chamada. API recusa, em vermelho: o servidor envia POST /livros à API com Content-Type application/json; a API responde 400 ou 409 com detalhes; o servidor devolve ao navegador o novo estado com a mensagem da API. API grava, em verde: a API responde 201 com o livro; no servidor, a caixa verde mostra updateTag de livros e redirect para /livros/42; o servidor pede GET /livros/42 à API, recebe 200 com o dado novo e devolve ao navegador a tela do detalhe já renderizada. Uma nota embaixo diz que o navegador só fala com o servidor Next.js, que a API é chamada pela ação, sem CORS, e que gravar, invalidar e montar a tela seguinte cabem na resposta de um único POST.
O navegador nunca fala com a API durante uma escrita. A ação confere, chama a API pelo servidor e, quando grava, devolve a tela seguinte na mesma resposta.

A ação fica num arquivo próprio, com a diretiva no topo. Assim, toda função exportada por ele é uma ação:

app/livros/acoes.ts (trecho)
"use server";

export async function cadastrarLivro(
_estadoAnterior: EstadoFormulario,
formData: FormData,
): Promise<EstadoFormulario> {
const { valores, dados, erros } = lerFormulario(formData);
if (!dados) {
return { valores, erros, mensagem: null };
}
// ...
}

Três consequências decorrem da Figura 1:

  • A chamada à API não passa por CORS. Quem fala com a API é o servidor Next.js, como na leitura da Parte 2 da aula 13. O navegador envia o formulário para a mesma origem da página.
  • O formulário funciona como formulário HTML. O que o React entrega ao navegador é um <form method="POST"> com campos ocultos que identificam a ação. O servidor lê os campos visíveis como FormData.
  • A ação é uma porta pública. O identificador está no HTML da página, e qualquer um que monte o mesmo POST pode chamar a ação, com o formulário ou sem ele. A documentação do Next.js é explícita: cada ação deve ser tratada como ponto de entrada não confiável. Por isso a primeira linha da ação confere os dados, ainda que o navegador já confira os mesmos campos. O passo 14 desliga a conferência do navegador e mostra a da ação respondendo sozinha.

O que chega no FormData​

O FormData entrega texto. Um <input type="number"> chega como "1891", e um <select> sem escolha chega como "". A conversão para o formato da API é responsabilidade da ação — no projeto, da função lerFormulario, em lib/formulario-livro.ts:

lib/formulario-livro.ts (trecho)
// Hífens e espaços são aceitos na digitação e retirados antes do envio.
const isbn = valores.isbn.replace(/[-\s]/g, "");
if (!/^(\d{9}[\dX]|\d{13})$/.test(isbn)) {
erros.isbn = "O ISBN tem 10 ou 13 dígitos.";
}

const ano = Number(valores.ano);
if (!Number.isInteger(ano) || ano < 1450 || ano > 2100) {
erros.ano = "Informe um ano entre 1450 e 2100.";
}

As regras repetem as do CriarLivroDto só no que dá para dizer ao usuário campo a campo antes de gastar uma chamada. O dígito verificador do ISBN, por exemplo, fica com a API. Quem decide continua sendo a API; a conferência da ação é uma cortesia com quem preenche o formulário.

Um formulário sem action também funciona — à moda antiga

Se o <form> não tiver action, o navegador faz o que sempre fez: envia um GET para o próprio endereço, com os campos na URL. Com o formulário de cadastro, o resultado é /livros/novo?titulo=…&isbn=…&ano=…&autorId=…&editoraId=. É a Parte 1 de novo, e mostra que é o action que transforma o formulário numa escrita.


Parte 4 — Os desfechos de uma escrita​

A aula 13 separou três desfechos de uma leitura: dados, ausência e falha. Uma escrita tem quatro, e cada um pede uma resposta diferente do formulário:

DesfechoComo chega à açãoO que o formulário mostra
Campo inválidolerFormulario devolve erros, e a API não é chamadao erro ao lado de cada campo
Recusa da API400, 404 ou 409, com o envelope de erroo erro ao lado do campo, ou a mensagem no topo
Falhao fetch rejeita, ou a API responde 5xxuma mensagem geral; nada foi gravado
Gravação2xx com o livrooutra tela: o detalhe do livro

A função de escrita em lib/livros.ts separa os dois primeiros casos da API dos dois últimos, com um tipo união — o mesmo recurso da busca da aula 13:

lib/livros.ts (trecho)
export type ResultadoEscrita =
| { situacao: "gravado"; livro: Livro }
| { situacao: "recusado"; status: number; detalhes: string[] };

A recusa é resposta do contrato: é devolvida como valor. A falha não é: vira exceção, como na leitura, e a ação a captura.

Recusa: de qual campo é o erro?​

O envelope de erro da aula 6 traz detalhes, uma lista de textos. Nos erros de validação, o class-validator começa cada texto pelo nome do campo — isbn must be an ISBN. É esse prefixo que permite pôr o erro ao lado do campo certo:

lib/formulario-livro.ts (trecho)
for (const detalhe of detalhes) {
const campo = (Object.keys(MENSAGEM_POR_CAMPO) as (keyof CamposLivro)[])
.find((nome) => detalhe.startsWith(`${nome} `));
if (campo) {
erros[campo] = MENSAGEM_POR_CAMPO[campo];
} else {
restantes.push(detalhe);
}
}

O texto mostrado é do cliente — "ISBN inválido: confira os dígitos." —, porque a mensagem da API está em inglês e foi escrita para quem programa. Já o 409 vem com mensagem em português, escrita pelo service para o domínio ("ISBN … já cadastrado"), mas não diz a qual campo se refere; ela vai para o topo do formulário.

No seu projeto

Mapear o erro pelo começo de um texto funciona, mas é frágil: depende de uma convenção da biblioteca de validação, e não do contrato. Se o seu cliente precisa de erros por campo, a decisão melhor é do contrato da sua API: devolver, no envelope de erro, o nome do campo ao lado de cada mensagem.

Gravação: o redirect fica fora do try​

Quando a API grava, a ação redireciona para o detalhe do livro:

app/livros/acoes.ts (trecho)
let resultado: ResultadoEscrita;
try {
resultado = await criarLivro(dados);
} catch {
return { valores, erros: {}, mensagem: FALHA_DE_COMUNICACAO };
}

if (resultado.situacao === "recusado") {
return {
valores,
...traduzirRecusa(resultado.status, resultado.detalhes),
};
}

updateTag(ETIQUETA_ACERVO);

redirect(`/livros/${resultado.livro.id}`);

redirect não devolve nada: ele lança uma exceção especial, que o Next.js reconhece e transforma em navegação. Dentro de um try, o catch a captura antes do Next.js. O passo 11 mede a consequência, e ela é das piores: o livro é gravado, a tela diz "Nada foi gravado", e o usuário, ao tentar de novo, recebe "ISBN já cadastrado". Nenhum aviso aparece no TypeScript, no ESLint ou no console. A regra é estrutural: o try envolve só a chamada que pode falhar; o redirect vem depois.


Parte 5 — O estado do formulário: useActionState e useFormStatus​

Uma ação chamada direto pelo action do formulário não tem como devolver nada à tela. Para mostrar os erros, o formulário passa a ser um componente de cliente e usa o hook useActionState:

app/components/FormularioLivro.tsx (trecho)
const [estado, acaoDoFormulario] = useActionState(acao, estadoInicial);
const { valores, erros, mensagem } = estado;

return (
<form action={acaoDoFormulario} className="mt-4 max-w-lg space-y-4">

O hook recebe a ação e o estado de partida, e devolve o estado atual e uma nova ação, que é a que vai no <form>. A cada envio, a ação recebe o estado anterior e o FormData, e o que ela devolve passa a ser o estado — daí a assinatura (estadoAnterior, formData) da Parte 3.

O React reinicia o formulário depois de cada envio​

Com campos não controlados, há um detalhe que pega quase todo mundo: depois que a ação termina, o React reinicia o formulário, e cada campo volta ao seu defaultValue. Se o envio foi recusado, o usuário perderia tudo o que digitou. Por isso o estado devolvido pela ação carrega os valores junto com os erros, e cada campo usa esses valores como defaultValue:

app/components/FormularioLivro.tsx (trecho)
<input
name="titulo"
required
maxLength={200}
defaultValue={valores.titulo}
aria-invalid={erros.titulo ? true : undefined}
className={classeDoCampo}
/>

Com os <select>, isso não basta. O passo 10 mede: depois de um envio recusado, os campos de texto mostram os valores devolvidos, mas os dois seletores voltam à primeira opção, embora o defaultValue esteja certo. A correção é a mesma do campo de busca da Parte 1 — uma key que muda com o valor, e faz o React montar o elemento de novo:

app/components/FormularioLivro.tsx (trecho)
<select
key={`autor-${valores.autorId}`}
name="autorId"
required
defaultValue={valores.autorId}

A aula 12 usou key para dar identidade aos itens de uma lista; aqui ela é usada pelo efeito colateral da mesma regra: mudou a chave, o React descarta o elemento e cria outro, que nasce com o defaultValue atual.

Enquanto envia: useFormStatus​

O segundo hook informa se o formulário está sendo enviado. Ele tem uma restrição que define onde é usado: lê o estado do <form> que envolve o componente. No mesmo componente que renderiza o <form>, não enxerga nada. Por isso o botão é um componente à parte:

app/components/BotaoEnviar.tsx
"use client";

import { useFormStatus } from "react-dom";

// `useFormStatus` lê o estado do <form> que ENVOLVE este componente — por
// isso o botão é um componente à parte, e não um trecho do formulário. Dentro
// do próprio componente que renderiza o <form>, o hook não enxerga nada.
export default function BotaoEnviar({ rotulo }: { rotulo: string }) {
const { pending } = useFormStatus();

return (
<button
type="submit"
disabled={pending}
className="rounded-md border border-black/15 px-4 py-2 text-sm font-medium hover:bg-black/5 disabled:cursor-wait disabled:opacity-50 dark:border-white/20 dark:hover:bg-white/10"
>
{pending ? "Enviando…" : rotulo}
</button>
);
}

Desabilitar o botão durante o envio não é só cortesia: evita o segundo clique, que enviaria o mesmo livro duas vezes. O useActionState também devolve um terceiro valor, pending, com a mesma informação, útil quando quem precisa saber é o próprio formulário.

Como as quatro peças se encaixam​

Com FormData, DTO, Estado e useFormStatus já em código, a Figura 2 fecha o ciclo que a Parte 3 abriu com a Figura 1 — agora do ponto de vista do que cada peça guarda, e por quanto tempo.

Diagrama com seis caixas. Na fileira central, da esquerda para a direita: Formulário, HTML não controlado; Server Action, lerFormulario; API, quem decide de fato. Uma seta azul liga Formulário a Server Action, rotulada FormData; outra liga Server Action a API, rotulada POST; uma seta tracejada cinza volta de API a Server Action, rotulada 201 ou erro. Acima de Server Action, a caixa DTO da API, CriarLivroDto, ligada a ela por uma linha rotulada confere contra, e por uma linha tracejada até API, rotulada mesma forma. Acima de Formulário, a caixa useFormStatus, BotaoEnviar, ligada a Formulário por uma linha tracejada rotulada pending. Abaixo de Server Action, a caixa Estado, valores, erros, mensagem, recebendo uma seta azul de Server Action rotulada guarda o resultado, e devolvendo uma seta tracejada até Formulário, rotulada defaultValue, key.
O `FormData` e o DTO nunca se tocam direto: a ação está entre os dois. O Estado é o que sobrevive ao reinício do formulário a cada envio; o `useFormStatus` não guarda nada, só lê o <form> ao redor.

Guarde esse mapa para os passos 7 a 11 do laboratório: todo defeito que não emite erro — campo que esquece o valor digitado, seletor que volta ao início, botão que não desabilita — é alguma dessas setas quebrada.


Parte 6 — Depois de gravar: o que deixa de valer​

A aula 13 deixou para esta aula a última linha da tabela da Parte 4: a leitura com prazo, next: { revalidate }. Com ela, o Next.js guarda o resultado de um fetch e o reaproveita por um tempo, sem perguntar de novo à API. Para um catálogo que muda pouco, é uma economia real: a listagem deixa de custar uma consulta à API a cada visita.

lib/livros.ts (trecho)
const resposta = await fetch(`${API_URL}/livros?${consulta}`, {
next: { revalidate: VALIDADE_SEGUNDOS, tags: [ETIQUETA_ACERVO] },
});

A opção tem duas partes. revalidate: 60 é o prazo, em segundos. tags é uma etiqueta: um nome que permite descartar de uma vez todas as leituras marcadas com ele. As duas leituras do acervo, a listagem e o detalhe, usam a mesma etiqueta, "livros".

A página continua dinâmica, porque lê searchParams. O que é guardado não é a página: é a resposta da API. A página é montada a cada requisição, com o dado guardado.

O prazo não é o que parece​

O laboratório mede um comportamento que o nome revalidate não sugere: depois de vencido o prazo, a primeira visita ainda recebe o dado antigo. Ela dispara a consulta à API em segundo plano, e só a visita seguinte vê o dado novo. É a estratégia conhecida como stale-while-revalidate: ninguém espera pela API, e o preço é que alguém vê o dado velho uma vez a mais.

A escrita invalida a etiqueta​

Com as leituras guardadas, o livro recém-cadastrado não aparece na listagem até o prazo vencer — e, pela regra acima, até uma segunda visita depois disso. A ação precisa avisar que o que estava guardado deixou de valer. O Next.js 16 oferece três funções, e o laboratório mede as três no mesmo cenário: /livros visitada antes, um livro cadastrado, e /livros visitada de novo.

Chamada na açãoPrimeira visita depois de gravarSegunda visita
nenhumasem o livro novosem o livro novo, até o prazo vencer
revalidateTag("livros", "max")sem o livro novocom o livro novo
revalidatePath("/livros")com o livro novocom o livro novo
updateTag("livros")com o livro novocom o livro novo

As três últimas linhas parecem empatar, e não empatam:

  • revalidateTag(etiqueta, "max") marca o dado como velho e o atualiza em segundo plano — a mesma estratégia do prazo. Serve a quem gravou por outro caminho, num webhook, por exemplo, e aceita um atraso. Para quem acabou de gravar e vai ver o resultado, a primeira tela engana.
  • revalidatePath("/livros") invalida um endereço. O detalhe do livro, /livros/42, é outro endereço, e continuaria guardado depois de uma edição.
  • updateTag(etiqueta) descarta tudo o que tem a etiqueta, em qualquer página, e faz a próxima leitura esperar pela API. Só pode ser chamada de dentro de uma Server Action, e foi desenhada para o caso desta aula: o usuário grava e quer ver o que gravou.

É por isso que a ação chama updateTag antes do redirect. A tela seguinte, o detalhe do livro, é montada já com o dado novo, e chega na resposta do mesmo POST — o fim da Figura 1.

updateTag vale para as escritas deste cliente

Um livro cadastrado pelo Swagger, ou por outro cliente, não passa pela ação, e ninguém chama updateTag. A listagem só o mostra depois do prazo — e, pela regra do stale-while-revalidate, depois de uma visita a mais. O prazo de 60 segundos é a demora máxima aceitável para mudanças feitas fora deste cliente, e escolhê-lo é uma decisão do domínio, não do framework.

Os id mudam a cada seed, e o cache não sabe disso

Rodar npx prisma db seed com o cliente no ar deixa as leituras guardadas apontando para registros que não existem mais. Por até um minuto, a listagem mostra livros cujos detalhes respondem "não encontrado". As leituras guardadas ficam gravadas em disco, dentro de .next: depois de cada seed, pare o npm run dev, apague a pasta e suba de novo.


Parte 7 — Edição: o mesmo formulário, outra ação​

A edição usa o mesmo FormularioLivro, com três diferenças, todas passadas por props: a ação, o estado inicial (o livro como está na API) e o rótulo do botão. A ação de edição precisa de um dado que não está no formulário: o id do livro. Ele é amarrado com bind:

app/livros/[id]/editar/page.tsx (trecho)
// `bind` produz uma nova ação com o primeiro argumento já preenchido. Para
// o formulário, ela tem a mesma forma de `cadastrarLivro`.
const acao = editarLivro.bind(null, livro.id);
app/livros/acoes.ts (trecho)
export async function editarLivro(
id: number,
_estadoAnterior: EstadoFormulario,
formData: FormData,
): Promise<EstadoFormulario> {

O bind mantém o formulário igual nas duas telas, mas não é proteção. Como toda ação, editarLivro pode ser chamada por qualquer um, com qualquer id. Decidir quem pode alterar qual livro é autorização, e é a aula 15 que a acrescenta — dentro da ação, e não no formulário.

O PATCH da API aceita qualquer subconjunto de campos (PartialType, aula 6). O formulário envia sempre todos, porque mostra todos. Um formulário que alterasse só um campo poderia enviar só ele.


Parte 8 — Escrever pelo servidor ou pelo navegador​

A Parte 8 da aula 13 classificou as escritas — POST com JSON, PATCH e DELETE — como requisições com preflight, e as anunciou para esta aula e a próxima. Com Server Actions, elas não passam pelo navegador: quem as envia é o servidor Next.js, e o preflight não acontece. O que o navegador envia é um POST para a própria origem, que não é requisição entre origens.

A escrita pelo navegador continua possível: um componente de cliente com fetch(…, { method: "POST" }) direto para a API, como a busca da aula 13. A tabela compara os dois caminhos para a escrita, com os critérios da Parte 10 da aula 13:

CritérioServer Actionfetch pelo navegador
CORSnão se aplicaa API precisa liberar a origem e responder ao preflight
Onde a API precisa estar acessívelsó para o servidor Next.jsna internet, ao alcance de cada navegador
Credenciais de serviçoficam no servidornão podem existir: tudo vai no JavaScript público
Conferência dos dadosna ação, no servidorna API, e só nela
Tela seguinte depois de gravarna mesma resposta (Figura 1)uma navegação a mais, pedida pelo componente
Invalidação do que o servidor guardouupdateTag na própria açãonão alcança: o navegador não tem acesso ao cache do servidor

A última linha é a que decide nesta aula: as leituras guardadas da Parte 6 estão no servidor Next.js, e só código do servidor as invalida. A escrita pelo navegador volta a fazer sentido no app Flutter do Módulo 4, que não tem servidor intermediário.


Erros comuns​

ErroSintomaCorreção
Ler searchParams sem awaitTypeScript recusa a desestruturação de uma Promiseawait searchParams
Converter pagina com Number() sem conferir?pagina=abc vira NaN, a API responde 400 e a página mostra a tela de falhaconferir o formato e chamar notFound()
Campo de busca com defaultValue e sem key"Limpar a busca" volta a lista inteira, e o campo continua com o termo antigokey={termo}
Sacola guardando só ids com a lista paginadao título reservado some ao mudar de páginaguardar os objetos
Confiar no required do navegadora ação recebe campos vazios de quem chamar o POST diretoconferir na ação; a API confere de novo
Ler formData.get("ano") como número"1891" é texto, e "1891" + 1 é "18911"converter e validar
Campo sem defaultValue vindo do estadodepois de um envio recusado, o campo aparece vaziodefaultValue={valores.campo}
<select> sem keydepois de um envio recusado, volta à primeira opçãokey com o valor atual
useFormStatus no mesmo componente do <form>pending é sempre falsobotão em componente filho
redirect dentro do tryo livro é gravado e a tela diz que não foi; reenviar dá 409redirect depois do try/catch
Leitura guardada sem invalidação depois de gravaro livro cadastrado não aparece na listagemupdateTag com a etiqueta das leituras
revalidateTag(etiqueta, "max") depois de gravaro livro aparece só na segunda visitaupdateTag em Server Action
revalidatePath("/livros") depois de uma ediçãoa listagem atualiza e o detalhe nãoetiqueta comum às duas leituras e updateTag
Esquecer o Content-Type no POST à API400 com todos os campos recusados como ausentes"Content-Type": "application/json"

Laboratório 14 — Escrever no acervo​

O laboratório segue o mesmo domínio-guia dos módulos anteriores: o acervo de uma biblioteca. Os blocos No seu projeto indicam como traduzir cada passo para o domínio do seu estudo de caso.

Estado inicial esperado: o projeto ao fim do laboratório 13, rodando; e a API da aula 14 no ar, com o banco populado pelo seed (passo 2).

Resultado ao fim deste laboratório: /livros é paginada e filtrada pela URL; /livros/novo cadastra e /livros/[id]/editar altera um livro, com erros por campo, estado pendente e a listagem atualizada logo depois de gravar.

Parte do roteiroPassos
Pôr os dois projetos no ar1 e 2
O estado na URL3 a 6
O formulário que escreve7 a 11
Guardar e invalidar12
Editar e fechar o ciclo13 e 14

Passo 1 — Partir do projeto da aula 13​

Copie a pasta do projeto anterior, para que ele continue existindo como estava:

cp -R biblioteca-web-aula-13 biblioteca-web-aula-14
cd biblioteca-web-aula-14
rm -rf .next
npm install

Troque o texto do rodapé, em app/layout.tsx, para "Laboratório da aula 14", e o parágrafo de app/page.tsx para "As páginas leem e alteram o acervo pela API do Módulo 2".

Passo 2 — Pôr no ar a API da aula 14​

A API desta aula é a da aula 13 com três acréscimos, nenhum deles no schema:

  • GET /autores e GET /editoras, em módulos próprios, com a lista em ordem alfabética. O formulário de cadastro precisa oferecer o autor e a editora como opções, e até aqui a API não tinha como listá-los;
  • um seed com doze livros, quatro editoras e uma autora sem nenhum livro — Conceição Evaristo;
  • as tags dos dois módulos novos no Swagger.

O service de autores mostra o que as duas rotas fazem:

src/autores/autores.service.ts (API da aula 14, trecho)
@Injectable()
export class AutoresService {
constructor(private readonly prisma: PrismaService) {}

listar() {
return this.prisma.autor.findMany({
select: { id: true, nome: true, nacionalidade: true },
orderBy: { nome: 'asc' },
});
}
}

A lista de autores não pode ser montada a partir de GET /livros: deixaria de fora quem ainda não tem livro no acervo, e a autora sem livro está no seed exatamente para isso aparecer. As rotas não são paginadas: o resultado é usado inteiro, num seletor. Se um dia não couber num seletor, a resposta é trocar o seletor por uma busca.

Na pasta da API da aula 14, aplique o seed e suba o serviço:

npx prisma db seed
npm run start:dev

Confira no navegador:

EndereçoEsperado
http://localhost:3000/autoresdez autores, Conceição Evaristo entre eles
http://localhost:3000/editorasCompanhia das Letras, Global, Record e Rocco
http://localhost:3000/livros?tamanho=5cinco livros, total: 12, totalDePaginas: 3
http://localhost:3000/livros?tamanho=5&pagina=99dados: [], total: 12
No seu projeto

Liste os relacionamentos obrigatórios das entidades que o seu cliente vai cadastrar. Cada um vira um seletor no formulário, e cada seletor precisa de uma rota que liste as opções. Se a rota não existe, ela é a primeira coisa a fazer — e é na API, não no cliente.

Passo 3 — Uma página de cada vez​

Em lib/livros.ts, acrescente a constante do tamanho da página logo depois de API_URL:

lib/livros.ts (trecho)
// Quantos livros por página a interface mostra. A API aceita até 100; cinco
// é o tamanho que deixa a paginação visível com o acervo do seed.
export const TAMANHO_DA_PAGINA = 5;

E troque listarLivros inteira. Ela passa a receber o termo e a página, e a devolver o envelope, não só dados:

lib/livros.ts (trecho)
export async function listarLivros({
autor,
pagina,
}: {
autor: string;
pagina: number;
}): Promise<Pagina<Livro>> {
await esperar(ATRASO_MS);

// URLSearchParams codifica o termo: "são" vira "s%C3%A3o", e um "&"
// digitado não quebra a consulta.
const consulta = new URLSearchParams({
pagina: String(pagina),
tamanho: String(TAMANHO_DA_PAGINA),
});
if (autor !== "") {
consulta.set("autor", autor);
}

const resposta = await fetch(`${API_URL}/livros?${consulta}`);

// `fetch` só rejeita quando não há resposta nenhuma (API fora do ar, porta
// errada). Um 500 é uma resposta: chega aqui com `ok` falso.
if (!resposta.ok) {
throw new Error(`GET /livros respondeu ${resposta.status}`);
}

return resposta.json();
}

Rode npx tsc --noEmit. O TypeScript aponta app/livros/page.tsx: a chamada não passa os argumentos, e o resultado deixou de ser um vetor. É o passo 4.

Passo 4 — A URL como estado​

Crie app/components/BuscaNoAcervo.tsx:

app/components/BuscaNoAcervo.tsx
import Form from "next/form";

// O formulário de busca do acervo. `Form`, de `next/form`, é um <form> que,
// quando `action` é um endereço, transforma os campos em parâmetros da URL e
// navega pelo roteador do Next.js, sem recarregar a página. Sem JavaScript,
// continua sendo um <form method="get"> comum e funciona do mesmo jeito.
//
// Não tem "use client": o componente não guarda estado nem registra evento.
export default function BuscaNoAcervo({ termo }: { termo: string }) {
return (
<Form action="/livros" className="flex items-end gap-2">
<label className="block flex-1 text-sm">
Autor
{/* A chave faz o campo acompanhar a URL: ao voltar do histórico ou
limpar a busca, o termo muda e o <input> é refeito com o novo
defaultValue. */}
<input
key={termo}
name="autor"
type="search"
defaultValue={termo}
placeholder="Parte do nome do autor"
className="mt-1 block w-full rounded-md border border-black/15 bg-transparent px-3 py-2 dark:border-white/20"
/>
</label>
<button
type="submit"
className="rounded-md border border-black/15 px-3 py-2 text-sm hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
>
Buscar
</button>
</Form>
);
}

O passo 5 reescreve app/livros/page.tsx por inteiro, já com a busca e a paginação. Antes dele, confira que a busca existe: rode npx tsc --noEmit de novo e veja que o único erro continua sendo o da página.

Passo 5 — Paginação e sacola​

Crie app/components/Paginacao.tsx:

app/components/Paginacao.tsx
import Link from "next/link";

// Monta o endereço de uma página preservando o termo de busca. Mudar de
// página não pode desfazer a busca, e buscar volta sempre à página 1 (o
// formulário de busca não envia `pagina`).
export function enderecoDaPagina(pagina: number, termo: string): string {
const consulta = new URLSearchParams();
if (termo !== "") {
consulta.set("autor", termo);
}
if (pagina > 1) {
consulta.set("pagina", String(pagina));
}
const texto = consulta.toString();
return texto === "" ? "/livros" : `/livros?${texto}`;
}

// Componente de servidor: só links. Quem decide o que mostrar é a URL.
export default function Paginacao({
pagina,
totalDePaginas,
termo,
}: {
pagina: number;
totalDePaginas: number;
termo: string;
}) {
if (totalDePaginas <= 1) {
return null;
}

const classe =
"rounded-md border border-black/15 px-3 py-1 text-sm dark:border-white/20";

return (
<nav aria-label="Paginação" className="flex items-center gap-3">
{pagina > 1 ? (
<Link href={enderecoDaPagina(pagina - 1, termo)} className={`${classe} hover:bg-black/5 dark:hover:bg-white/10`}>
Anterior
</Link>
) : (
<span aria-disabled="true" className={`${classe} opacity-40`}>
Anterior
</span>
)}

<span className="text-sm">
Página {pagina} de {totalDePaginas}
</span>

{pagina < totalDePaginas ? (
<Link href={enderecoDaPagina(pagina + 1, termo)} className={`${classe} hover:bg-black/5 dark:hover:bg-white/10`}>
Próxima
</Link>
) : (
<span aria-disabled="true" className={`${classe} opacity-40`}>
Próxima
</span>
)}
</nav>
);
}

Substitua app/livros/page.tsx inteiro:

app/livros/page.tsx
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import BuscaNoAcervo from "@/app/components/BuscaNoAcervo";
import PainelAcervo from "@/app/components/PainelAcervo";
import Paginacao, { enderecoDaPagina } from "@/app/components/Paginacao";
import { listarLivros } from "@/lib/livros";

export const metadata: Metadata = {
title: "Acervo",
};

// Converte o parâmetro `pagina` da URL. Ausente é a primeira página; qualquer
// coisa que não seja um inteiro positivo é um endereço que não pode existir.
function lerPagina(valor: string | string[] | undefined): number | null {
if (valor === undefined) {
return 1;
}
if (typeof valor !== "string" || !/^[1-9]\d*$/.test(valor)) {
return null;
}
const numero = Number(valor);
return Number.isSafeInteger(numero) ? numero : null;
}

// A página lê `searchParams`, e isso basta para torná-la dinâmica: o que ela
// mostra depende da requisição. O `await connection()` da aula 13 saiu por
// esse motivo.
export default async function Page({ searchParams }: PageProps<"/livros">) {
const { autor, pagina } = await searchParams;

// `?autor=a&autor=b` chega como vetor. Só o texto simples é aceito.
const termo = typeof autor === "string" ? autor.trim() : "";
const numeroDaPagina = lerPagina(pagina);

if (numeroDaPagina === null) {
notFound();
}

const resultado = await listarLivros({ autor: termo, pagina: numeroDaPagina });

return (
<>
<h1 className="text-2xl font-semibold">Acervo</h1>

<div className="mt-4 space-y-4">
<BuscaNoAcervo termo={termo} />

<p className="text-sm opacity-80">
{resultado.total === 0
? "Nenhum título encontrado"
: `${resultado.total} ${resultado.total === 1 ? "título" : "títulos"}`}
{termo !== "" ? ` para o autor "${termo}"` : ""}.
{termo !== "" ? (
<>
{" "}
<Link href="/livros" className="underline">
Limpar a busca
</Link>
</>
) : null}
</p>

{/* Uma página além da última não é endereço inválido: ela pode passar
a existir quando o acervo crescer. Por isso não é 404. */}
{resultado.total > 0 && numeroDaPagina > resultado.totalDePaginas ? (
<p className="text-sm">
A página {numeroDaPagina} não existe nesta consulta.{" "}
<Link
href={enderecoDaPagina(resultado.totalDePaginas, termo)}
className="underline"
>
Ir para a página {resultado.totalDePaginas}
</Link>
</p>
) : (
<>
<PainelAcervo livros={resultado.dados} />
<Paginacao
pagina={numeroDaPagina}
totalDePaginas={resultado.totalDePaginas}
termo={termo}
/>
</>
)}
</div>
</>
);
}

Por fim, o painel. Apague app/components/FiltroAcervo.tsx e substitua app/components/PainelAcervo.tsx. O filtro sai, e a sacola passa a guardar os livros:

app/components/PainelAcervo.tsx
"use client";

import { useState } from "react";
import AcoesLivro from "@/app/components/AcoesLivro";
import LivroCard from "@/app/components/LivroCard";
import SacolaReserva from "@/app/components/SacolaReserva";
import type { Livro } from "@/lib/livros";

// O painel recebe só os livros da página atual. Dois efeitos disso, ambos
// tratados na aula 14:
//
// - o filtro em memória da aula 12 saiu: filtraria só cinco livros. A busca
// passou para a URL e é feita pela API (`BuscaNoAcervo`);
// - a sacola guarda os LIVROS reservados, e não só os ids. Com ids, um título
// reservado na página 1 sumiria da sacola na página 2, porque a lista
// recebida por props já não o contém.
export default function PainelAcervo({ livros }: { livros: Livro[] }) {
// Cada posição do histórico é uma sacola inteira; a última é a atual.
const [historico, setHistorico] = useState<Livro[][]>([[]]);

const sacola = historico[historico.length - 1];
const podeDesfazer = historico.length > 1;

function estaReservado(id: number) {
return sacola.some((livro) => livro.id === id);
}

function alternarReserva(id: number) {
const livro = livros.find((candidato) => candidato.id === id);
if (!livro) {
return;
}
const novaSacola = estaReservado(id)
? sacola.filter((reservado) => reservado.id !== id)
: [...sacola, livro];

setHistorico([...historico, novaSacola]);
}

function desfazer() {
setHistorico(historico.slice(0, -1));
}

return (
<div className="space-y-4">
<SacolaReserva
livros={sacola}
podeDesfazer={podeDesfazer}
onDesfazer={desfazer}
/>

<ul className="space-y-3">
{livros.map((livro) => (
<li key={livro.id}>
<LivroCard livro={livro}>
<AcoesLivro
livro={livro}
reservado={estaReservado(livro.id)}
onAlternarReserva={alternarReserva}
/>
</LivroCard>
</li>
))}
</ul>
</div>
);
}

Suba o cliente com npm run dev e confira:

Endereço / açãoEsperado
/livroscinco cartões, "12 títulos." e "Página 1 de 3"
"Próxima" duas vezes?pagina=3, dois cartões, "Próxima" esmaecido
reservar A Hora da Estrela na página 1 e ir à página 2a sacola continua com A Hora da Estrela
buscar graciliano?autor=graciliano, três cartões, sem paginação; a sacola continua
voltar no navegadora página anterior, com o campo de busca vazio
/livros?pagina=99"A página 99 não existe nesta consulta." e o link para a página 3
/livros?pagina=abc"Página não encontrada"

Passo 6 — Experimento: o campo que não acompanha a URL​

Em BuscaNoAcervo.tsx, apague a linha key={termo}. Busque graciliano e clique em "Limpar a busca". A lista volta aos doze títulos, e o campo continua mostrando graciliano. Nenhum erro, nenhum aviso.

O defaultValue só vale quando o elemento nasce. Ao limpar a busca, a página é montada de novo no servidor, mas o React encontra o mesmo <input> na mesma posição e o mantém, com o texto que o navegador guardou. Com key={termo}, a troca do termo troca a identidade do elemento, e o React cria outro, que nasce com o defaultValue atual — a regra das listas da aula 12, aplicada a um elemento só.

Devolva a linha e repita: o campo esvazia junto com a lista.

📦 Ficou para trás? O pacote aula-14-biblioteca-web-checkpoint-1.zip traz o projeto neste ponto (busca e paginação pela URL; ainda sem cadastro, edição ou cache). Veja como usar em Pacotes de checkpoint.

Passo 7 — Listas de apoio, escritas e regras do formulário​

Em lib/livros.ts, acrescente depois do tipo Pagina os tipos da escrita:

lib/livros.ts (trecho)
// O corpo que `POST /livros` aceita, campo a campo, como o `CriarLivroDto`
// declara. `PATCH /livros/:id` aceita o mesmo corpo com todos os campos
// opcionais; o formulário de edição envia sempre todos.
export type DadosLivro = {
titulo: string;
isbn: string;
ano: number;
autorId: number;
editoraId?: number;
};

// O que uma escrita pode dar. "Recusada" é a API respondendo que não aceita
// os dados (400, 404, 409): é um desfecho previsto, que o formulário mostra.
// Falha de rede ou 5xx não entra aqui: vira exceção, como na leitura.
export type ResultadoEscrita =
| { situacao: "gravado"; livro: Livro }
| { situacao: "recusado"; status: number; detalhes: string[] };

E, no fim do arquivo, as listas de apoio e as duas escritas:

lib/livros.ts (trecho)
// As duas listas de apoio do formulário. Sem opção de cache: quem as chama
// são páginas dinâmicas, e numa página dinâmica um `fetch` sem opção vai à
// API a cada requisição.
export async function listarAutores(): Promise<Autor[]> {
const resposta = await fetch(`${API_URL}/autores`);
if (!resposta.ok) {
throw new Error(`GET /autores respondeu ${resposta.status}`);
}
return resposta.json();
}

export async function listarEditoras(): Promise<Editora[]> {
const resposta = await fetch(`${API_URL}/editoras`);
if (!resposta.ok) {
throw new Error(`GET /editoras respondeu ${resposta.status}`);
}
return resposta.json();
}

// As duas escritas. Quem as chama é sempre uma Server Action: a requisição
// sai do servidor Next.js, sem navegador no meio e, portanto, sem CORS.
export function criarLivro(dados: DadosLivro): Promise<ResultadoEscrita> {
return enviar("POST", "/livros", dados);
}

export function atualizarLivro(
id: number,
dados: DadosLivro,
): Promise<ResultadoEscrita> {
return enviar("PATCH", `/livros/${id}`, dados);
}

async function enviar(
metodo: "POST" | "PATCH",
caminho: string,
dados: DadosLivro,
): Promise<ResultadoEscrita> {
await esperar(ATRASO_MS);

const resposta = await fetch(`${API_URL}${caminho}`, {
method: metodo,
// Sem este cabeçalho o NestJS não lê o corpo como JSON, e a validação
// recusa todos os campos como ausentes.
headers: { "Content-Type": "application/json" },
body: JSON.stringify(dados),
});

if (resposta.ok) {
return { situacao: "gravado", livro: await resposta.json() };
}

// 400 (validação), 404 (o livro sumiu) e 409 (ISBN repetido, autor ou
// editora inexistentes) são respostas do contrato: o corpo traz o
// envelope de erro da aula 6, e os `detalhes` explicam a recusa.
if (
resposta.status === 400 ||
resposta.status === 404 ||
resposta.status === 409
) {
const corpo: { detalhes: string[] } = await resposta.json();
return {
situacao: "recusado",
status: resposta.status,
detalhes: corpo.detalhes,
};
}

throw new Error(`${metodo} ${caminho} respondeu ${resposta.status}`);
}

Crie lib/formulario-livro.ts, com os tipos do formulário, a leitura dos campos e a tradução das recusas. Copie-o inteiro; as Partes 3 a 5 explicam cada função:

lib/formulario-livro.ts
import type { DadosLivro, Livro } from "@/lib/livros";

// O que o formulário de livro envia, tal como chega: tudo é texto. É o
// FormData que entrega assim — um <input type="number"> também chega como
// string, e um <select> sem escolha chega como "".
export type CamposLivro = {
titulo: string;
isbn: string;
ano: string;
autorId: string;
editoraId: string;
};

export type ErrosLivro = Partial<Record<keyof CamposLivro, string>>;

// O estado que a Server Action devolve ao formulário a cada envio. Os
// `valores` voltam junto com os erros porque o React reinicia o formulário
// depois de cada envio: sem eles, o usuário perderia o que digitou.
export type EstadoFormulario = {
valores: CamposLivro;
erros: ErrosLivro;
mensagem: string | null;
};

export const ESTADO_VAZIO: EstadoFormulario = {
valores: { titulo: "", isbn: "", ano: "", autorId: "", editoraId: "" },
erros: {},
mensagem: null,
};

// Ponto de partida do formulário de edição: o livro como está na API,
// convertido para o formato dos campos.
export function estadoDoLivro(livro: Livro): EstadoFormulario {
return {
valores: {
titulo: livro.titulo,
isbn: livro.isbn,
ano: String(livro.ano),
autorId: String(livro.autor.id),
editoraId: livro.editora ? String(livro.editora.id) : "",
},
erros: {},
mensagem: null,
};
}

function texto(formData: FormData, campo: keyof CamposLivro): string {
const valor = formData.get(campo);
// `get` devolve `null` se o campo não veio, e `File` se for um upload.
// Nenhum dos dois é um texto digitado.
return typeof valor === "string" ? valor.trim() : "";
}

// Lê e confere os campos. Devolve os dados prontos para a API, ou os erros
// por campo. As regras repetem as do `CriarLivroDto` só no que dá para dizer
// ao usuário campo a campo, antes de gastar uma chamada; quem decide de fato
// continua sendo a API.
export function lerFormulario(formData: FormData): {
valores: CamposLivro;
dados: DadosLivro | null;
erros: ErrosLivro;
} {
const valores: CamposLivro = {
titulo: texto(formData, "titulo"),
isbn: texto(formData, "isbn"),
ano: texto(formData, "ano"),
autorId: texto(formData, "autorId"),
editoraId: texto(formData, "editoraId"),
};

const erros: ErrosLivro = {};

if (valores.titulo === "") {
erros.titulo = "Informe o título.";
} else if (valores.titulo.length > 200) {
erros.titulo = "O título aceita até 200 caracteres.";
}

// Hífens e espaços são aceitos na digitação e retirados antes do envio.
const isbn = valores.isbn.replace(/[-\s]/g, "");
if (!/^(\d{9}[\dX]|\d{13})$/.test(isbn)) {
erros.isbn = "O ISBN tem 10 ou 13 dígitos.";
}

const ano = Number(valores.ano);
if (!Number.isInteger(ano) || ano < 1450 || ano > 2100) {
erros.ano = "Informe um ano entre 1450 e 2100.";
}

if (valores.autorId === "") {
erros.autorId = "Escolha o autor.";
}

if (Object.keys(erros).length > 0) {
return { valores, dados: null, erros };
}

return {
valores,
erros,
dados: {
titulo: valores.titulo,
isbn,
ano,
autorId: Number(valores.autorId),
// A editora é opcional: "" quer dizer "sem editora", e o campo nem
// vai no corpo.
...(valores.editoraId !== "" && { editoraId: Number(valores.editoraId) }),
},
};
}

// Quando a API recusa, os `detalhes` do envelope de erro dizem o motivo. Nos
// erros de validação (400), cada detalhe gerado pelo class-validator começa
// pelo nome do campo — "isbn must be an ISBN" —, e é isso que permite pôr o
// erro ao lado do campo certo. O texto mostrado é do cliente: a mensagem da
// API está em inglês e foi escrita para quem programa, não para quem usa.
const MENSAGEM_POR_CAMPO: Record<keyof CamposLivro, string> = {
titulo: "A API recusou o título.",
isbn: "ISBN inválido: confira os dígitos.",
ano: "A API recusou o ano.",
autorId: "A API recusou o autor.",
editoraId: "A API recusou a editora.",
};

export function traduzirRecusa(
status: number,
detalhes: string[],
): Pick<EstadoFormulario, "erros" | "mensagem"> {
if (status === 400) {
const erros: ErrosLivro = {};
const restantes: string[] = [];

for (const detalhe of detalhes) {
const campo = (Object.keys(MENSAGEM_POR_CAMPO) as (keyof CamposLivro)[])
.find((nome) => detalhe.startsWith(`${nome} `));
if (campo) {
erros[campo] = MENSAGEM_POR_CAMPO[campo];
} else {
restantes.push(detalhe);
}
}

return {
erros,
mensagem: restantes.length > 0 ? restantes.join(" ") : null,
};
}

// 404 e 409 já vêm com mensagem em português, escrita pelo service da API
// para o domínio ("ISBN … já cadastrado"). Ela não diz a qual campo se
// refere, então vai para o topo do formulário.
return { erros: {}, mensagem: detalhes.join(" ") };
}

Rode npx tsc --noEmit: nenhum erro. Nada disso aparece na tela ainda.

Passo 8 — A ação e o formulário​

Crie app/livros/acoes.ts. Nesta primeira versão, a ação não invalida nada depois de gravar — o passo 12 mostra quando isso passa a ser necessário:

app/livros/acoes.ts (primeira versão)
"use server";

// Server Actions do acervo. A diretiva no topo do arquivo marca TODAS as
// funções exportadas como ações: o código delas roda só no servidor, e o
// navegador recebe apenas uma referência que, ao ser chamada, faz um POST
// para o servidor Next.js. Por isso cada uma confere os dados que recebe:
// qualquer um que saiba montar esse POST pode chamá-la, com o formulário ou
// sem ele.

import { redirect } from "next/navigation";
import {
type EstadoFormulario,
lerFormulario,
traduzirRecusa,
} from "@/lib/formulario-livro";
import { type ResultadoEscrita, criarLivro } from "@/lib/livros";

const FALHA_DE_COMUNICACAO =
"Não foi possível falar com o serviço da biblioteca. Nada foi gravado; tente de novo.";

// Assinatura imposta pelo `useActionState`: o estado anterior chega primeiro,
// o FormData depois, e o que a função devolve vira o novo estado do
// formulário.
export async function cadastrarLivro(
_estadoAnterior: EstadoFormulario,
formData: FormData,
): Promise<EstadoFormulario> {
const { valores, dados, erros } = lerFormulario(formData);
if (!dados) {
return { valores, erros, mensagem: null };
}

let resultado: ResultadoEscrita;
try {
resultado = await criarLivro(dados);
} catch {
return { valores, erros: {}, mensagem: FALHA_DE_COMUNICACAO };
}

if (resultado.situacao === "recusado") {
return {
valores,
...traduzirRecusa(resultado.status, resultado.detalhes),
};
}

// `redirect` interrompe a função lançando uma exceção que o Next.js
// reconhece. Por isso ele fica FORA do try/catch: dentro, o catch a
// engoliria e o redirecionamento não aconteceria.
redirect(`/livros/${resultado.livro.id}`);
}

Crie app/components/BotaoEnviar.tsx com o código da Parte 5, e app/components/FormularioLivro.tsx:

app/components/FormularioLivro.tsx
"use client";

import { useActionState } from "react";
import BotaoEnviar from "@/app/components/BotaoEnviar";
import type { EstadoFormulario } from "@/lib/formulario-livro";
import type { Autor, Editora } from "@/lib/livros";

// O mesmo formulário serve ao cadastro e à edição. O que muda entre os dois
// chega por props: a ação a executar, o estado de partida e o rótulo do botão.
//
// Os campos são NÃO controlados: nenhum `value`, nenhum `onChange`. Quem
// guarda o texto é o navegador, e o React só o lê no envio, pelo FormData. É o
// contrário do filtro da aula 12: aqui nenhuma tecla provoca renderização.
export default function FormularioLivro({
acao,
estadoInicial,
autores,
editoras,
rotulo,
}: {
acao: (
estado: EstadoFormulario,
formData: FormData,
) => Promise<EstadoFormulario>;
estadoInicial: EstadoFormulario;
autores: Autor[];
editoras: Editora[];
rotulo: string;
}) {
const [estado, acaoDoFormulario] = useActionState(acao, estadoInicial);
const { valores, erros, mensagem } = estado;

return (
<form action={acaoDoFormulario} className="mt-4 max-w-lg space-y-4">
{mensagem ? (
<p
role="alert"
className="rounded border border-red-600/40 p-3 text-sm"
>
{mensagem}
</p>
) : null}

<Campo rotulo="Título" erro={erros.titulo}>
<input
name="titulo"
required
maxLength={200}
defaultValue={valores.titulo}
aria-invalid={erros.titulo ? true : undefined}
className={classeDoCampo}
/>
</Campo>

<Campo rotulo="ISBN" erro={erros.isbn} dica="10 ou 13 dígitos; hífens são aceitos">
<input
name="isbn"
required
inputMode="numeric"
defaultValue={valores.isbn}
aria-invalid={erros.isbn ? true : undefined}
className={`${classeDoCampo} font-mono`}
/>
</Campo>

<Campo rotulo="Ano" erro={erros.ano}>
<input
name="ano"
type="number"
required
min={1450}
max={2100}
defaultValue={valores.ano}
aria-invalid={erros.ano ? true : undefined}
className={classeDoCampo}
/>
</Campo>

<Campo rotulo="Autor" erro={erros.autorId}>
{/* A chave refaz o <select> quando o estado muda. Sem ela, o valor
mostrado depois de um envio recusado não acompanha `defaultValue`
(ver a Parte 5 da aula 14). */}
<select
key={`autor-${valores.autorId}`}
name="autorId"
required
defaultValue={valores.autorId}
aria-invalid={erros.autorId ? true : undefined}
className={classeDoCampo}
>
<option value="">Escolha o autor</option>
{autores.map((autor) => (
<option key={autor.id} value={autor.id}>
{autor.nome}
</option>
))}
</select>
</Campo>

<Campo rotulo="Editora" erro={erros.editoraId} dica="opcional">
<select
key={`editora-${valores.editoraId}`}
name="editoraId"
defaultValue={valores.editoraId}
className={classeDoCampo}
>
<option value="">Sem editora</option>
{editoras.map((editora) => (
<option key={editora.id} value={editora.id}>
{editora.nome}
</option>
))}
</select>
</Campo>

<BotaoEnviar rotulo={rotulo} />
</form>
);
}

const classeDoCampo =
"mt-1 block w-full rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm dark:border-white/20";

function Campo({
rotulo,
erro,
dica,
children,
}: {
rotulo: string;
erro?: string;
dica?: string;
children: React.ReactNode;
}) {
return (
<label className="block text-sm">
{rotulo}
{dica ? <span className="opacity-60"> ({dica})</span> : null}
{children}
{erro ? (
<span className="mt-1 block text-red-700 dark:text-red-400">{erro}</span>
) : null}
</label>
);
}

Por fim, a página, app/livros/novo/page.tsx:

app/livros/novo/page.tsx
import type { Metadata } from "next";
import { connection } from "next/server";
import FormularioLivro from "@/app/components/FormularioLivro";
import { cadastrarLivro } from "@/app/livros/acoes";
import { ESTADO_VAZIO } from "@/lib/formulario-livro";
import { listarAutores, listarEditoras } from "@/lib/livros";

export const metadata: Metadata = {
title: "Cadastrar livro",
};

export default async function Page() {
// A lição da aula 13 vale aqui também: sem esta linha, as duas listas
// seriam lidas uma vez no build, e um autor cadastrado depois nunca
// apareceria no seletor.
await connection();

// As duas chamadas não dependem uma da outra: saem juntas.
const [autores, editoras] = await Promise.all([
listarAutores(),
listarEditoras(),
]);

return (
<>
<h1 className="text-2xl font-semibold">Cadastrar livro</h1>
<FormularioLivro
acao={cadastrarLivro}
estadoInicial={ESTADO_VAZIO}
autores={autores}
editoras={editoras}
rotulo="Cadastrar"
/>
</>
);
}

E o link para ela, em app/livros/layout.tsx, depois do link "Buscar por autor":

app/livros/layout.tsx (trecho)
<Link href="/livros/novo" className="underline">
Cadastrar livro
</Link>

Abra /livros/novo e confira que o seletor de autor tem dez nomes, com Conceição Evaristo entre eles. Preencha:

CampoValor
TítuloQuincas Borba
ISBN978-85-7232-697-2
Ano1891
AutorMachado de Assis
EditoraRecord

e envie. O ISBN tem treze dígitos, e a ação o aceita; quem o recusa é a API, pelo dígito verificador. Aparece "ISBN inválido: confira os dígitos." embaixo do campo, e todos os outros valores continuam preenchidos. No terminal do npm run dev, o Next.js registra a chamada da ação, com os argumentos.

Corrija o ISBN para 9788572326971 e envie de novo. A tela passa ao detalhe de Quincas Borba. Em "Todos os títulos", busque machado: ele aparece ao lado dos outros dois livros do autor.

Por último, volte a /livros/novo e cadastre outro livro com o mesmo ISBN. O topo do formulário mostra "ISBN 9788572326971 já cadastrado".

No seu projeto

Escolha a entidade principal do seu domínio e escreva o formulário de cadastro dela. Para cada campo, decida o que a ação confere antes de chamar a API e o que fica só com a API. A regra prática: a ação confere o que dá para explicar ao usuário campo a campo; regra de negócio que depende do banco — unicidade, existência de um relacionamento — fica com a API.

Passo 9 — Esperando e falhando​

Em .env.local, descomente ATRASO_API_MS=1500 e reinicie o npm run dev. Cadastre um livro qualquer com um ISBN válido de outra obra — por exemplo, Memorial de Aires, 9788535901115, 1908, Machado de Assis. Durante um segundo e meio, o botão fica esmaecido, com "Enviando…", e não aceita outro clique.

Comente de novo ATRASO_API_MS e reinicie. Com a API no ar, abra /livros/novo e preencha o formulário com outro livro — Helena, 9788535901139, 1876, Machado de Assis. Pare a API e só então envie. O topo do formulário mostra "Não foi possível falar com o serviço da biblioteca. Nada foi gravado; tente de novo.", com os campos preenchidos. Suba a API de novo e envie: o livro é gravado.

📦 Ficou para trás? O pacote aula-14-biblioteca-web-checkpoint-2.zip traz o projeto neste ponto (/livros/novo cadastra pela Server Action; leituras ainda sem cache, e sem edição). Veja como usar em Pacotes de checkpoint.

Passo 10 — Experimento: valores perdidos e seletores que voltam ao início​

Dois experimentos com o mesmo roteiro: preencher o formulário com o ISBN errado do passo 8 (978-85-7232-697-2) e enviar.

  1. Em FormularioLivro.tsx, apague a linha defaultValue={valores.titulo} do campo de título. Depois do envio recusado, o título aparece vazio; os outros campos, não. Devolva a linha.
  2. Apague as duas linhas key=… dos <select>. Depois do envio recusado, os campos de texto mantêm os valores, e os dois seletores voltam a "Escolha o autor" e "Sem editora". Devolva as linhas.

Nenhum dos dois produz erro ou aviso. O primeiro mostra o React reiniciando o formulário; o segundo, que, para o <select>, o novo defaultValue não basta — é preciso que o elemento nasça de novo, como no passo 6.

Passo 11 — Experimento: o redirect engolido​

Em acoes.ts, troque o trecho entre lerFormulario e o redirect por esta versão, com tudo dentro do try:

app/livros/acoes.ts — alteração temporária
try {
const resultado = await criarLivro(dados);
if (resultado.situacao === "recusado") {
return { valores, ...traduzirRecusa(resultado.status, resultado.detalhes) };
}
redirect(`/livros/${resultado.livro.id}`);
} catch {
return { valores, erros: {}, mensagem: FALHA_DE_COMUNICACAO };
}

Cadastre Esaú e Jacó, ISBN 9788535901122, 1904, Machado de Assis. A tela continua no formulário, com "Não foi possível falar com o serviço da biblioteca. Nada foi gravado; tente de novo." Busque machado em outra aba: o livro foi gravado. Volte ao formulário e envie de novo: "ISBN 9788535901122 já cadastrado".

O TypeScript, o ESLint e o console não dizem nada. O redirect lançou a exceção que o Next.js usaria para navegar, e o catch a tratou como falha de rede. Desfaça a alteração.

Passo 12 — Guardar as leituras, e o que deixa de valer​

Em lib/livros.ts, acrescente as duas constantes do cache depois de TAMANHO_DA_PAGINA:

lib/livros.ts (trecho)
// Toda leitura do acervo é marcada com esta etiqueta, e é por ela que uma
// escrita invalida o que estava guardado (`updateTag` em `app/livros/acoes.ts`).
export const ETIQUETA_ACERVO = "livros";

// Quanto tempo, em segundos, uma leitura do acervo pode ser reaproveitada
// antes de a API ser consultada de novo. Ver a Parte 6 da aula 14.
const VALIDADE_SEGUNDOS = 60;

E passe a opção às duas leituras do acervo, a de listarLivros e a de buscarLivro:

lib/livros.ts (trecho)
const resposta = await fetch(`${API_URL}/livros?${consulta}`, {
next: { revalidate: VALIDADE_SEGUNDOS, tags: [ETIQUETA_ACERVO] },
});
lib/livros.ts (trecho)
// A mesma etiqueta da listagem: alterar um livro invalida as duas telas.
const resposta = await fetch(`${API_URL}/livros/${id}`, {
next: { revalidate: VALIDADE_SEGUNDOS, tags: [ETIQUETA_ACERVO] },
});

Reinicie o npm run dev. Abra /livros e confira a primeira página: A Hora da Estrela, A Rosa do Povo, Angústia, Capitães da Areia e o quinto título. Cadastre A Paixão segundo G.H., ISBN 9788535901108, 1964, Clarice Lispector. O detalhe aparece; clique em "Todos os títulos". O livro não está na primeira página, embora devesse estar entre A Hora da Estrela e A Rosa do Povo. Recarregar não muda nada.

A leitura de /livros foi guardada antes do cadastro, e nada avisou que deixou de valer. Em acoes.ts, importe updateTag e a etiqueta, e chame-a antes do redirect:

app/livros/acoes.ts (trecho)
import { updateTag } from "next/cache";
app/livros/acoes.ts (trecho)
// A gravação aconteceu na API, mas as leituras guardadas pelo Next.js
// ainda têm o acervo antigo. `updateTag` as descarta, e a próxima tela que
// ler o acervo espera pelos dados novos.
updateTag(ETIQUETA_ACERVO);

Acrescente ETIQUETA_ACERVO ao import de @/lib/livros. Para repetir o experimento do zero, pare o npm run dev, aplique o seed de novo na API, apague a pasta .next (as leituras guardadas ficam gravadas em disco, dentro dela) e suba o cliente. Abra /livros e cadastre o mesmo livro: agora ele aparece na primeira página assim que se clica em "Todos os títulos".

Por último, troque updateTag(ETIQUETA_ACERVO) por revalidateTag(ETIQUETA_ACERVO, "max") (importada também de next/cache) e repita do zero: a primeira visita a /livros depois do cadastro não mostra o livro; a segunda, sim. É o stale-while-revalidate da Parte 6. Volte ao updateTag.

📦 Ficou para trás? O pacote aula-14-biblioteca-web-checkpoint-3.zip traz o projeto neste ponto (leituras guardadas com revalidate/tags, invalidadas por updateTag ao gravar; ainda sem edição). Veja como usar em Pacotes de checkpoint.

Passo 13 — Edição​

No acoes.ts, acrescente atualizarLivro ao import de @/lib/livros e a segunda ação:

app/livros/acoes.ts (trecho)
// O id não é um campo do formulário: a página de edição o amarra com `bind`,
// e ele chega como primeiro argumento. Isso mantém o formulário igual nas
// duas telas, mas não é proteção: como toda ação, esta pode ser chamada com
// qualquer id. Decidir QUEM pode alterar QUAL livro é autorização, assunto
// da aula 15.
export async function editarLivro(
id: number,
_estadoAnterior: EstadoFormulario,
formData: FormData,
): Promise<EstadoFormulario> {
const { valores, dados, erros } = lerFormulario(formData);
if (!dados) {
return { valores, erros, mensagem: null };
}

let resultado: ResultadoEscrita;
try {
resultado = await atualizarLivro(id, dados);
} catch {
return { valores, erros: {}, mensagem: FALHA_DE_COMUNICACAO };
}

if (resultado.situacao === "recusado") {
return {
valores,
...traduzirRecusa(resultado.status, resultado.detalhes),
};
}

updateTag(ETIQUETA_ACERVO);
redirect(`/livros/${id}`);
}

Crie app/livros/[id]/editar/page.tsx:

app/livros/[id]/editar/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import FormularioLivro from "@/app/components/FormularioLivro";
import { editarLivro } from "@/app/livros/acoes";
import { estadoDoLivro } from "@/lib/formulario-livro";
import { buscarLivro, listarAutores, listarEditoras } from "@/lib/livros";

export const metadata: Metadata = {
title: "Editar livro",
};

export default async function Page({
params,
}: PageProps<"/livros/[id]/editar">) {
const { id } = await params;

if (!/^[1-9]\d*$/.test(id) || !Number.isSafeInteger(Number(id))) {
notFound();
}

const [livro, autores, editoras] = await Promise.all([
buscarLivro(Number(id)),
listarAutores(),
listarEditoras(),
]);

if (!livro) {
notFound();
}

// `bind` produz uma nova ação com o primeiro argumento já preenchido. Para
// o formulário, ela tem a mesma forma de `cadastrarLivro`.
const acao = editarLivro.bind(null, livro.id);

return (
<>
<h1 className="text-2xl font-semibold">Editar {livro.titulo}</h1>
<FormularioLivro
acao={acao}
estadoInicial={estadoDoLivro(livro)}
autores={autores}
editoras={editoras}
rotulo="Salvar alterações"
/>
</>
);
}

E o link no detalhe, em app/livros/[id]/page.tsx, trocando o <div> do botão de copiar:

app/livros/[id]/page.tsx (trecho)
<div className="mt-6 flex flex-wrap items-center gap-3">
<BotaoCopiarIsbn isbn={livro.isbn} />
<Link
href={`/livros/${livro.id}/editar`}
className="rounded-md border border-black/15 px-3 py-1 text-sm hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
>
Editar
</Link>
</div>

Abra o detalhe de Dom Casmurro: editora "não informada". Clique em "Editar". O formulário vem preenchido, com "Sem editora" no seletor. Escolha Companhia das Letras e salve: o detalhe mostra a editora nova, e a listagem também — as duas leituras têm a mesma etiqueta.

Passo 14 — Fechar o ciclo​

Primeiro, a conferência da ação sem a do navegador. Em /livros/novo, abra o console das ferramentas de desenvolvedor e desligue a validação do formulário:

console do navegador, em localhost:3001/livros/novo
document.querySelector("form").noValidate = true;

Digite 99 no ano, deixe o resto vazio e envie. Os quatro erros aparecem — título, ISBN, ano e autor —, todos vindos de lerFormulario, no servidor. A API não foi chamada.

Depois, com a API no ar:

npm run lint
npm run build

O mapa de rotas deve mostrar:

┌ ○ /
├ ○ /_not-found
├ ƒ /livros
├ ƒ /livros/[id]
├ ƒ /livros/[id]/editar
├ ○ /livros/busca
└ ƒ /livros/novo

/livros continua ƒ sem o connection(): quem a torna dinâmica agora é a leitura de searchParams. /livros/novo é ƒ por causa do connection().

📦 Ficou para trás? O pacote aula-14-biblioteca-web-checkpoint-4.zip traz o laboratório completo desta aula (/livros/[id]/editar, com cadastro, paginação e cache). Veja como usar em Pacotes de checkpoint.

Critérios de conclusão​

O laboratório está completo quando:

  • /livros mostra cinco títulos por página, e "Anterior" e "Próxima" levam às três páginas;
  • a busca por autor muda a URL, volta à página 1, e "Limpar a busca" esvazia o campo e a busca;
  • /livros?pagina=99 mostra o aviso com o link para a última página, e /livros?pagina=abc, "Página não encontrada";
  • um título reservado na página 1 continua na sacola na página 2;
  • o seletor de autor do cadastro mostra Conceição Evaristo;
  • um ISBN com dígito verificador errado mostra o erro no campo, e os demais valores continuam preenchidos;
  • um ISBN repetido mostra a mensagem da API no topo do formulário;
  • durante o envio, o botão fica desabilitado; com a API parada, a mensagem de falha aparece e nada é gravado;
  • você reproduziu os experimentos dos passos 6, 10 e 11, e explicou por que nenhum deles emite aviso;
  • com as leituras guardadas, o livro cadastrado aparece na listagem logo depois de gravar, e você reproduziu os casos sem invalidação e com revalidateTag(…, "max");
  • a edição de um livro aparece no detalhe e na listagem;
  • com noValidate, os erros vêm da ação;
  • npm run lint e npm run build terminam sem apontamentos, com o mapa de rotas do passo 14;
  • o formulário de cadastro da entidade principal do seu domínio grava na sua API.

Fechamento​

O cliente agora escreve na API, apoiado em quatro decisões que valem para qualquer formulário — e, como na aula 13, nenhuma delas é sobre sintaxe.

A primeira foi o que vai na URL. O resultado que merece endereço próprio — uma busca, uma página da listagem — é estado da URL, lido pelo servidor, e ganha de graça o botão "voltar" e o link que se pode enviar a alguém.

A segunda foi onde a escrita é executada. A Server Action roda no servidor, fala com a API sem passar pelo navegador e por isso não depende de CORS. Mas é uma porta pública, e confere tudo o que recebe.

A terceira foi o que o formulário mostra em cada desfecho. Campo inválido, recusa da API, falha e gravação são quatro respostas diferentes, e o que o usuário digitou sobrevive às três primeiras.

A quarta foi o que deixa de valer depois de gravar. Guardar leituras economiza chamadas à API e obriga a escrita a avisar o que mudou; o aviso certo, para quem acabou de gravar, é o updateTag.

A aula 15 fecha as portas que esta aula deixou abertas: o login, a sessão, as rotas protegidas e a autorização dentro de cada ação.


Exercícios (checkpoints)​

  1. Decida, justificando com a pergunta da Parte 1, se cada estado deve ir para a URL: (a) a ordenação da listagem por título ou por ano; (b) o texto de um campo de sugestões enquanto o usuário digita; (c) a aba aberta na página de um leitor ("empréstimos em aberto" ou "histórico"); (d) a sacola de reserva.

  2. Explique por que /livros?pagina=abc responde "Página não encontrada" e /livros?pagina=99 não, e diga o que a API devolve em cada caso.

  3. Explique por que, com a listagem paginada, a sacola passou a guardar os livros e não os ids, e indique em que situação ela ainda perde o conteúdo.

  4. Explique por que cadastrarLivro confere os campos mesmo com required, min e max no formulário, e descreva o que do HTML da página permite chamar a ação sem usar o formulário.

  5. Preveja o que o usuário vê, e o que fica gravado na API, quando o redirect da ação está dentro do try: (a) com um livro válido; (b) com um ISBN repetido; (c) com a API parada.

  6. Compare updateTag("livros"), revalidateTag("livros", "max") e revalidatePath("/livros") depois de editar um livro, dizendo o que mostram a listagem e o detalhe na primeira visita depois de salvar.

  7. Diagnostique: um bibliotecário cadastra um livro pelo Swagger da API, abre a listagem do cliente web 30 segundos depois e não o encontra; um minuto e meio depois, recarrega duas vezes e o livro aparece só na segunda. Explique cada observação com a Parte 6.

  8. Explique por que o <input> do título precisa só de defaultValue e o <select> do autor precisa também de key, e relacione a correção com a regra de identidade das listas da aula 12.

  9. Implemente a remoção de um livro: uma ação removerLivro, amarrada ao id, chamada por um botão no detalhe, que trate o 204 (redirecionar para a listagem, invalidando a etiqueta) e o 409 de um livro com empréstimos (mostrar a mensagem da API). Explique por que a resposta 204 não pode passar por resposta.json().

  10. Classifique cada recusa da API, dizendo em que campo, ou no topo, o formulário a mostra: (a) 400 com ano must not be less than 1450; (b) 409 com ISBN 9788525406958 já cadastrado; (c) 409 com Autor ou editora informados não existem; (d) 400 com property capa should not exist.


Referências​

Principais​

Aprofundamento​