Pular para o conteúdo principal

Aula 12: Fundamentos de React

A aula 11 montou a estrutura do cliente web: rotas, layout, arquivos especiais e a fronteira entre servidor e cliente. Esta aula volta um passo, para a biblioteca que sustenta o framework, e trata do que a anterior anunciou sem explicar — o onClick do BotaoCopiarIsbn e o key={livro.id} da listagem.

O React resolve um problema só: descrever a tela a partir de dados e mantê-la coerente quando esses dados mudam. O que essa frase esconde são quatro decisões — o que precisa virar estado, em que componente esse estado mora, como ele é atualizado e de que lado da fronteira ele existe —, e são elas o conteúdo da aula.

Ao fim da aula, a rota /livros deixa de ser uma lista fixa e passa a ter um painel: filtro por título ou autor, contagem, sacola de reserva e desfazer. O laboratório parte do projeto da aula 11, porque as decisões que interessam aqui só aparecem quando mais de um pedaço da tela depende do mesmo valor.

O que vem depoisOnde
Busca de dados na API, useEffect, layouts aninhados, estados de carregamento e erro e estratégias de renderizaçãoAula 13 — Dados da API
Formulários, envio de dados, paginação e cacheAula 14 — Consumo de serviços no frontend
Login, sessão, rotas protegidas 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:

  • Explicar o que é um componente, o que são props e por que props são somente leitura.
  • Implementar composição por children, mantendo um componente reaproveitável sem que ele conheça quem o usa.
  • Justificar a escolha da chave de uma lista e prever o que acontece quando a chave é a posição.
  • Distinguir o que precisa virar estado do que pode ser calculado, aplicando as três perguntas do método.
  • Decidir em que componente um estado compartilhado deve ser declarado, e defender a escolha.
  • Implementar atualizações de estado sem alterar valores existentes, e explicar por que a alternativa falha em silêncio.
  • Diagnosticar dois defeitos silenciosos: o vetor de estado alterado no lugar e a chave pela posição.
  • Situar o estado em relação à fronteira servidor/cliente da aula 11, e verificar no build o que foi enviado ao navegador.

Ambiente sugerido​

Pré-requisitos: Introdução ao Next.js (aula 11), com o laboratório daquela aula concluído e rodando. De TypeScript (aula 3), revise funções, tipos de objeto, tipos união e o operador de encadeamento opcional.

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

FerramentaVersãoObservação
Node.js22 LTS ou superiora mesma da aula 11
Next.js16.xexemplos verificados com 16.3.4
React19.2.xo roteiro foi executado de ponta a ponta com 19.2.8; confira o package.json
TypeScript5.xmínimo 5.1
NavegadorChrome, Edge, Firefox ou Safari recentesas ferramentas de desenvolvedor são usadas no passo 12
Material de terceiros sobre React costuma ser de classe, não de função

Um tutorial que fala em class ... extends React.Component, this.state, componentDidMount ou render() é anterior a 2019. O código ainda funciona, mas não é o que se escreve hoje, não combina com nada desta disciplina e não serve de referência para o seu projeto. Procure material que use funções e hooks — useState, e não this.setState.


Conteúdo​

O que a aula 11 deixou em aberto​

Três perguntas ficaram em aberto na aula 11, e as três são desta:

Pergunta da aula 11Onde é respondida
O que é o onClick que não atravessa a fronteira?Parte 5
Por que key={livro.id} e não a posição na lista?Parte 3, e o passo 11 do laboratório
O que aconteceria se o botão precisasse lembrar de algo entre dois cliques?Parte 4

Parte 1 — Um componente é uma função​

O LivroCard da aula 11, sem nenhuma alteração:

app/components/LivroCard.tsx (aula 11)
export default function LivroCard({ livro }: { livro: Livro }) {
return (
<article className="rounded-lg border border-black/10 p-4">
<h2 className="font-medium">{livro.titulo}</h2>
<p className="mt-1 text-sm opacity-80">
{livro.autor.nome} — {livro.ano}
</p>
</article>
);
}

É uma função comum de TypeScript. Recebe um objeto e devolve uma descrição da tela. Não há classe, não há ciclo de vida a declarar e não há template separado do código: a marcação é uma expressão da própria linguagem.

Quatro regras decorrem disso, e as quatro têm consequência prática:

O nome começa com maiúscula. <LivroCard /> é interpretado como um componente; <livroCard /> seria tratado como uma marca HTML desconhecida. Não há erro — aparece uma marca vazia na página.

O retorno é um único elemento. Para devolver irmãos sem criar uma caixa a mais no HTML, use um fragment: <> e </>.

As chaves inserem uma expressão na marcação. {livro.titulo} é JavaScript dentro do JSX. Vale qualquer expressão, mas não comandos: um if não cabe ali, enquanto um operador ternário ou um && cabem.

Quem chama o componente é o React, não você. A diferença entre <LivroCard livro={livro} /> e LivroCard({ livro }) não é estilo. Na primeira forma, o React passa a conhecer aquele elemento, sabe onde ele está na árvore e pode guardar estado associado a ele. Na segunda, a função é apenas executada e o resultado é colado ali — não existe componente nenhum, e nada do que a Parte 4 descreve funciona.

Componente puro

Enquanto uma função de componente está sendo executada para produzir a tela, ela deve se comportar como um cálculo: mesmas props, mesmo resultado. Não altere variáveis declaradas fora dela nem objetos que recebeu por props durante a renderização. Tudo o que muda o mundo — responder a um clique, escrever no armazenamento do navegador — acontece em handlers de evento, e não no corpo da função. A Parte 8 mostra o defeito concreto que a quebra dessa regra produz.


Parte 2 — Props: o que desce pela árvore​

Props são os argumentos do componente. Duas características importam mais que a sintaxe.

São somente leitura. Um componente não altera o que recebeu. LivroCard não muda o livro; ele o exibe. Se algo precisa mudar, quem muda é o dono do valor — e a Parte 7 trata de quem é esse dono.

Descem, nunca sobem. Dados fluem do ancestral para o descendente. Isso torna o rastreamento de um valor errado na tela um trabalho mecânico: suba a árvore até encontrar quem o produziu. Não existe um componente alterando o valor de outro pelas costas.

