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 depois | Onde |
|---|---|
| Login, sessão, rotas protegidas, autorização nas ações e publicação | Aula 15 — Sessão e publicação |
| O mesmo serviço consumido por outro cliente | Mó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
searchParamse<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
useActionStateeuseFormStatus, 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
defaultValueekeypara que o usuário não perca o que digitou. - Comparar
updateTag,revalidateTagerevalidatePathdepois 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
redirectengolido por umcatche 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:
- Baixe e descompacte o
.zip. - Dentro da pasta,
npm install. - Copie
.env.examplepara.env.local(os valores padrão já apontam parahttp://localhost:3000, a porta da API). - Com a API no ar,
npm run dev— o cliente sobe emhttp://localhost:3001.
| Checkpoint | Depois do | O que já funciona |
|---|---|---|
aula-14-biblioteca-web-checkpoint-1.zip | Passo 6 | busca e paginação pela URL; ainda sem cadastro, edição ou cache |
aula-14-biblioteca-web-checkpoint-2.zip | Passo 9 | /livros/novo cadastra pela Server Action; leituras ainda sem cache |
aula-14-biblioteca-web-checkpoint-3.zip | Passo 12 | leituras guardadas com revalidate/tags, invalidadas por updateTag ao gravar |
aula-14-biblioteca-web-checkpoint-4.zip | Passo 14 | /livros/[id]/editar — o laboratório completo |
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.
| Ferramenta | Versão | Observação |
|---|---|---|
| Node.js | 22 LTS ou superior | a mesma das aulas anteriores |
| Next.js | 16.x | exemplos verificados com 16.3.4 |
| React | 19.2.x | verificado com 19.2.8 |
| TypeScript | 5.x | mínimo 5.1 |
| API do Módulo 2 | a da aula 14, sem autenticação | em http://localhost:3000, com o seed aplicado |
| PostgreSQL | 16 ou superior | o mesmo das aulas 7 a 13 |
| Navegador | Chrome, Edge, Firefox ou Safari recentes | o console é usado no passo 14 |
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 deixada | Onde é 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ça | O que é | Quem a define |
|---|---|---|
FormData | o que o navegador entrega no envio: pares nome/texto, sem tipo | o HTML do formulário |
| DTO da API | o contrato que decide se os dados servem (CriarLivroDto, aula 6) | a API |
| Estado do formulário | valores, erros por campo e mensagem, guardados entre um envio e o próximo | useActionState |
| Envio em andamento | se o <form> ao redor está sendo enviado agora | useFormStatus |
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 API | o servidor, uma vez, ao abrir a página | o navegador, a cada tecla | o servidor, a cada envio |
Requisições enquanto o usuário digita clarice | nenhuma | sete | nenhuma; uma ao enviar |
| Considera o acervo inteiro | não: só o que veio na página | sim | sim |
| O resultado pode ser guardado nos favoritos ou enviado a alguém | não | não | sim |
| O botão "voltar" desfaz a busca | não | não | sim |
| CORS e estados escritos à mão | não | sim | nã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:
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:
<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.
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.
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:
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:
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ço | O que é | O que a tela mostra |
|---|---|---|
/livros?pagina=abc | um endereço que não pode existir | "Página não encontrada", via notFound() |
/livros?pagina=99 | uma 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.
Os links preservam a busca
"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:
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.
A ação fica num arquivo próprio, com a diretiva no topo. Assim, toda função exportada por ele é uma ação:
"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 comoFormData. - A ação é uma porta pública. O identificador está no HTML da página, e
qualquer um que monte o mesmo
POSTpode 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:
// 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.
action também funciona — à moda antigaSe 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:
| Desfecho | Como chega à ação | O que o formulário mostra |
|---|---|---|
| Campo inválido | lerFormulario devolve erros, e a API não é chamada | o erro ao lado de cada campo |
| Recusa da API | 400, 404 ou 409, com o envelope de erro | o erro ao lado do campo, ou a mensagem no topo |
| Falha | o fetch rejeita, ou a API responde 5xx | uma mensagem geral; nada foi gravado |
| Gravação | 2xx com o livro | outra 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:
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:
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.
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:
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:
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:
<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:
<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:
"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.
<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.
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ção | Primeira visita depois de gravar | Segunda visita |
|---|---|---|
| nenhuma | sem o livro novo | sem o livro novo, até o prazo vencer |
revalidateTag("livros", "max") | sem o livro novo | com o livro novo |
revalidatePath("/livros") | com o livro novo | com o livro novo |
updateTag("livros") | com o livro novo | com 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 clienteUm 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.
id mudam a cada seed, e o cache não sabe dissoRodar 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:
// `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);
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ério | Server Action | fetch pelo navegador |
|---|---|---|
| CORS | não se aplica | a API precisa liberar a origem e responder ao preflight |
| Onde a API precisa estar acessível | só para o servidor Next.js | na internet, ao alcance de cada navegador |
| Credenciais de serviço | ficam no servidor | não podem existir: tudo vai no JavaScript público |
| Conferência dos dados | na ação, no servidor | na API, e só nela |
| Tela seguinte depois de gravar | na mesma resposta (Figura 1) | uma navegação a mais, pedida pelo componente |
| Invalidação do que o servidor guardou | updateTag na própria ação | nã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
| Erro | Sintoma | Correção |
|---|---|---|
Ler searchParams sem await | TypeScript recusa a desestruturação de uma Promise | await searchParams |
Converter pagina com Number() sem conferir | ?pagina=abc vira NaN, a API responde 400 e a página mostra a tela de falha | conferir 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 antigo | key={termo} |
| Sacola guardando só ids com a lista paginada | o título reservado some ao mudar de página | guardar os objetos |
Confiar no required do navegador | a ação recebe campos vazios de quem chamar o POST direto | conferir 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 estado | depois de um envio recusado, o campo aparece vazio | defaultValue={valores.campo} |
<select> sem key | depois de um envio recusado, volta à primeira opção | key com o valor atual |
useFormStatus no mesmo componente do <form> | pending é sempre falso | botão em componente filho |
redirect dentro do try | o livro é gravado e a tela diz que não foi; reenviar dá 409 | redirect depois do try/catch |
| Leitura guardada sem invalidação depois de gravar | o livro cadastrado não aparece na listagem | updateTag com a etiqueta das leituras |
revalidateTag(etiqueta, "max") depois de gravar | o livro aparece só na segunda visita | updateTag em Server Action |
revalidatePath("/livros") depois de uma edição | a listagem atualiza e o detalhe não | etiqueta comum às duas leituras e updateTag |
Esquecer o Content-Type no POST à API | 400 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 roteiro | Passos |
|---|---|
| Pôr os dois projetos no ar | 1 e 2 |
| O estado na URL | 3 a 6 |
| O formulário que escreve | 7 a 11 |
| Guardar e invalidar | 12 |
| Editar e fechar o ciclo | 13 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 /autoreseGET /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:
@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ço | Esperado |
|---|---|
http://localhost:3000/autores | dez autores, Conceição Evaristo entre eles |
http://localhost:3000/editoras | Companhia das Letras, Global, Record e Rocco |
http://localhost:3000/livros?tamanho=5 | cinco livros, total: 12, totalDePaginas: 3 |
http://localhost:3000/livros?tamanho=5&pagina=99 | dados: [], total: 12 |
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:
// 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:
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:
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:
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:
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:
"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ção | Esperado |
|---|---|
/livros | cinco 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 2 | a sacola continua com A Hora da Estrela |
buscar graciliano | ?autor=graciliano, três cartões, sem paginação; a sacola continua |
| voltar no navegador | a 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.ziptraz 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:
// 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:
// 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:
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:
"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:
"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:
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":
<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:
| Campo | Valor |
|---|---|
| Título | Quincas Borba |
| ISBN | 978-85-7232-697-2 |
| Ano | 1891 |
| Autor | Machado de Assis |
| Editora | Record |
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".
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.ziptraz o projeto neste ponto (/livros/novocadastra 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.
- Em
FormularioLivro.tsx, apague a linhadefaultValue={valores.titulo}do campo de título. Depois do envio recusado, o título aparece vazio; os outros campos, não. Devolva a linha. - 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:
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:
// 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:
const resposta = await fetch(`${API_URL}/livros?${consulta}`, {
next: { revalidate: VALIDADE_SEGUNDOS, tags: [ETIQUETA_ACERVO] },
});
// 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:
import { updateTag } from "next/cache";
// 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.ziptraz o projeto neste ponto (leituras guardadas comrevalidate/tags, invalidadas porupdateTagao 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:
// 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:
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:
<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:
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.ziptraz 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:
-
/livrosmostra 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=99mostra 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 lintenpm run buildterminam 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)
-
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.
-
Explique por que
/livros?pagina=abcresponde "Página não encontrada" e/livros?pagina=99não, e diga o que a API devolve em cada caso. -
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.
-
Explique por que
cadastrarLivroconfere os campos mesmo comrequired,minemaxno formulário, e descreva o que do HTML da página permite chamar a ação sem usar o formulário. -
Preveja o que o usuário vê, e o que fica gravado na API, quando o
redirectda ação está dentro dotry: (a) com um livro válido; (b) com um ISBN repetido; (c) com a API parada. -
Compare
updateTag("livros"),revalidateTag("livros", "max")erevalidatePath("/livros")depois de editar um livro, dizendo o que mostram a listagem e o detalhe na primeira visita depois de salvar. -
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.
-
Explique por que o
<input>do título precisa só dedefaultValuee o<select>do autor precisa também dekey, e relacione a correção com a regra de identidade das listas da aula 12. -
Implemente a remoção de um livro: uma ação
removerLivro, amarrada ao id, chamada por um botão no detalhe, que trate o204(redirecionar para a listagem, invalidando a etiqueta) e o409de um livro com empréstimos (mostrar a mensagem da API). Explique por que a resposta204não pode passar porresposta.json(). -
Classifique cada recusa da API, dizendo em que campo, ou no topo, o formulário a mostra: (a)
400comano must not be less than 1450; (b)409comISBN 9788525406958 já cadastrado; (c)409comAutor ou editora informados não existem; (d)400comproperty capa should not exist.
Referências
Principais
- Next.js — Forms — Server Actions em formulários, validação,
useActionStatee estado pendente - Next.js — Server Actions and Mutations — a resposta única, a segurança das ações e a integração com o cache
- Next.js — Form — o componente
Forme a navegação com parâmetros de consulta - Next.js — updateTag — invalidação para quem acabou de gravar
- Next.js — revalidateTag — os perfis e o stale-while-revalidate
- React — useActionState — o estado devolvido pela ação
- React — useFormStatus — o estado de envio lido pelo filho do formulário
Aprofundamento
- Next.js — revalidatePath — invalidação por endereço e a relação com as etiquetas
- Next.js — Data Security — por que tratar cada ação como entrada não confiável; base para a aula 15
- Next.js — page.js — as props
paramsesearchParams - React — form —
actioncom função e o reinício do formulário depois do envio - MDN — FormData — o objeto que a ação recebe
- MDN — URLSearchParams — a montagem de consultas com codificação correta