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 depois | Onde |
|---|---|
Busca de dados na API, useEffect, layouts aninhados, estados de carregamento e erro e estratégias de renderização | Aula 13 — Dados da API |
| Formulários, envio de dados, paginação e cache | Aula 14 — Consumo de serviços no frontend |
| Login, sessão, rotas protegidas 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:
- 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.
| Ferramenta | Versão | Observação |
|---|---|---|
| Node.js | 22 LTS ou superior | a mesma da aula 11 |
| Next.js | 16.x | exemplos verificados com 16.3.4 |
| React | 19.2.x | o roteiro foi executado de ponta a ponta com 19.2.8; confira o package.json |
| TypeScript | 5.x | mínimo 5.1 |
| Navegador | Chrome, Edge, Firefox ou Safari recentes | as ferramentas de desenvolvedor são usadas no passo 12 |
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 11 | Onde é 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:
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.
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.
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.
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.
| Prop | Estado | |
|---|---|---|
| Quem define o valor | o componente de cima | o próprio componente |
| Pode ser alterado por quem recebe | não | sim, pela função de atualização |
| Sobrevive a uma nova renderização | é recebido de novo | sim, o React o preserva |
| Muda quando | o ancestral renderiza de novo | algué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
iddo 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:
- A variável não sobrevive. Cada renderização executa a função de novo, e
cliquesvolta a zero. - 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.
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
returnantecipado e nunca dentro deif, laço,tryou 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:
| Escrito | O 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:
<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.
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:
- Ele permanece o mesmo com o tempo? Então não é estado — é uma constante.
- Ele chega por props? Então não é estado — é do componente de cima.
- 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:
| Valor | Veredito |
|---|---|
| o acervo | prop: vem do componente de servidor e não muda na tela |
| o texto do filtro | estado: nada o determina além do que foi digitado |
| a lista filtrada | calculada a partir do acervo e do texto |
| a quantidade exibida | o comprimento da lista filtrada |
| o histórico de reservas | estado: resultado de cliques anteriores |
| a sacola atual | a última posição do histórico |
| se o botão de desfazer está ativo | verdadeiro quando o histórico tem mais de uma posição |
E é assim que eles aparecem no código:
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.
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.
A Figura 3 mostra os dois desfechos do mesmo problema, e o procedimento que leva do primeiro ao segundo:
- 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.
- Encontre o ancestral comum mais próximo dos dois. É o
PainelAcervo. - 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:
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ê quer | Não use | Use |
|---|---|---|
| acrescentar ao fim | push | [...vetor, item] |
| remover um item | splice | vetor.filter(...) |
| trocar um item | vetor[i] = x | vetor.map(...) |
| ordenar | sort | [...vetor].sort(...) |
| mudar um campo de um objeto | obj.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.
// 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.
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:
| Responsabilidade | Onde |
|---|---|
| Obter os dados | componente de servidor — a página |
| Decidir o que aparece a partir da interação | componente de cliente — o painel |
| Formatar e exibir | qualquer um dos dois |
A página não mudou de lado:
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.
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
| Erro | Sintoma | Correção |
|---|---|---|
onClick={reservar()} em vez de onClick={reservar} | a função roda na renderização; às vezes vira laço infinito | passar a função, ou envolver em () => reservar(id) |
useState dentro de if ou de laço | erro sobre ordem dos hooks, ou estado trocado entre si | declarar todos no topo da função |
useState em componente de servidor | erro dizendo que o hook exige componente de cliente | "use client" no menor componente possível |
Alterar o vetor do estado com push | a tela não muda, e nada é registrado em lugar nenhum | produzir um vetor novo, como na Parte 8 |
key={indice} em lista que filtra ou reordena | o estado de um item aparece em outro, sem aviso | usar um identificador estável do registro |
Lista sem key | aviso no console e no terminal | acrescentar key no elemento mais externo do map |
value sem onChange | o campo não aceita digitação | passar também o handler que atualiza o estado |
| Guardar em estado um valor calculável | duas partes da tela discordam entre si | calcular na renderização |
Copiar uma prop para dentro de um useState | o valor congela no inicial e ignora atualizações do ancestral | usar a prop direto |
Ler o estado logo depois de chamar set | vem o valor antigo | usar o valor que você acabou de calcular, ou a forma com função |
| Componente escrito com inicial minúscula | não renderiza nada, e não há erro | inicial 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étodo | Onde aparece |
|---|---|
| 1. Quebrar a interface em componentes | passos 3 e 4 |
| 2. Construir uma versão estática, sem estado | passo 4 |
| 3. Achar a representação mínima do estado | passos 5 e 6 |
| 4. Decidir onde cada estado mora | passos 7 e 9 |
| 5. Ligar o fluxo inverso | passos 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:
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:
const novaFronteira: Editora = { id: 2, nome: "Nova Fronteira" };
E três títulos no vetor livros, depois dos três que já existem:
{
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:
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:
{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.
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:
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:
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.
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:
"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:
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:
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:
<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.
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:
// 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.
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:
function desfazer() {
setHistorico(historico.slice(0, -1));
}
E a sacola, no return, logo depois do filtro:
<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:
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:
{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.

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.
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.

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:
- Sem filtro, clique em "Detalhes" no primeiro cartão, Dom Casmurro.
O ISBN aparece:
9788525406958. - Digite
clariceno filtro. Sobra um cartão, A Hora da Estrela — e ele aparece com os detalhes abertos, mostrando o ISBN9788520925829. Ninguém abriu esse cartão. - Limpe o filtro. O detalhe volta para Dom Casmurro.

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:
-
/livrosmostra seis cartões e "6 de 6 títulos exibidos"; - filtrar por
machadodeixa dois cartões, e porzzzmostra 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
useStateguarda um valor que poderia ser calculado; - só
PainelAcervotem 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
grepdo passo 12 encontra oLivroCardnos pacotes de cliente e não encontra a página de detalhe; -
npm run lintenpm run buildterminam 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)
-
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.
-
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.
-
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. -
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.
-
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 oid. -
Implemente a normalização de acentos no filtro, de modo que
memoriasencontre Memórias Póstumas. Indique onde a mudança entra e explique por que ela não exige nenhum estado novo. -
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ê.
-
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
- React — Describing the UI — componentes, JSX, props, listas e chaves
- React — Adding Interactivity — estado, eventos e o ciclo de renderização
- React — Managing State — estrutura do estado, lifting state up e estado derivado
- React — Thinking in React — o método de cinco passos aplicado no laboratório
- Next.js — Server and Client Components — onde o estado pode existir
Aprofundamento
- React — Tutorial: tic-tac-toe — a mesma progressão em outro domínio, com o histórico como recurso condutor
- React — State as a Snapshot — por que o valor do estado é fixo dentro de uma renderização
- React — Updating Arrays in State — a tabela completa do que altera e do que produz um valor novo
- React — Preserving and Resetting State — o mecanismo por trás da chave de lista
- React — Rules of Hooks — por que a ordem das chamadas importa
- React — You Might Not Need an Effect — leitura de apoio para a aula 13
- React — useState — a referência da API