Um tipo de prop merece destaque, porque muda o que se consegue reaproveitar: children. É o conteúdo que o componente recebe entre a marca de abertura e a de fechamento.

app/components/LivroCard.tsx (aula 12)
export default function LivroCard({
livro,
children,
}: {
livro: Livro;
children?: ReactNode;
}) {
return (
<article className="rounded-lg border border-black/10 p-4">
<h2 className="font-medium">{livro.titulo}</h2>
<p className="mt-1 text-sm opacity-80">
{livro.autor.nome} — {livro.ano}
</p>
{children ? <div className="mt-3">{children}</div> : null}
</article>
);
}

O cartão passou a ter um espaço, e continua sem saber o que vai ser colocado nele. Quem usa decide:

<LivroCard livro={livro}>
<AcoesLivro livro={livro} reservado={...} onAlternarReserva={...} />
</LivroCard>

A alternativa seria dar ao cartão uma prop mostrarBotaoDeReserva, depois outra para o botão de detalhes, depois uma terceira para a próxima ideia. Cada uma dessas props obrigaria a mexer no cartão. Com children, o cartão está pronto — o que muda é quem o usa.

Árvore de componentes do painel do acervo em três níveis. No topo, o componente de servidor da página do acervo, que lê os dados. Abaixo dele, PainelAcervo, marcado como componente de cliente e único dono do estado compartilhado. Na base, três componentes: FiltroAcervo, LivroCard com AcoesLivro, e SacolaReserva, cada um declarando o que recebe por props e qual função chama para avisar. Uma seta longa à esquerda indica que as props descem e outra à direita indica que os eventos sobem. Duas caixas explicam que para baixo vão dados prontos e para cima vão chamadas de função.
O painel que o laboratório constrói. As duas direções carregam coisas diferentes: para baixo vão valores, para cima vão chamadas de função — nunca dados.

A Figura 1 é o mapa do que o laboratório constrói. A seta da esquerda é o assunto desta parte; a da direita é o da Parte 5; a altura em que termo e historico aparecem é o da Parte 7.

PropEstado
Quem define o valoro componente de cimao próprio componente
Pode ser alterado por quem recebenãosim, pela função de atualização
Sobrevive a uma nova renderizaçãoé recebido de novosim, o React o preserva
Muda quandoo ancestral renderiza de novoalguém chama a função de atualização

Parte 3 — Listas e identidade​

Uma lista de elementos sai de um .map sobre um vetor:

<ul>
{livros.map((livro) => (
<li key={livro.id}>
<LivroCard livro={livro} />
</li>
))}
</ul>

A prop key não aparece no HTML e não é lida pelo seu componente. Ela serve ao React, e serve para uma coisa só: dizer qual elemento da lista nova corresponde a qual elemento da lista anterior.

Isso é necessário porque a lista é recriada inteira a cada renderização. Sem uma chave, o React só tem a posição para se orientar, e a posição é uma informação frágil: some um item do meio e todos os seguintes mudam de lugar sem ter mudado de identidade.

Quando o React casa dois elementos, ele preserva o que estava associado ao antigo: o estado interno daquele componente e o estado do próprio navegador, como o texto digitado em um campo ou a posição do cursor. Casar errado não quebra a tela — move essas coisas para o item errado.

Regras práticas:

  • use um identificador estável e único entre os irmãos: o id do registro é quase sempre a resposta certa;
  • a chave não precisa ser única na página inteira, só dentro da mesma lista;
  • não use a posição no vetor quando a lista puder ser filtrada, reordenada ou ter itens removidos do meio;
  • não gere a chave na renderização, com um número aleatório ou um contador: uma chave diferente a cada renderização faz o React destruir e recriar todos os itens, jogando fora exatamente o que a chave deveria preservar.

Sem chave nenhuma, o React avisa — em desenvolvimento, no console do navegador e no terminal:

Each child in a list should have a unique "key" prop.
Check the render method of `PainelAcervo`.
See https://react.dev/link/warning-keys for more information.

Com a chave errada, não há aviso nenhum. O passo 11 do laboratório provoca esse caso de propósito.


Parte 4 — Estado: o que o componente lembra​

Um componente precisa de memória quando algo na tela depende do que aconteceu antes: o texto já digitado, o cartão já aberto, os livros já reservados.

A tentação é declarar uma variável comum:

// Somente leitura: não funciona, por dois motivos independentes.
export default function Contador() {
let cliques = 0;
return <button onClick={() => { cliques = cliques + 1; }}>{cliques}</button>;
}

Os dois motivos:

  1. A variável não sobrevive. Cada renderização executa a função de novo, e cliques volta a zero.
  2. Alterá-la não avisa ninguém. Mesmo que sobrevivesse, o React não teria como saber que precisa desenhar a tela outra vez.

useState resolve os dois de uma vez:

const [cliques, setCliques] = useState(0);

A chamada devolve dois valores: o valor guardado nesta renderização e uma função que pede a próxima. O argumento é o valor inicial, usado só na primeira vez — nas seguintes ele é ignorado.

Ciclo de quatro etapas em disposição quadrada. Primeira etapa: o leitor clica e o handler passado em onClick executa. Segunda: o handler chama a função de atualização, e a variável de estado não muda naquele instante, pois a chamada apenas pede outra renderização. Terceira: o React chama o componente de novo, e só nessa chamada a leitura do estado devolve o valor novo. Quarta: o React compara o resultado com o anterior e altera apenas o que mudou no documento. Uma caixa embaixo registra que três chamadas somando um ao valor lido rendem um só incremento, enquanto três chamadas na forma de função rendem três.
A chamada da função de atualização não altera a variável naquele instante: ela pede uma nova renderização, e é nela que o valor novo aparece.

A Figura 2 explica o comportamento que mais confunde quem começa. Dentro de uma renderização, o valor do estado é fixo. Ele foi lido quando aquela renderização começou e não muda no meio dela, nem depois de uma chamada à função de atualização.

A consequência é medida, não teórica:

// Somente leitura. Em um único clique:
onClick={() => {
setCliques(cliques + 1);
setCliques(cliques + 1);
setCliques(cliques + 1);
}}
// resultado: o contador sobe 1, não 3.

As três chamadas leem o mesmo cliques — o desta renderização. As três pedem o mesmo valor novo. Para encadear atualizações, passe uma função, que recebe o valor mais recente:

// Somente leitura. No mesmo clique, agora:
onClick={() => {
setCliques((c) => c + 1);
setCliques((c) => c + 1);
setCliques((c) => c + 1);
}}
// resultado: o contador sobe 3.

Na prática, a forma com função só é necessária quando uma atualização depende da anterior dentro do mesmo handler. Nos casos do laboratório, o valor novo é calculado uma vez e passado direto.

Onde o estado pode ser declarado​

useState é um hook, e hooks obedecem a duas regras que o ESLint verifica:

  • só no topo da função, antes de qualquer return antecipado e nunca dentro de if, laço, try ou função aninhada;
  • só em componentes ou em outros hooks, nunca em uma função comum.

A razão é a mesma para as duas: o React identifica cada estado pela ordem em que os hooks são chamados. Uma chamada condicional mudaria essa ordem entre duas renderizações, e o segundo estado passaria a receber o valor do primeiro.

E o estado pertence a uma posição na árvore, não ao arquivo. Dois <AcoesLivro /> irmãos têm, cada um, o seu detalhesAbertos, embora venham do mesmo arquivo. É por isso que a chave da Parte 3 importa: ela é o que diz ao React qual posição corresponde a qual item.


Parte 5 — Eventos e entradas controladas​

Um handler é uma função passada como prop ao elemento:

<button type="button" onClick={() => onAlternarReserva(livro.id)}>
Reservar
</button>

O erro mais comum aqui é de um par de parênteses:

EscritoO que acontece
onClick={reservar}o React guarda a função e a chama no clique
onClick={reservar()}a função é chamada durante a renderização, e o resultado dela vira o handler
onClick={() => reservar(id)}a forma correta quando é preciso passar um argumento

A segunda linha costuma aparecer como um laço infinito: a função chamada na renderização altera o estado, que pede outra renderização, que a chama de novo.

Entradas controladas​

Um campo de texto tem estado por natureza — o navegador guarda o que foi digitado. Isso cria duas versões do mesmo dado, e elas saem de sincronia. A saída do React é assumir o controle:

app/components/FiltroAcervo.tsx
<input
type="search"
value={termo}
onChange={(evento) => onTermoMudar(evento.target.value)}
/>

O value vem do React; o onChange devolve cada tecla ao React. Existe uma só verdade sobre o conteúdo do campo, e ela está no estado.

Campo que não aceita digitação

Escrever value={termo} sem onChange produz um campo travado: cada tecla é descartada porque o valor exibido continua vindo do estado, que ninguém atualizou. É o defeito mais comum das entradas controladas, e o React avisa no console.

O fluxo inverso​

Repare no que FiltroAcervo faz: ele não altera nada. Recebe termo e chama onTermoMudar. O componente que declarou o estado é o único que o muda — é a seta da direita da Figura 1.

Esse é o padrão que se repete em toda a árvore: para deixar um descendente mudar um valor, passe para baixo a função que o muda. Por convenção, a prop que recebe uma dessas funções tem nome começado por on, e a função que a implementa tem um nome que diz o que faz.


Parte 6 — Derivado, não guardado​

Nem todo valor que aparece na tela é estado. Antes de escrever useState, passe o valor por três perguntas:

  1. Ele permanece o mesmo com o tempo? Então não é estado — é uma constante.
  2. Ele chega por props? Então não é estado — é do componente de cima.
  3. Dá para calculá-lo a partir de outro estado ou de outra prop? Então definitivamente não é estado.

No painel do acervo, o resultado é curto. Só duas coisas sobrevivem às três perguntas:

ValorVeredito
o acervoprop: vem do componente de servidor e não muda na tela
o texto do filtroestado: nada o determina além do que foi digitado
a lista filtradacalculada a partir do acervo e do texto
a quantidade exibidao comprimento da lista filtrada
o histórico de reservasestado: resultado de cliques anteriores
a sacola atuala última posição do histórico
se o botão de desfazer está ativoverdadeiro quando o histórico tem mais de uma posição

E é assim que eles aparecem no código:

app/components/PainelAcervo.tsx
const [termo, setTermo] = useState("");
const [historico, setHistorico] = useState<number[][]>([[]]);

// --- valores derivados: recalculados a cada renderização, nunca guardados
const reservados = historico[historico.length - 1];
const podeDesfazer = historico.length > 1;

const termoNormalizado = termo.trim().toLowerCase();
const livrosFiltrados = livros.filter(
(livro) =>
livro.titulo.toLowerCase().includes(termoNormalizado) ||
livro.autor.nome.toLowerCase().includes(termoNormalizado),
);

Guardar um valor calculável não é apenas redundante: cria uma segunda verdade sobre o mesmo fato. Um useState para a quantidade de livros filtrados obrigaria a lembrar de atualizá-lo em todo lugar que mexe no filtro, e o primeiro esquecimento produz um contador que discorda da lista logo abaixo dele — sem erro, sem aviso, e difícil de rastrear porque as duas partes da tela parecem corretas isoladamente.

E se o cálculo for caro?

A pergunta é legítima, mas quase sempre prematura. Filtrar algumas dezenas ou centenas de itens é trabalho desprezível diante do que o navegador já faz para desenhar a tela. Se um dia a medição mostrar um custo real, a resposta continua não sendo guardar o resultado em estado. Há ferramentas próprias para isso, e elas não são assunto desta disciplina.


Parte 7 — Onde o estado mora​

Sobreviveu às três perguntas: é estado. Falta decidir em que componente declará-lo, e essa é a decisão de projeto mais importante da aula.

Duas árvores idênticas lado a lado, separadas por uma linha tracejada. À esquerda, sob o título estado declarado no cartão, o painel não guarda a reserva, o componente de ações guarda um valor por cartão e a sacola precisa da lista sem ter como alcançá-la; as duas caixas de baixo estão marcadas em vermelho. À direita, sob o título estado no ancestral comum, o histórico mora no painel e os dois descendentes recebem o que precisam por props. Uma caixa embaixo traz três perguntas: que componentes desenham a partir do valor, qual é o ancestral comum mais próximo e onde o valor deve ser declarado.
A árvore é a mesma nos dois lados. O que muda é a altura em que o valor é declarado — e isso decide se a tela consegue ser coerente.

A Figura 3 mostra os dois desfechos do mesmo problema, e o procedimento que leva do primeiro ao segundo:

  1. Liste todos os componentes que desenham alguma coisa a partir desse valor. No caso da reserva, são dois: o cartão, que muda o rótulo do botão, e a sacola, que mostra a lista e a contagem.
  2. Encontre o ancestral comum mais próximo dos dois. É o PainelAcervo.
  3. Declare o valor ali e deixe-o descer por props.

Se o estado ficasse dentro de AcoesLivro, cada cartão saberia da própria reserva e a sacola não teria como somar seis valores que moram em seis lugares diferentes — dados não sobem. O único caminho seria duplicar a informação, que é o problema da Parte 6 outra vez.

Isso se chama lifting state up: subir o estado até a altura em que ele deixa de ser particular. Vale o mínimo necessário. Estado declarado acima do ancestral comum também custa: obriga a atravessar componentes que não o usam, repassando props que eles só carregam adiante.

Nem todo estado precisa subir​

O mesmo AcoesLivro guarda um estado seu:

app/components/AcoesLivro.tsx
const [detalhesAbertos, setDetalhesAbertos] = useState(false);

A pergunta 1 responde sozinha: ninguém, além daquele cartão, desenha qualquer coisa a partir desse valor. Ele fica onde está. Subi-lo para o painel funcionaria, e seria pior — o painel passaria a guardar um vetor de estados de abertura e a repassá-lo, sem que nada na tela ganhasse com isso.

A regra prática, em uma frase: suba o estado até onde ele é necessário, e nem um nível além.


Parte 8 — Imutabilidade, e o recurso que a justifica​

Com o estado no lugar certo, falta atualizá-lo. Aqui está a regra que mais se decora sem entender: não altere o valor que está no estado; produza outro.

O motivo é mecânico. O React decide se precisa redesenhar comparando o valor novo com o anterior, e para objetos e vetores a comparação é de referência: é o mesmo objeto, ou é outro? Alterar um vetor no lugar mantém a referência. Para o React, nada mudou.

Você querNão useUse
acrescentar ao fimpush[...vetor, item]
remover um itemsplicevetor.filter(...)
trocar um itemvetor[i] = xvetor.map(...)
ordenarsort[...vetor].sort(...)
mudar um campo de um objetoobj.campo = x{ ...obj, campo: x }

A coluna do meio altera o valor existente; a da direita produz um valor novo a partir dele.

Dito assim, é regra decorada. Ela vira necessidade quando a aplicação precisa do valor anterior — e é exatamente por isso que o laboratório tem um botão de desfazer.

app/components/PainelAcervo.tsx
// Cada posição do histórico é uma sacola inteira; a última é a atual.
const [historico, setHistorico] = useState<number[][]>([[]]);
const reservados = historico[historico.length - 1];

function alternarReserva(id: number) {
const novaSacola = reservados.includes(id)
? reservados.filter((reservadoId) => reservadoId !== id)
: [...reservados, id];

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

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

O desfazer cabe em uma linha porque nenhuma sacola anterior foi destruída. Se alternarReserva tivesse feito reservados.push(id), todas as posições do histórico apontariam para o mesmo vetor, e voltar uma posição não voltaria nada: as duas seriam a mesma coisa.

A imutabilidade não é uma formalidade do React: é o que permite que exista um passado para consultar.

Guardar o histórico não é obrigatório

Sem o desfazer, bastaria guardar a sacola atual, e a regra continuaria valendo pelo motivo mecânico do primeiro parágrafo. O histórico está aqui porque é o recurso mais simples que torna a razão visível. No seu projeto, avalie se ele se justifica: memória e código também custam.


Parte 9 — Estado e a fronteira da aula 11​

Falta ligar tudo isso ao que a aula 11 estabeleceu. Estado é uma coisa que existe entre dois momentos — entre um clique e o próximo. Um componente de servidor não tem esses dois momentos: ele é executado, produz o resultado e acaba. Por isso useState só existe do lado do cliente, e por isso o PainelAcervo começa com "use client".

O padrão que vale para o resto do módulo:

ResponsabilidadeOnde
Obter os dadoscomponente de servidor — a página
Decidir o que aparece a partir da interaçãocomponente de cliente — o painel
Formatar e exibirqualquer um dos dois

A página não mudou de lado:

app/livros/page.tsx
export default function Page() {
const livros = listarLivros();

return (
<section>
<h1 className="text-2xl font-semibold">Acervo</h1>
<PainelAcervo livros={livros} />
</section>
);
}

Uma consequência dessa mudança merece atenção, porque contraria a intuição: o LivroCard não tem a diretiva e mesmo assim passou a ser enviado ao navegador. A fronteira segue os imports, como a aula 11 afirmou, e agora quem importa o cartão é o painel, que é de cliente. Nenhuma linha do cartão mudou; mudou quem o usa.

Isso é verificável depois de npm run build, procurando um trecho exclusivo de cada componente nos pacotes enviados ao navegador. O passo 12 do laboratório faz essa conferência.

A rota continua estática

O mapa de rotas do build ainda marca /livros com o círculo de conteúdo estático, mesmo com o painel interativo. Não há contradição: o HTML da primeira tela é gerado durante o build, com o filtro vazio e a sacola vazia, e o JavaScript do painel assume dali em diante — é a hidratação da aula 11. O que o leitor vê antes do primeiro clique não precisa ser calculado a cada requisição.


Erros comuns​

ErroSintomaCorreção
onClick={reservar()} em vez de onClick={reservar}a função roda na renderização; às vezes vira laço infinitopassar a função, ou envolver em () => reservar(id)
useState dentro de if ou de laçoerro sobre ordem dos hooks, ou estado trocado entre sideclarar todos no topo da função
useState em componente de servidorerro dizendo que o hook exige componente de cliente"use client" no menor componente possível
Alterar o vetor do estado com pusha tela não muda, e nada é registrado em lugar nenhumproduzir um vetor novo, como na Parte 8
key={indice} em lista que filtra ou reordenao estado de um item aparece em outro, sem avisousar um identificador estável do registro
Lista sem keyaviso no console e no terminalacrescentar key no elemento mais externo do map
value sem onChangeo campo não aceita digitaçãopassar também o handler que atualiza o estado
Guardar em estado um valor calculávelduas partes da tela discordam entre sicalcular na renderização
Copiar uma prop para dentro de um useStateo valor congela no inicial e ignora atualizações do ancestralusar a prop direto
Ler o estado logo depois de chamar setvem o valor antigousar o valor que você acabou de calcular, ou a forma com função
Componente escrito com inicial minúsculanão renderiza nada, e não há erroinicial maiúscula

Laboratório 12 — O painel do acervo​

O laboratório segue o mesmo domínio-guia do Módulo 2: 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 11, rodando, com as três rotas, o acervo fixo em lib/livros.ts e os dois componentes próprios.

Resultado ao fim deste laboratório: a rota /livros ganha um painel com filtro por título ou autor, contagem, sacola de reserva com desfazer e um detalhe que abre por cartão — tudo sobre o mesmo acervo fixo, com o serviço do Módulo 2 desligado.

O roteiro aplica, na ordem, um método que vale para qualquer tela que você venha a construir:

Passo do métodoOnde aparece
1. Quebrar a interface em componentespassos 3 e 4
2. Construir uma versão estática, sem estadopasso 4
3. Achar a representação mínima do estadopassos 5 e 6
4. Decidir onde cada estado morapassos 7 e 9
5. Ligar o fluxo inversopassos 5, 7 e 8

Passo 1 — Partir do projeto da aula 11​

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

cp -R biblioteca-web biblioteca-web-aula-12
cd biblioteca-web-aula-12
npm install
npm run dev

Abra /livros e confirme que os três cartões aparecem. Se algo falhar aqui, resolva antes de continuar: todos os passos seguintes supõem este ponto.

Passo 2 — Ampliar o acervo​

Três livros são poucos para o que vem depois: com três, o filtro não tem o que esconder e o experimento do passo 11 não se manifesta. Em lib/livros.ts, mantenha os tipos e os três livros existentes, com os mesmos id. Acrescente três autores logo depois dos dois que já estão declarados:

lib/livros.ts
const rosa: Autor = {
id: 3,
nome: "João Guimarães Rosa",
nacionalidade: "Brasileira",
};

const graciliano: Autor = {
id: 4,
nome: "Graciliano Ramos",
nacionalidade: "Brasileira",
};

const carolina: Autor = {
id: 5,
nome: "Carolina Maria de Jesus",
nacionalidade: "Brasileira",
};

Uma editora, depois da record:

lib/livros.ts
const novaFronteira: Editora = { id: 2, nome: "Nova Fronteira" };

E três títulos no vetor livros, depois dos três que já existem:

lib/livros.ts
{
id: 4,
titulo: "Grande Sertão: Veredas",
isbn: "9788520925300",
ano: 1956,
autor: rosa,
editora: novaFronteira,
},
{
id: 5,
titulo: "Vidas Secas",
isbn: "9788501069450",
ano: 1938,
autor: graciliano,
editora: record,
},
{
id: 6,
titulo: "Quarto de Despejo",
isbn: "9788574481296",
ano: 1960,
autor: carolina,
editora: null,
},

Dois autores passam a ter mais de um título, e três livros ficam sem editora. As duas coisas são deliberadas: a primeira dá ao filtro por autor um resultado com mais de um item, e a segunda mantém o tratamento do nulo em uso.

Confira em /livros: seis cartões.

Passo 3 — Abrir espaço no cartão​

O cartão vai precisar exibir botões que ele não conhece. Em vez de dar a ele uma prop para cada botão, dê um espaço. Em app/components/LivroCard.tsx, acrescente o import do tipo e a prop children:

app/components/LivroCard.tsx
import Link from "next/link";
import type { ReactNode } from "react";
import type { Livro } from "@/lib/livros";

export default function LivroCard({
livro,
children,
}: {
livro: Livro;
children?: ReactNode;
}) {

E, dentro do <article>, depois do parágrafo com autor e ano:

app/components/LivroCard.tsx
{children ? <div className="mt-3">{children}</div> : null}

Nada mais muda. O cartão continua sem estado e sem evento, e a tela continua igual — children ainda está vazio.

No seu projeto

Identifique, na sua tela de listagem, o componente que repete um registro. Pergunte se ele deveria conhecer os botões que aparecem dentro dele. Se a resposta for não, é caso de children.

Passo 4 — A versão estática, ainda sem estado​

Este passo constrói a tela inteira sem nenhum useState. É deliberado: a versão estática obriga a decidir quais componentes existem antes de decidir o que muda.

Crie app/components/FiltroAcervo.tsx:

app/components/FiltroAcervo.tsx
export default function FiltroAcervo({
termo,
onTermoMudar,
}: {
termo: string;
onTermoMudar: (novoTermo: string) => void;
}) {
return (
<label className="block">
<span className="text-sm opacity-80">Filtrar por título ou autor</span>
<input
type="search"
value={termo}
onChange={(evento) => onTermoMudar(evento.target.value)}
placeholder="machado, estrela, vidas..."
className="mt-1 w-full rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm dark:border-white/20"
/>
</label>
);
}

E app/components/SacolaReserva.tsx:

app/components/SacolaReserva.tsx
import type { Livro } from "@/lib/livros";

export default function SacolaReserva({
livros,
podeDesfazer,
onDesfazer,
}: {
livros: Livro[];
podeDesfazer: boolean;
onDesfazer: () => void;
}) {
return (
<aside className="rounded-lg border border-black/10 p-4 dark:border-white/15">
<div className="flex items-baseline justify-between gap-4">
<h2 className="font-medium">Sacola de reserva ({livros.length})</h2>

<button
type="button"
onClick={onDesfazer}
disabled={!podeDesfazer}
className="rounded-md border border-black/15 px-3 py-1 text-sm hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/20 dark:hover:bg-white/10"
>
Desfazer
</button>
</div>

{livros.length === 0 ? (
<p className="mt-2 text-sm opacity-70">Nenhum título reservado.</p>
) : (
<ul className="mt-2 list-inside list-disc text-sm">
{livros.map((livro) => (
<li key={livro.id}>{livro.titulo}</li>
))}
</ul>
)}
</aside>
);
}

Repare no que os dois não têm: useState, e a diretiva "use client". Nenhum dos dois guarda nada — os dois recebem tudo e avisam por funções. A diretiva virá uma vez só, no passo 5, no componente que os importa.

No seu projeto

Faça o mesmo exercício de recorte na sua tela: liste os componentes olhando para o que se repete e para o que tem responsabilidade única. Construa todos sem estado primeiro. Se a versão estática já parecer difícil de montar, o recorte está errado, e é mais barato perceber isso agora.

Passo 5 — O primeiro estado, e a fronteira​

Crie app/components/PainelAcervo.tsx. Por enquanto, só com o filtro:

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

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

export default function PainelAcervo({ livros }: { livros: Livro[] }) {
const [termo, setTermo] = useState("");

return (
<div className="mt-4 space-y-4">
<FiltroAcervo termo={termo} onTermoMudar={setTermo} />

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

E adapte a página do acervo, que continua no servidor:

app/livros/page.tsx
import type { Metadata } from "next";
import PainelAcervo from "@/app/components/PainelAcervo";
import { listarLivros } from "@/lib/livros";

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

export default function Page() {
const livros = listarLivros();

return (
<section>
<h1 className="text-2xl font-semibold">Acervo</h1>
<PainelAcervo livros={livros} />
</section>
);
}

Digite no campo: o texto aparece. O setTermo foi passado a um componente que não declarou o estado, e é esse componente que dispara a atualização — o fluxo inverso da Parte 5, em funcionamento. A lista ainda não reage, porque ninguém a filtrou.

Passo 6 — Calcular, em vez de guardar​

Ainda em PainelAcervo, entre o useState e o return:

app/components/PainelAcervo.tsx
const termoNormalizado = termo.trim().toLowerCase();
const livrosFiltrados = livros.filter(
(livro) =>
livro.titulo.toLowerCase().includes(termoNormalizado) ||
livro.autor.nome.toLowerCase().includes(termoNormalizado),
);

No return, substitua o <ul> inteiro pela contagem, pelo caso vazio e pela lista já filtrada:

app/components/PainelAcervo.tsx
<p className="text-sm opacity-80">
{livrosFiltrados.length} de {livros.length} títulos exibidos.
</p>

{livrosFiltrados.length === 0 ? (
<p className="text-sm">Nenhum título corresponde ao filtro.</p>
) : (
<ul className="space-y-3">
{livrosFiltrados.map((livro) => (
<li key={livro.id}>
<LivroCard livro={livro} />
</li>
))}
</ul>
)}

Confira: machado deixa dois cartões, clarice deixa um, e zzz não deixa nenhum, com a mensagem no lugar da lista. Sem esse ramo, o zzz renderizaria um <ul> vazio — a tela não quebra, mas não diz nada a quem está olhando.

Nenhum dos dois valores novos virou useState: os dois são calculados a cada renderização, a partir do único estado que existe.

No seu projeto

Passe cada valor da sua tela pelas três perguntas da Parte 6 e escreva a lista de veredictos antes de codificar. Feito no papel, esse exercício costuma eliminar useState que pareciam necessários.

Passo 7 — O estado que precisa subir​

Agora a reserva. Dois lugares da tela dependem dela — o botão do cartão e a sacola —, então ela não pode morar no cartão. Acrescente o segundo estado e o que se calcula a partir dele:

app/components/PainelAcervo.tsx
// Cada posição do histórico é uma sacola inteira; a última é a atual.
const [historico, setHistorico] = useState<number[][]>([[]]);

const reservados = historico[historico.length - 1];
const podeDesfazer = historico.length > 1;
const livrosReservados = livros.filter((livro) =>
reservados.includes(livro.id),
);

function alternarReserva(id: number) {
// Nenhuma das duas expressões altera `reservados`: as duas produzem um
// vetor novo. É isso que mantém a versão anterior intacta no histórico.
const novaSacola = reservados.includes(id)
? reservados.filter((reservadoId) => reservadoId !== id)
: [...reservados, id];

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

Guardar o histórico, e não apenas a sacola atual, é a decisão que o passo 8 vai cobrar. Por ora, basta saber que reservados deixou de ser estado: virou a última posição de um estado maior.

Três avisos aparecem aqui, e somem nos dois passos seguintes

npm run lint passa a apontar podeDesfazer, livrosReservados e alternarReserva como declarados e não usados. São avisos, não erros: os três são ligados à tela nos passos 8 e 9, e o apontamento desaparece sozinho. Se ainda estiver lá ao fim do passo 9, aí sim há algo esquecido.

Passo 8 — O desfazer​

Uma função e uma linha no return. A função:

app/components/PainelAcervo.tsx
function desfazer() {
setHistorico(historico.slice(0, -1));
}

E a sacola, no return, logo depois do filtro:

app/components/PainelAcervo.tsx
<SacolaReserva
livros={livrosReservados}
podeDesfazer={podeDesfazer}
onDesfazer={desfazer}
/>

Lembre do import de SacolaReserva no topo do arquivo.

O desfazer coube em uma linha, e coube porque nenhuma sacola anterior foi destruída em alternarReserva. É a razão inteira da regra da Parte 8 — e o passo 10 mostra o que acontece sem ela.

Passo 9 — Estado que fica onde está​

Falta o que aciona a reserva. Crie app/components/AcoesLivro.tsx:

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

export default function AcoesLivro({
livro,
reservado,
onAlternarReserva,
}: {
livro: Livro;
reservado: boolean;
onAlternarReserva: (id: number) => void;
}) {
const [detalhesAbertos, setDetalhesAbertos] = useState(false);

return (
<div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setDetalhesAbertos(!detalhesAbertos)}
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"
>
{detalhesAbertos ? "Ocultar detalhes" : "Detalhes"}
</button>

<button
type="button"
onClick={() => onAlternarReserva(livro.id)}
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"
>
{reservado ? "Remover da sacola" : "Reservar"}
</button>
</div>

{detalhesAbertos ? (
<dl className="mt-3 grid grid-cols-[6rem_1fr] gap-y-1 text-sm">
<dt className="opacity-70">ISBN</dt>
<dd className="font-mono">{livro.isbn}</dd>

<dt className="opacity-70">Editora</dt>
<dd>{livro.editora ? livro.editora.nome : "não informada"}</dd>
</dl>
) : null}
</div>
);
}

Os dois botões deste arquivo respondem de formas diferentes, e a diferença é o assunto da Parte 7: detalhesAbertos fica aqui porque ninguém mais desenha a partir dele; a reserva não fica, porque a sacola também depende dela.

Coloque o componente dentro do cartão, no return do painel:

app/components/PainelAcervo.tsx
{livrosFiltrados.map((livro) => (
// A chave é o id do livro, não a posição na lista filtrada: a
// posição muda a cada tecla digitada no filtro.
<li key={livro.id}>
<LivroCard livro={livro}>
<AcoesLivro
livro={livro}
reservado={reservados.includes(livro.id)}
onAlternarReserva={alternarReserva}
/>
</LivroCard>
</li>
))}

Confira a tela inteira: reserve dois títulos, veja a sacola e a contagem, desfaça duas vezes até o botão desabilitar, abra os detalhes de um cartão e confirme que só aquele abriu.

Captura do painel do acervo. O campo de filtro está preenchido com a palavra machado. Abaixo dele, a sacola de reserva marca dois títulos e lista Dom Casmurro e Vidas Secas, com o botão Desfazer ativo. Em seguida, a contagem indica dois de seis títulos exibidos, e aparecem os dois cartões de Machado de Assis: o de Dom Casmurro está com os detalhes abertos, mostrando o ISBN e a editora não informada, e o de Memórias Póstumas está fechado, com os botões Detalhes e Reservar.
O painel ao fim do laboratório. Repare que a sacola guarda um título que o filtro não está exibindo: a reserva e o filtro são estados independentes, e nenhum dos dois foi guardado duas vezes.

A Figura 4 mostra o resultado esperado. Vale comparar a sua tela com ela antes dos dois experimentos, porque os dois supõem que tudo funciona.

No seu projeto

Procure na sua tela um par equivalente: um valor que só interessa a um item e outro que precisa ser visto em dois lugares. Se o segundo não existir, a tela não exige estado compartilhado, e o recorte fica mais simples.

Passo 10 — Experimento: alterar o vetor que está no estado​

Este passo existe para dar errado, e o defeito não aparece em lugar nenhum além da tela. Em PainelAcervo, substitua o corpo de alternarReserva:

// Somente leitura: alteração temporária, para observar o defeito.
function alternarReserva(id: number) {
reservados.push(id);
setHistorico(historico);
}

Salve e clique em "Reservar" em qualquer cartão. Nada acontece. A sacola continua em zero, o rótulo do botão continua "Reservar", o console do navegador está limpo, o terminal está limpo e o TypeScript não tem do que reclamar — push é um método legítimo de um vetor.

Agora faça o teste que revela o que aconteceu de verdade: digite uma letra no campo de filtro. Isso muda outro estado e força uma nova renderização — e a reserva aparece.

Duas capturas da mesma tela, empilhadas. Na de cima, logo depois de um clique em Reservar: o campo de filtro está vazio, a sacola marca zero, a mensagem diz que nenhum título foi reservado e o botão do cartão de Dom Casmurro ainda diz Reservar. Na de baixo, depois de digitar a letra d no filtro: a sacola marca um e lista Dom Casmurro, e o botão do mesmo cartão passou a dizer Remover da sacola. Nas duas capturas o botão Desfazer aparece esmaecido.
Acima, logo depois do clique: a tela não mudou. Abaixo, depois de uma única tecla no filtro: a reserva aparece. Ela já existia nas duas — o que faltava era o React ter como perceber.

A Figura 5 registra os dois momentos, e o valor já tinha mudado nos dois. O que faltou foi o React saber disso: a referência do vetor continuou a mesma, e setHistorico(historico) entregou essa mesma referência. Não havia o que comparar.

Repare no botão "Desfazer", esmaecido nas duas capturas. O histórico nunca cresceu, e a única sacola que existia foi alterada no lugar: o passado não ficou inconsistente — deixou de existir.

Restaure a versão do passo 7 antes de seguir.

Passo 11 — Experimento: a chave pela posição​

O segundo erro deliberado paga a promessa da aula 11. Na lista do painel, troque a chave pela posição:

// Somente leitura: alteração temporária, para observar o defeito.
{livrosFiltrados.map((livro, indice) => (
<li key={indice}>

Siga esta sequência exata:

  1. Sem filtro, clique em "Detalhes" no primeiro cartão, Dom Casmurro. O ISBN aparece: 9788525406958.
  2. Digite clarice no filtro. Sobra um cartão, A Hora da Estrela — e ele aparece com os detalhes abertos, mostrando o ISBN 9788520925829. Ninguém abriu esse cartão.
  3. Limpe o filtro. O detalhe volta para Dom Casmurro.
Duas capturas da mesma tela, empilhadas. Na de cima, sem filtro, o cartão de Dom Casmurro está com os detalhes abertos, mostrando o ISBN terminado em 6958 e a editora não informada. Na de baixo, com a palavra clarice no filtro, sobra um único cartão, o de A Hora da Estrela, e ele aparece com os detalhes abertos, mostrando o ISBN terminado em 5829 e a editora Record.
O detalhe foi aberto em Dom Casmurro e reapareceu em A Hora da Estrela, que ninguém abriu. O estado ficou preso à posição na lista, e não ao livro.

A Figura 6 mostra o resultado, e nenhum aviso acompanha o defeito. O que aconteceu é o da Parte 3: com a posição como chave, o React entendeu que o item da posição 0 continuava sendo o mesmo item e preservou nele o estado que pertencia a outro livro. O detalhesAbertos seguiu a posição, e não o livro.

Este defeito é mais perigoso do que parece, por dois motivos. O primeiro é que ele não aparece em lista pequena e imóvel — só se manifesta quando a lista filtra, ordena ou perde itens do meio, que costuma ser depois da entrega. O segundo é que o estado preservado pode não ser visual: um campo de texto já preenchido, uma caixa marcada, uma seleção. Com key={livro.id} o problema não existe, e é por isso que a aula 11 já usava a chave certa antes de poder explicá-la.

Restaure key={livro.id} antes de seguir.

Passo 12 — Conferir o que atravessou a fronteira​

Pare o npm run dev e gere a versão de produção:

npm run build

Agora procure, nos pacotes que o navegador recebe, um trecho exclusivo de cada componente:

grep -rl "mt-1 text-sm opacity-80" .next/static/chunks # LivroCard
grep -rl "grid-cols-\[8rem_1fr\]" .next/static/chunks # página de detalhe
grep -rl "Copiar ISBN" .next/static/chunks # BotaoCopiarIsbn

O primeiro e o terceiro encontram um arquivo. O segundo não encontra nenhum.

A leitura é a da Parte 9. O BotaoCopiarIsbn viaja porque tem a diretiva. A página de detalhe não viaja porque é componente de servidor. E o LivroCard viaja sem ter diretiva nenhuma, porque quem o importa agora é o painel. Nenhuma linha do cartão mudou entre a aula 11 e a aula 12; mudou o caminho pelo qual ele entra na árvore.

Abra também as ferramentas de desenvolvedor do navegador, na aba de rede, e recarregue /livros: o HTML que chega já contém os seis títulos, antes de qualquer JavaScript executar.

Passo 13 — Fechar o ciclo​

npm run lint
npm run build

Confira o mapa de rotas: /livros continua marcada como estática, apesar de todo o painel. A explicação está na última nota da Parte 9 — e a pergunta que ela levanta, sobre quando uma rota deixa de poder ser estática, é assunto da aula 13.

Critérios de conclusão​

O laboratório está completo quando:

  • /livros mostra seis cartões e "6 de 6 títulos exibidos";
  • filtrar por machado deixa dois cartões, e por zzz mostra a mensagem de lista vazia;
  • reservar dois títulos leva a sacola a "(2)", e o botão do cartão reservado passa a "Remover da sacola";
  • desfazer duas vezes esvazia a sacola e desabilita o botão;
  • abrir "Detalhes" em um cartão não abre os outros, e filtrar não move o detalhe de livro;
  • nenhum useState guarda um valor que poderia ser calculado;
  • só PainelAcervo tem a diretiva "use client" entre os componentes do painel;
  • você reproduziu os passos 10 e 11, observou os dois defeitos e desfez as alterações;
  • o grep do passo 12 encontra o LivroCard nos pacotes de cliente e não encontra a página de detalhe;
  • npm run lint e npm run build terminam sem apontamentos;
  • o mesmo painel existe no seu projeto, com o seu domínio.

Fechamento​

O painel está pronto, apoiado em quatro decisões que valem para o resto do módulo — e nenhuma delas é sobre sintaxe.

A primeira foi o que é estado. Três perguntas bastaram para reduzir uma tela inteira a dois valores guardados; tudo o mais é calculado. Estado a mais não é código a mais: é uma segunda versão de um fato que já existia em outro lugar.

A segunda foi onde ele mora. A altura certa é o ancestral comum mais próximo de quem desenha a partir dele — nem abaixo, onde a informação fica presa, nem acima, onde ela atravessa componentes que não a usam.

A terceira foi como atualizá-lo. Produzir um valor novo em vez de alterar o existente parece cerimônia até aparecer um recurso que precisa do valor anterior. O desfazer coube em uma linha por causa disso.

A quarta é a que o Módulo 3 acrescenta ao React: de que lado da fronteira tudo isso acontece. Estado é coisa de cliente, dados são coisa de servidor, e o LivroCard mostrou que a fronteira se move sozinha quando o grafo de imports muda.

A aula 13 volta ao framework e liga o cliente à API: o que acontece enquanto a tela ainda não está pronta e o que decide se uma rota é gerada no build ou a cada requisição.


Exercícios (checkpoints)​

  1. Classifique cada valor de uma tela de empréstimos como constante, prop, estado ou valor derivado, justificando com uma das três perguntas da Parte 6: (a) o rótulo "Empréstimos"; (b) a lista vinda do servidor; (c) o texto digitado em um campo de busca; (d) a quantidade de empréstimos atrasados; (e) qual linha está selecionada.

  2. Decida onde declarar o estado de um seletor de ordenação que afeta a lista de livros e um rótulo no topo da tela, aplicando os três passos da Parte 7 e nomeando o componente escolhido.

  3. Explique por que setCliques(cliques + 1) três vezes no mesmo handler soma 1, e indique a situação em que a forma com função é necessária.

  4. Diagnostique: um colega relata que clicar em "Reservar" não faz nada, mas que a reserva aparece assim que ele digita no filtro. Aponte a causa provável e explique por que nenhuma ferramenta acusou o erro.

  5. Preveja o que acontece, no painel com key={indice}, se o leitor marcar uma caixa de seleção no terceiro cartão e em seguida remover o primeiro livro do vetor. Compare com o comportamento usando o id.

  6. Implemente a normalização de acentos no filtro, de modo que memorias encontre Memórias Póstumas. Indique onde a mudança entra e explique por que ela não exige nenhum estado novo.

  7. Implemente um seletor de ordenação (por título ou por ano) que conviva com o filtro. Descreva quantos estados novos você precisou e por quê.

  8. Refatore o desfazer para guardar apenas a sacola atual, sem histórico. Explique o que se perde e argumente se a troca compensa em uma tela em que não existe o botão de desfazer.


Referências​

Principais​

Aprofundamento​