Você passou cinco módulos dominando JavaScript — assincronismo, APIs, Node.js, qualidade de código. Agora chegamos ao React — a biblioteca que transformou a forma como construímos interfaces web e que hoje é a habilidade front-end mais demandada do mercado.
React não é um framework completo como Angular. É uma biblioteca focada em uma coisa só: construir interfaces a partir de componentes. Simples na ideia, poderoso na prática.
O problema que o React resolve
Antes do React, interfaces dinâmicas eram construídas manipulando o DOM manualmente:
// ── Jeito antigo — jQuery / DOM puro ──────────────
let contador = 0;
function incrementar() {
contador++;
document.querySelector('#valor').textContent = contador;
document.querySelector('#btn-decrement').disabled = contador <= 0;
if (contador > 10) {
document.querySelector('#aviso').style.display = 'block';
document.querySelector('#aviso').textContent = 'Limite atingido!';
} else {
document.querySelector('#aviso').style.display = 'none';
}
}
// Para cada mudança de estado:
// 1. Calcular o que mudou
// 2. Encontrar o elemento no DOM
// 3. Atualizar manualmente
// 4. Lembrar de atualizar TUDO que depende daquele estado
// 5. Não esquecer nenhum caso especial
// ── React — descrevendo o resultado desejado ───────
function Contador() {
const [contador, setContador] = useState(0);
return (
<div>
<p>Valor: {contador}</p>
<button onClick={() => setContador(c => c + 1)}>+</button>
<button onClick={() => setContador(c => c - 1)} disabled={contador <= 0}>-</button>
{contador > 10 && <p>Limite atingido!</p>}
</div>
);
}
// React cuida de atualizar o DOM automaticamente
// Você apenas descreve como a UI deve parecer dado um estado
A diferença fundamental: com React você descreve o que deve aparecer, não como atualizar o DOM.
Instalando e criando um projeto
# Vite — a forma moderna e rápida (recomendado)
npm create vite@latest meu-app -- --template react
cd meu-app
npm install
npm run dev
# O Create React App foi DESCONTINUADO pela própria equipe do React em 2025 e
# saiu da documentação oficial. Projeto novo não deve usar. Se você encontrar
# um projeto antigo com ele, a migração natural é para o Vite.
# Para app que precisa de renderização no servidor, a recomendação oficial
# hoje é um framework: Next.js ou React Router (antigo Remix).
A estrutura gerada com Vite:
meu-app/
├── public/
│ └── vite.svg
├── src/
│ ├── assets/
│ ├── App.css
│ ├── App.jsx
│ ├── index.css
│ └── main.jsx ← ponto de entrada
├── index.html
├── package.json
└── vite.config.js
// src/main.jsx — ponto de entrada
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
O vite.config.js e o recarregamento automático
Ao salvar um arquivo, a tela atualiza sozinha e sem perder o estado — o campo continua preenchido, o contador continua no número em que estava. Isso é o Fast Refresh, e ele já vem ligado com o @vitejs/plugin-react. Não é recarregamento de página: o Vite troca só o módulo alterado, pelo mecanismo chamado HMR.
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 3003,
open: true, // abre o navegador ao subir
watch: {
// ⚠️ Obrigatório no WSL quando o projeto está numa pasta do Windows
// (/mnt/c, /mnt/d) e em Docker com volume montado do host. Nesses casos
// o inotify não atravessa a fronteira entre os dois sistemas de arquivos:
// você salva, nada acontece, e parece que o HMR quebrou.
usePolling: true,
interval: 300, // de quanto em quanto tempo conferir, em ms
},
proxy: {
// Tudo que começar com /api é repassado para a API local. Assim o
// navegador enxerga uma origem só e o CORS deixa de existir em
// desenvolvimento — e o código chama fetch('/api/produtos'), sem
// URL absoluta nem variável de ambiente no caminho.
'/api': {
target: 'http://localhost:5003',
changeOrigin: true, // reescreve o header Host para o do destino
// rewrite: (p) => p.replace(/^\/api/, ''), // se a API não usa o prefixo
},
},
},
});
Sobre o usePolling, vale a mesma ressalva feita no artigo de NPM a respeito do nodemon -L: consultar os arquivos de tempo em tempo custa CPU, e num projeto grande isso aparece. Quando for possível, manter o código dentro do sistema de arquivos do Linux resolve o problema pela raiz e ainda acelera tudo o mais. Se preferir não versionar a opção, a variável de ambiente CHOKIDAR_USEPOLLING=true produz o mesmo efeito.
E há um detalhe do Fast Refresh que costuma confundir: ele só preserva o estado se o arquivo exportar apenas componentes. Um arquivo que exporta o componente e também uma constante ou uma função auxiliar faz o Vite cair no recarregamento completo da página a cada save — o estado se perde e a impressão é de que o HMR não funciona direito. A regra é um componente por arquivo, com o que for auxiliar em outro módulo; a regra react-refresh/only-export-components, do eslint-plugin-react-refresh, avisa quando isso acontece.
JSX — JavaScript + XML
JSX é uma extensão de sintaxe que permite escrever HTML dentro do JavaScript. O Babel (ou Vite) o transforma em chamadas JavaScript:
// JSX que você escreve
const elemento = <h1 className="titulo">Olá, {nome}!</h1>;
// JavaScript que é gerado
const elemento = React.createElement(
'h1',
{ className: 'titulo' },
'Olá, ',
nome,
'!'
);
Regras importantes do JSX:
// ✅ 1. Sempre um elemento raiz (ou Fragment)
return (
<div>
<h1>Título</h1>
<p>Parágrafo</p>
</div>
);
// ✅ Fragment — sem elemento extra no DOM
return (
<>
<h1>Título</h1>
<p>Parágrafo</p>
</>
);
// ✅ 2. Expressões JavaScript entre chaves
const nome = 'Ana';
return <p>Olá, {nome}!</p>;
return <p>Resultado: {2 + 2}</p>;
return <p>{condicao ? 'Sim' : 'Não'}</p>;
// ✅ 3. className em vez de class
return <div className="container active"></div>;
// ✅ 4. htmlFor em vez de for
return <label htmlFor="email">Email:</label>;
// ✅ 5. Estilos como objeto JavaScript
return <div style={{ color: 'red', fontSize: '16px' }}></div>;
// ✅ 6. Tags auto-fechantes
return <input type="text" />;
return <img src="foto.jpg" alt="foto" />;
return <br />;
// ✅ 7. Comentários dentro do JSX
return (
<div>
{/* Isso é um comentário JSX */}
<p>Conteúdo</p>
</div>
);
Componentes — os blocos de construção
Um componente React é uma função que recebe props e retorna JSX:
// Componente simples
function Saudacao() {
return <h1>Olá, mundo!</h1>;
}
// Componente com props
function CartaoUsuario({ nome, email, avatar, ativo }) {
return (
<div className={`cartao ${ativo ? 'ativo' : 'inativo'}`}>
<img src={avatar} alt={nome} />
<h2>{nome}</h2>
<p>{email}</p>
<span>{ativo ? '✅ Ativo' : '❌ Inativo'}</span>
</div>
);
}
// Usando o componente
function App() {
return (
<div>
<CartaoUsuario
nome="Ana Paula"
email="ana@email.com"
avatar="https://api.dicebear.com/7.x/avataaars/svg?seed=Ana"
ativo={true}
/>
<CartaoUsuario
nome="Carlos Silva"
email="carlos@email.com"
avatar="https://api.dicebear.com/7.x/avataaars/svg?seed=Carlos"
ativo={false}
/>
</div>
);
}
Props — passando dados para componentes
// Props básicas
function Botao({ texto, tipo = 'button', desabilitado = false, onClick }) {
return (
<button
type={tipo}
disabled={desabilitado}
onClick={onClick}
className="btn"
>
{texto}
</button>
);
}
// Props com children — conteúdo entre as tags
function Card({ titulo, children, rodape }) {
return (
<div className="card">
<div className="card-header">
<h3>{titulo}</h3>
</div>
<div className="card-body">
{children}
</div>
{rodape && (
<div className="card-footer">
{rodape}
</div>
)}
</div>
);
}
// Usando children
function App() {
return (
<Card
titulo="Informações do usuário"
rodape={<small>Última atualização: hoje</small>}
>
<p>Nome: Ana Paula</p>
<p>Email: ana@email.com</p>
</Card>
);
}
// Spread de props — útil para passar muitas props
function EntradaTexto({ label, erro, ...inputProps }) {
return (
<div>
<label>{label}</label>
<input {...inputProps} /> {/* passa type, value, onChange, etc. */}
{erro && <span className="erro">{erro}</span>}
</div>
);
}
<EntradaTexto
label="Email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="seu@email.com"
erro={erroEmail}
/>
useState — gerenciando estado
import { useState } from 'react';
// Estado simples
function Contador() {
const [contador, setContador] = useState(0);
return (
<div>
<p>Contagem: {contador}</p>
<button onClick={() => setContador(contador + 1)}>+1</button>
<button onClick={() => setContador(0)}>Resetar</button>
</div>
);
}
// Estado com objeto
function Formulario() {
const [formulario, setFormulario] = useState({
nome: '',
email: '',
senha: '',
});
// Atualiza apenas o campo alterado — spread + campo dinâmico
function handleChange(e) {
const { name, value } = e.target;
setFormulario(prev => ({ ...prev, [name]: value }));
}
return (
<form>
<input name="nome" value={formulario.nome} onChange={handleChange} />
<input name="email" value={formulario.email} onChange={handleChange} />
<input name="senha" type="password" value={formulario.senha} onChange={handleChange} />
</form>
);
}
// Estado com array
function ListaTarefas() {
const [tarefas, setTarefas] = useState([
{ id: 1, texto: 'Estudar React', feita: false },
{ id: 2, texto: 'Fazer exercícios', feita: true },
]);
function adicionarTarefa(texto) {
// NUNCA mutate o estado diretamente — sempre cria novo array
setTarefas(prev => [
...prev,
{ id: Date.now(), texto, feita: false },
]);
}
function alternarTarefa(id) {
setTarefas(prev =>
prev.map(t => t.id === id ? { ...t, feita: !t.feita } : t)
);
}
function removerTarefa(id) {
setTarefas(prev => prev.filter(t => t.id !== id));
}
return (
<ul>
{tarefas.map(tarefa => (
<li key={tarefa.id}>
<input
type="checkbox"
checked={tarefa.feita}
onChange={() => alternarTarefa(tarefa.id)}
/>
<span style={{ textDecoration: tarefa.feita ? 'line-through' : 'none' }}>
{tarefa.texto}
</span>
<button onClick={() => removerTarefa(tarefa.id)}>🗑</button>
</li>
))}
</ul>
);
}
A prop key — por que é obrigatória em listas
// ❌ Sem key — React não sabe o que mudou
{itens.map(item => <Item item={item} />)}
// ❌ Key com índice — problemático quando a ordem muda
{itens.map((item, i) => <Item key={i} item={item} />)}
// ✅ Key com ID único e estável
{itens.map(item => <Item key={item.id} item={item} />)}
// A key ajuda o React a identificar quais itens
// foram adicionados, removidos ou reordenados
// Sem key, o React avisa no console e cai no índice como padrão — que é
// justamente o caso problemático acima: ao reordenar ou remover um item do
// meio, ele associa o estado ao elemento errado. O que se perde não é
// desempenho, é identidade: texto digitado e checkbox marcado trocam de linha.
useEffect — efeitos colaterais
import { useState, useEffect } from 'react';
// Buscar dados ao montar o componente
function ListaUsuarios() {
const [usuarios, setUsuarios] = useState([]);
const [carregando, setCarregando] = useState(true);
const [erro, setErro] = useState(null);
useEffect(() => {
// Executa após a renderização
let cancelado = false; // evita atualizar estado após desmontagem
async function buscar() {
try {
setCarregando(true);
const res = await fetch('https://jsonplaceholder.typicode.com/users');
const dados = await res.json();
if (!cancelado) setUsuarios(dados);
} catch (e) {
if (!cancelado) setErro(e.message);
} finally {
if (!cancelado) setCarregando(false);
}
}
buscar();
// Cleanup — executado quando o componente desmonta
return () => { cancelado = true; };
}, []); // [] = executa apenas uma vez (ao montar)
if (carregando) return <p>Carregando...</p>;
if (erro) return <p>Erro: {erro}</p>;
return (
<ul>
{usuarios.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
// Dependências do useEffect
function BuscaPorTermo({ termo }) {
const [resultados, setResultados] = useState([]);
useEffect(() => {
if (!termo.trim()) { setResultados([]); return; }
const controller = new AbortController();
fetch(`/api/busca?q=${termo}`, { signal: controller.signal })
.then(r => r.json())
.then(setResultados)
.catch(e => { if (e.name !== 'AbortError') console.error(e); });
return () => controller.abort(); // cancela requisição anterior
}, [termo]); // re-executa toda vez que 'termo' muda
return <ul>{resultados.map(r => <li key={r.id}>{r.nome}</li>)}</ul>;
}
// Variações do array de dependências
useEffect(() => { /* executa em toda renderização */ });
useEffect(() => { /* executa apenas ao montar */ }, []);
useEffect(() => { /* executa quando a ou b mudam */ }, [a, b]);
Renderização condicional
function StatusPedido({ status, usuario }) {
// if/return antecipado (early return)
if (!usuario) return <p>Faça login para ver o pedido.</p>;
return (
<div>
{/* Operador ternário */}
{status === 'aprovado'
? <Aprovado />
: <Pendente />
}
{/* && — renderiza apenas se verdadeiro */}
{status === 'cancelado' && (
<div className="alerta-vermelho">
Pedido cancelado. Entre em contato.
</div>
)}
{/* Múltiplas condições com switch */}
{(() => {
switch (status) {
case 'pendente': return <span>⏳ Aguardando pagamento</span>;
case 'aprovado': return <span>✅ Aprovado</span>;
case 'enviado': return <span>🚚 A caminho</span>;
case 'entregue': return <span>📦 Entregue</span>;
default: return <span>❓ Status desconhecido</span>;
}
})()}
{/* Nulo retorna nada */}
{usuario.admin && <BotaoAdmin />}
</div>
);
}
Exemplo completo — App de Tarefas
// src/App.jsx
import { useState, useEffect } from 'react';
import './App.css';
// ── Componentes ────────────────────────────────────
function Cabecalho({ total, concluidas }) {
return (
<header className="cabecalho">
<h1>📋 Minhas Tarefas</h1>
<p>{concluidas} de {total} concluídas</p>
<div className="barra-progresso">
<div
className="barra-fill"
style={{ width: total > 0 ? `${(concluidas / total) * 100}%` : '0%' }}
/>
</div>
</header>
);
}
function FormularioNovaTarefa({ aoAdicionar }) {
const [texto, setTexto] = useState('');
const [prioridade, setPrioridade] = useState('media');
function handleSubmit(e) {
e.preventDefault();
if (!texto.trim()) return;
aoAdicionar(texto.trim(), prioridade);
setTexto('');
setPrioridade('media');
}
return (
<form onSubmit={handleSubmit} className="form-nova-tarefa">
<input
type="text"
value={texto}
onChange={e => setTexto(e.target.value)}
placeholder="Nova tarefa..."
className="input-tarefa"
/>
<select
value={prioridade}
onChange={e => setPrioridade(e.target.value)}
className="select-prioridade"
>
<option value="baixa">🟢 Baixa</option>
<option value="media">🟡 Média</option>
<option value="alta">🔴 Alta</option>
</select>
<button type="submit" disabled={!texto.trim()}>
Adicionar
</button>
</form>
);
}
function ItemTarefa({ tarefa, aoAlternar, aoRemover }) {
const icones = { baixa: '🟢', media: '🟡', alta: '🔴' };
return (
<li className={`tarefa-item ${tarefa.feita ? 'feita' : ''} prioridade-${tarefa.prioridade}`}>
<input
type="checkbox"
checked={tarefa.feita}
onChange={() => aoAlternar(tarefa.id)}
/>
<span className="tarefa-texto">{tarefa.texto}</span>
<span className="tarefa-prioridade" title={tarefa.prioridade}>
{icones[tarefa.prioridade]}
</span>
<span className="tarefa-data">
{new Date(tarefa.criadaEm).toLocaleDateString('pt-BR')}
</span>
<button
onClick={() => aoRemover(tarefa.id)}
className="btn-remover"
aria-label="Remover tarefa"
>
🗑
</button>
</li>
);
}
function FiltroBotoes({ filtroAtivo, aoFiltrar, tarefas }) {
const botoes = [
{ valor: 'todas', rotulo: 'Todas', count: tarefas.length },
{ valor: 'pendentes', rotulo: 'Pendentes', count: tarefas.filter(t => !t.feita).length },
{ valor: 'concluidas', rotulo: 'Concluídas', count: tarefas.filter(t => t.feita).length },
];
return (
<div className="filtros">
{botoes.map(b => (
<button
key={b.valor}
className={`btn-filtro ${filtroAtivo === b.valor ? 'ativo' : ''}`}
onClick={() => aoFiltrar(b.valor)}
>
{b.rotulo} <span className="badge">{b.count}</span>
</button>
))}
</div>
);
}
// ── App principal ──────────────────────────────────
const CHAVE_STORAGE = 'tarefas-react';
function carregarTarefas() {
try {
const salvas = localStorage.getItem(CHAVE_STORAGE);
return salvas ? JSON.parse(salvas) : [];
} catch {
return [];
}
}
export default function App() {
const [tarefas, setTarefas] = useState(carregarTarefas);
const [filtro, setFiltro] = useState('todas');
// Persiste tarefas no localStorage quando mudam
useEffect(() => {
localStorage.setItem(CHAVE_STORAGE, JSON.stringify(tarefas));
}, [tarefas]);
function adicionarTarefa(texto, prioridade) {
setTarefas(prev => [
{
id: crypto.randomUUID(),
texto,
prioridade,
feita: false,
criadaEm: new Date().toISOString(),
},
...prev,
]);
}
function alternarTarefa(id) {
setTarefas(prev =>
prev.map(t => t.id === id ? { ...t, feita: !t.feita } : t)
);
}
function removerTarefa(id) {
setTarefas(prev => prev.filter(t => t.id !== id));
}
function limparConcluidas() {
setTarefas(prev => prev.filter(t => !t.feita));
}
// Filtragem
const tarefasFiltradas = tarefas.filter(t => {
if (filtro === 'pendentes') return !t.feita;
if (filtro === 'concluidas') return t.feita;
return true;
});
// Ordenação: alta → media → baixa → concluídas por último
const prioridadeOrdem = { alta: 0, media: 1, baixa: 2 };
const tarefasOrdenadas = [...tarefasFiltradas].sort((a, b) => {
if (a.feita !== b.feita) return a.feita ? 1 : -1;
return (prioridadeOrdem[a.prioridade] ?? 2) - (prioridadeOrdem[b.prioridade] ?? 2);
});
const concluidas = tarefas.filter(t => t.feita).length;
return (
<div className="app">
<Cabecalho total={tarefas.length} concluidas={concluidas} />
<main className="conteudo">
<FormularioNovaTarefa aoAdicionar={adicionarTarefa} />
<FiltroBotoes
filtroAtivo={filtro}
aoFiltrar={setFiltro}
tarefas={tarefas}
/>
{tarefasOrdenadas.length === 0 ? (
<p className="lista-vazia">
{filtro === 'todas'
? '🎉 Nenhuma tarefa ainda. Adicione uma!'
: `Nenhuma tarefa ${filtro === 'pendentes' ? 'pendente' : 'concluída'}.`
}
</p>
) : (
<ul className="lista-tarefas">
{tarefasOrdenadas.map(tarefa => (
<ItemTarefa
key={tarefa.id}
tarefa={tarefa}
aoAlternar={alternarTarefa}
aoRemover={removerTarefa}
/>
))}
</ul>
)}
{concluidas > 0 && (
<button
onClick={limparConcluidas}
className="btn-limpar"
>
🧹 Limpar {concluidas} concluída{concluidas !== 1 ? 's' : ''}
</button>
)}
</main>
</div>
);
}
Os conceitos fundamentais que você aprendeu
┌───────────────────────────────────────────────────────┐
│ REACT — CONCEITOS BASE │
│ │
│ Componente → função que retorna JSX │
│ Props → dados passados do pai para o filho │
│ Estado → dados que quando mudam, re-renderizam │
│ JSX → HTML + JavaScript combinados │
│ Key → identifica itens em listas │
│ useEffect → efeitos colaterais e ciclo de vida │
│ │
│ Fluxo de dados: │
│ Pai → (props) → Filho │
│ Filho → (callbacks) → Pai (lifting state up) │
└───────────────────────────────────────────────────────┘
Tarefa para você
Expanda o App de Tarefas com:
// 1. Campo de busca que filtra tarefas em tempo real
// (use debounce de 300ms com useState + useEffect)
// 2. Edição inline — ao clicar no texto, vira um input
// Salva ao pressionar Enter ou ao tirar o foco (onBlur)
// Cancela ao pressionar Escape
// 3. Drag and drop simples — reordenação manual
// Dica: atributos draggable, onDragStart, onDragOver, onDrop
// 4. Estatísticas expandidas:
// - Tempo médio de conclusão (criadaEm → data de conclusão)
// - Tarefa mais antiga ainda pendente
// - Porcentagem por prioridade
// 5. Temas — botão que alterna entre claro e escuro
// Persista a preferência no localStorage
// Use className no elemento raiz: "app tema-claro" | "app tema-escuro"
Ver solução — a expansão inteira do App de Tarefas, com os 15 testes que a provam
// ---- src/main.jsx (o que muda no ponto de entrada)
// ReactDOM.createRoot(document.getElementById("root")).render(
// <React.StrictMode>
// <App tarefasIniciais={carregarDoLocalStorage()} />
// </React.StrictMode>
// );
//
// As tarefas entram por prop em vez de nascerem dentro do App. É o que
// torna o componente testável: o teste passa a lista que quiser, sem
// precisar de banco, de fetch ou de localStorage.
// ---- src/hooks/useDebounce.js
// 1 — BUSCA COM DEBOUNCE DE 300ms
import { useState, useEffect } from "react";
export function useDebounce(valor, atraso = 300) {
const [debounced, setDebounced] = useState(valor);
useEffect(() => {
const id = setTimeout(() => setDebounced(valor), atraso);
// A limpeza é o debounce inteiro: cada tecla cancela o timer anterior.
return () => clearTimeout(id);
}, [valor, atraso]);
return debounced;
}
// ---- src/utils/estatisticas.js
// 4 — ESTATÍSTICAS (e a reordenação do item 3)
//
// Funções puras, num arquivo sem React. Não é organização por
// organização: é o que permite testar a regra de negócio sem montar
// componente nenhum — e é onde os erros de cálculo realmente moram.
const DIA = 24 * 60 * 60 * 1000;
export function tempoMedioDeConclusao(tarefas) {
const concluidas = tarefas.filter((t) => t.concluida && t.concluidaEm);
if (concluidas.length === 0) return null;
const soma = concluidas.reduce(
(acc, t) => acc + (new Date(t.concluidaEm) - new Date(t.criadaEm)),
0
);
return soma / concluidas.length / DIA;
}
export function maisAntigaPendente(tarefas) {
const pendentes = tarefas.filter((t) => !t.concluida);
if (pendentes.length === 0) return null;
return pendentes.reduce((maisVelha, t) =>
new Date(t.criadaEm) < new Date(maisVelha.criadaEm) ? t : maisVelha
);
}
export function porcentagemPorPrioridade(tarefas) {
const vazio = { alta: 0, media: 0, baixa: 0 };
if (tarefas.length === 0) return vazio;
const contagem = tarefas.reduce(
(acc, t) => ({ ...acc, [t.prioridade]: (acc[t.prioridade] || 0) + 1 }),
{ ...vazio }
);
return Object.fromEntries(
Object.entries(contagem).map(([k, n]) => [k, (n / tarefas.length) * 100])
);
}
export function reordenar(lista, de, para) {
if (de === para || de < 0 || para < 0 || de >= lista.length || para >= lista.length) {
return lista;
}
const copia = [...lista];
const [movido] = copia.splice(de, 1);
copia.splice(para, 0, movido);
return copia;
}
// ---- src/hooks/useTema.js
// 5 — TEMA PERSISTIDO
import { useState, useEffect } from "react";
const CHAVE = "tarefas:tema";
export function useTema() {
// Inicializador preguiçoso: lê o localStorage uma vez, não a cada render.
const [tema, setTema] = useState(() => {
try {
return localStorage.getItem(CHAVE) || "claro";
} catch {
return "claro";
}
});
useEffect(() => {
try {
localStorage.setItem(CHAVE, tema);
} catch {
// modo privado do Safari: falhar aqui não pode derrubar a aplicação
}
}, [tema]);
const alternar = () => setTema((t) => (t === "claro" ? "escuro" : "claro"));
return { tema, alternar };
}
// ---- src/componentes/ItemTarefa.jsx
// 2 e 3 — EDIÇÃO INLINE E DRAG AND DROP
import { useState, useRef, useEffect } from "react";
export function ItemTarefa({ tarefa, indice, onAlternar, onRenomear, onArrastar }) {
const [editando, setEditando] = useState(false);
const [rascunho, setRascunho] = useState(tarefa.titulo);
const inputRef = useRef(null);
useEffect(() => {
if (editando) inputRef.current?.select();
}, [editando]);
function comecarEdicao() {
setRascunho(tarefa.titulo); // descarta rascunho de um cancelamento anterior
setEditando(true);
}
function salvar() {
const limpo = rascunho.trim();
// Título vazio não salva — mas também não trava o usuário no input.
if (limpo && limpo !== tarefa.titulo) onRenomear(tarefa.id, limpo);
setEditando(false);
}
function aoTeclar(evento) {
if (evento.key === "Enter") salvar();
if (evento.key === "Escape") {
setRascunho(tarefa.titulo);
setEditando(false);
}
}
return (
<li
className={`tarefa prioridade-${tarefa.prioridade}`}
draggable={!editando}
onDragStart={(e) => {
e.dataTransfer.setData("text/plain", String(indice));
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => e.preventDefault()} // sem isso o onDrop nunca dispara
onDrop={(e) => {
e.preventDefault();
const de = Number(e.dataTransfer.getData("text/plain"));
if (!Number.isNaN(de)) onArrastar(de, indice);
}}
data-testid={`tarefa-${tarefa.id}`}
>
<input
type="checkbox"
checked={tarefa.concluida}
onChange={() => onAlternar(tarefa.id)}
aria-label={`Concluir ${tarefa.titulo}`}
/>
{editando ? (
<input
ref={inputRef}
className="edicao"
value={rascunho}
onChange={(e) => setRascunho(e.target.value)}
onKeyDown={aoTeclar}
onBlur={salvar}
aria-label="Editar título"
/>
) : (
<span className="titulo" onClick={comecarEdicao} role="button" tabIndex={0}>
{tarefa.titulo}
</span>
)}
</li>
);
}
// ---- src/App.jsx
// O App que junta tudo
import { useState, useMemo } from "react";
import { useDebounce } from "./hooks/useDebounce";
import { useTema } from "./hooks/useTema";
import { ItemTarefa } from "./componentes/ItemTarefa";
import {
tempoMedioDeConclusao,
maisAntigaPendente,
porcentagemPorPrioridade,
reordenar,
} from "./utils/estatisticas";
export default function App({ tarefasIniciais = [] }) {
const [tarefas, setTarefas] = useState(tarefasIniciais);
const [busca, setBusca] = useState("");
const buscaDebounced = useDebounce(busca, 300);
const { tema, alternar } = useTema();
// useMemo com a busca JÁ debounced: filtrar a cada tecla é o que se
// queria evitar, e refazer o filtro a cada render também.
const visiveis = useMemo(() => {
const termo = buscaDebounced.trim().toLowerCase();
if (!termo) return tarefas;
return tarefas.filter((t) => t.titulo.toLowerCase().includes(termo));
}, [tarefas, buscaDebounced]);
const stats = useMemo(
() => ({
tempoMedio: tempoMedioDeConclusao(tarefas),
maisAntiga: maisAntigaPendente(tarefas),
porPrioridade: porcentagemPorPrioridade(tarefas),
}),
[tarefas]
);
const alternarTarefa = (id) =>
setTarefas((atuais) =>
atuais.map((t) =>
t.id === id
? { ...t, concluida: !t.concluida, concluidaEm: t.concluida ? null : new Date().toISOString() }
: t
)
);
const renomear = (id, titulo) =>
setTarefas((atuais) => atuais.map((t) => (t.id === id ? { ...t, titulo } : t)));
// A reordenação usa os índices da lista COMPLETA, não da filtrada:
// arrastar com busca ativa moveria a tarefa para o lugar errado.
const mover = (de, para) =>
setTarefas((atuais) => {
const idDe = visiveis[de]?.id;
const idPara = visiveis[para]?.id;
return reordenar(
atuais,
atuais.findIndex((t) => t.id === idDe),
atuais.findIndex((t) => t.id === idPara)
);
});
return (
<div className={`app tema-${tema}`}>
<header>
<h1>Minhas tarefas</h1>
<button onClick={alternar}>
{tema === "claro" ? "🌙 Tema escuro" : "☀️ Tema claro"}
</button>
</header>
<input
type="search"
placeholder="Buscar tarefas..."
value={busca}
onChange={(e) => setBusca(e.target.value)}
aria-label="Buscar tarefas"
/>
<ul>
{visiveis.map((tarefa, i) => (
<ItemTarefa
key={tarefa.id}
tarefa={tarefa}
indice={i}
onAlternar={alternarTarefa}
onRenomear={renomear}
onArrastar={mover}
/>
))}
</ul>
{visiveis.length === 0 && <p>Nenhuma tarefa encontrada.</p>}
<section aria-label="Estatísticas">
<p>
Tempo médio de conclusão:{" "}
{stats.tempoMedio === null ? "—" : `${stats.tempoMedio.toFixed(1)} dias`}
</p>
<p>Mais antiga pendente: {stats.maisAntiga?.titulo ?? "—"}</p>
<p>
Alta {stats.porPrioridade.alta.toFixed(0)}% · Média{" "}
{stats.porPrioridade.media.toFixed(0)}% · Baixa{" "}
{stats.porPrioridade.baixa.toFixed(0)}%
</p>
</section>
</div>
);
}
// ---- testes/app120.test.jsx
// OS TESTES — 15, todos passando
//
// Rodam com jest + @testing-library/react + jsdom:
// npm i -D jest jest-environment-jsdom @testing-library/react \
// @testing-library/user-event @testing-library/jest-dom \
// babel-jest @babel/preset-env @babel/preset-react
import { render, screen, fireEvent, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "../src/App";
import {
tempoMedioDeConclusao,
maisAntigaPendente,
porcentagemPorPrioridade,
reordenar,
} from "../src/utils/estatisticas";
const TAREFAS = [
{ id: 1, titulo: "Estudar React", prioridade: "alta", concluida: false, criadaEm: "2026-08-01T10:00:00Z", concluidaEm: null },
{ id: 2, titulo: "Revisar PR", prioridade: "media", concluida: true, criadaEm: "2026-08-02T10:00:00Z", concluidaEm: "2026-08-04T10:00:00Z" },
{ id: 3, titulo: "Escrever testes",prioridade: "baixa", concluida: false, criadaEm: "2026-07-20T10:00:00Z", concluidaEm: null },
];
beforeEach(() => localStorage.clear());
describe("estatísticas (funções puras)", () => {
it("tempo médio de conclusão em dias", () => {
expect(tempoMedioDeConclusao(TAREFAS)).toBeCloseTo(2, 5);
});
it("devolve null quando nada foi concluído", () => {
expect(tempoMedioDeConclusao([TAREFAS[0]])).toBeNull();
});
it("acha a mais antiga ainda pendente", () => {
expect(maisAntigaPendente(TAREFAS).titulo).toBe("Escrever testes");
});
it("porcentagem por prioridade soma 100", () => {
const p = porcentagemPorPrioridade(TAREFAS);
expect(p.alta + p.media + p.baixa).toBeCloseTo(100, 5);
});
it("não divide por zero com lista vazia", () => {
expect(porcentagemPorPrioridade([])).toEqual({ alta: 0, media: 0, baixa: 0 });
});
it("reordena movendo do índice 0 para o 2", () => {
expect(reordenar(["a", "b", "c"], 0, 2)).toEqual(["b", "c", "a"]);
});
it("índice fora da lista devolve a lista intacta", () => {
const l = ["a", "b"];
expect(reordenar(l, 0, 9)).toBe(l);
});
});
describe("1 — busca com debounce de 300ms", () => {
it("não filtra antes dos 300ms e filtra depois", async () => {
jest.useFakeTimers();
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(<App tarefasIniciais={TAREFAS} />);
await user.type(screen.getByLabelText("Buscar tarefas"), "revisar");
expect(screen.getByText("Estudar React")).toBeInTheDocument(); // ainda não filtrou
act(() => { jest.advanceTimersByTime(300); });
expect(screen.queryByText("Estudar React")).not.toBeInTheDocument();
expect(screen.getByText("Revisar PR")).toBeInTheDocument();
jest.useRealTimers();
});
});
describe("2 — edição inline", () => {
it("salva com Enter", async () => {
const user = userEvent.setup();
render(<App tarefasIniciais={TAREFAS} />);
await user.click(screen.getByText("Estudar React"));
const input = screen.getByLabelText("Editar título");
await user.clear(input);
await user.type(input, "Estudar React 19{Enter}");
expect(screen.getByText("Estudar React 19")).toBeInTheDocument();
expect(screen.queryByLabelText("Editar título")).not.toBeInTheDocument();
});
it("cancela com Escape e mantém o título antigo", async () => {
const user = userEvent.setup();
render(<App tarefasIniciais={TAREFAS} />);
await user.click(screen.getByText("Estudar React"));
const input = screen.getByLabelText("Editar título");
await user.clear(input);
await user.type(input, "lixo{Escape}");
expect(screen.getByText("Estudar React")).toBeInTheDocument();
expect(screen.queryByText("lixo")).not.toBeInTheDocument();
});
it("salva ao perder o foco", async () => {
const user = userEvent.setup();
render(<App tarefasIniciais={TAREFAS} />);
await user.click(screen.getByText("Revisar PR"));
const input = screen.getByLabelText("Editar título");
await user.clear(input);
await user.type(input, "Revisar PR #42");
await user.tab();
expect(screen.getByText("Revisar PR #42")).toBeInTheDocument();
});
it("título vazio não apaga a tarefa", async () => {
const user = userEvent.setup();
render(<App tarefasIniciais={TAREFAS} />);
await user.click(screen.getByText("Estudar React"));
const input = screen.getByLabelText("Editar título");
await user.clear(input);
await user.type(input, " {Enter}");
expect(screen.getByText("Estudar React")).toBeInTheDocument();
});
});
describe("3 — drag and drop", () => {
it("reordena a lista ao soltar", () => {
render(<App tarefasIniciais={TAREFAS} />);
const itens = () => screen.getAllByRole("listitem").map((li) => li.textContent);
expect(itens()[0]).toContain("Estudar React");
const dados = new Map();
const dataTransfer = {
setData: (k, v) => dados.set(k, v),
getData: (k) => dados.get(k),
};
fireEvent.dragStart(screen.getByTestId("tarefa-1"), { dataTransfer });
fireEvent.drop(screen.getByTestId("tarefa-3"), { dataTransfer });
expect(itens()[2]).toContain("Estudar React");
});
});
describe("5 — tema persistido", () => {
it("alterna e grava no localStorage", async () => {
const user = userEvent.setup();
const { container } = render(<App tarefasIniciais={TAREFAS} />);
expect(container.firstChild).toHaveClass("tema-claro");
await user.click(screen.getByRole("button", { name: /tema escuro/i }));
expect(container.firstChild).toHaveClass("tema-escuro");
expect(localStorage.getItem("tarefas:tema")).toBe("escuro");
});
it("recupera a preferência salva ao montar", () => {
localStorage.setItem("tarefas:tema", "escuro");
const { container } = render(<App tarefasIniciais={TAREFAS} />);
expect(container.firstChild).toHaveClass("tema-escuro");
});
});
// ---- saída real
// Test Suites: 1 passed, 1 total
// Tests: 15 passed, 15 total
// Time: 1.237 s
Três armadilhas do enunciado, todas pegas pelos testes. O debounce mora no valor, não no filtro: quem coloca o setTimeout em volta do filter continua re-renderizando a cada tecla. O onDragOver precisa de preventDefault() — sem ele o onDrop simplesmente nunca dispara, e não há erro no console para avisar. E a reordenação tem de traduzir os índices da lista filtrada para os da lista completa: arrastar com busca ativa move a tarefa errada, e o bug só aparece quando alguém usa as duas coisas juntas.
O que o React inverte não é a forma de escrever HTML, é a direção do trabalho: em vez de encontrar o elemento e alterá-lo, você descreve como a tela deve ser em cada estado e deixa que a diferença seja calculada. Todo o resto decorre daí — o estado que só muda pela função de atualização, a key que informa qual item é qual entre duas renderizações, e o useEffect para aquilo que precisa acontecer fora desse ciclo.
Fontes e Referências
- React — Documentação oficial: https://react.dev
- React — Aprenda React (tutorial oficial): https://react.dev/learn
- Vite — Documentação: https://vitejs.dev
- React — Thinking in React: https://react.dev/learn/thinking-in-react
- React — Describing the UI: https://react.dev/learn/describing-the-ui
- Road to React — Robin Wieruch: https://www.roadtoreact.com
- Learning React — Alex Banks e Eve Porcello (O'Reilly)
- roadmap.sh — React: https://roadmap.sh/react
Exercícios
Exercício 1
O botão deveria somar 3. Ele soma 1. Por quê?
function Contador() {
const [contador, setContador] = useState(0);
function somarTres() {
setContador(contador + 1);
setContador(contador + 1);
setContador(contador + 1);
}
return <button onClick={somarTres}>{contador}</button>;
}
Ver resposta
✓ Resposta: Porque contador é uma constante daquela renderização, não uma variável que se atualiza durante o evento. Quando a função roda, contador vale 0 nas três linhas; as três chamadas agendam "o novo valor é 1", e a última vence. O React não reatribui a variável no meio do manipulador — ele agenda uma nova renderização, e só nela o useState devolve o valor atualizado. A correção é a forma funcional: setContador(c => c + 1), que recebe o valor mais recente da fila em vez de ler o do escopo. Com ela, as três chamadas se encadeiam e o resultado é 3. A regra prática que resolve a maioria dos casos: quando o novo estado depende do anterior, use a função; quando é um valor independente, passar direto está certo. O mesmo raciocínio explica um sintoma vizinho e muito comum — ler o estado logo após chamá-lo, setContador(5); console.log(contador), e ver o valor antigo no console. Não é atraso nem bug: é a mesma constante, da mesma renderização.
Exercício 2
Quando a lista está vazia, aparece um 0 solto na tela. De onde ele vem?
function Painel({ tarefas }) {
return (
<div>
{tarefas.length && <p>Você tem {tarefas.length} tarefas</p>}
</div>
);
}
Ver resposta
✓ Resposta: Do próprio tarefas.length. O operador && não devolve um booleano: devolve o primeiro operando falsy, ou o último. Com a lista vazia, 0 && ... resulta em 0 — e o React renderiza o número zero, porque zero é um valor válido para exibir. Ele só ignora false, null, undefined e string vazia. O sintoma é aquele zero perdido no meio do layout que ninguém sabe de onde saiu. Há três correções, e a escolha é de gosto: transformar em booleano de verdade, com tarefas.length > 0 && ...; usar o ternário com null no lugar do falso, tarefas.length ? ... : null; ou fazer o early return quando a lista está vazia, que costuma render um código mais legível porque ainda permite mostrar um estado vazio decente. A armadilha tem uma irmã menos conhecida: com string vazia, nome && ... também não imprime nada visível, mas NaN && ... imprime NaN na tela. A regra segura é sempre converter a condição num booleano explícito antes do &&.
Exercício 3
A lista tem um campo de texto em cada item. O usuário digita no primeiro, depois remove o item do meio — e o que ele digitou aparece em outra linha. O que está errado?
{tarefas.map((tarefa, i) => (
<li key={i}>
<input defaultValue={tarefa.texto} />
<button onClick={() => remover(tarefa.id)}>remover</button>
</li>
))}
Ver resposta
✓ Resposta: A key é o índice, e índice não identifica um item: identifica uma posição. Removido o item do meio, todos os seguintes mudam de índice, e o React — que usa a key para decidir o que reaproveitar — conclui que o item de índice 2 continua sendo "o mesmo" e apenas mudou de conteúdo. Ele reaproveita o elemento do DOM, com o estado interno que ele carrega: o texto digitado no campo, a posição do cursor, o checkbox marcado, o vídeo tocando. O resultado é o estado colado na linha errada. Com key={tarefa.id}, a identidade acompanha o dado, e o React remove exatamente o elemento certo. Vale saber quando o índice é aceitável: lista que nunca é reordenada, nunca tem item removido do meio e cujos itens não têm estado próprio — o caso de uma listagem estática. Fora disso, procure um identificador estável; se o dado não tiver um, gere-o no momento em que o item é criado, e não durante a renderização — key={Math.random()} é pior que o índice, porque força o React a recriar todos os elementos a cada renderização e faz o campo perder o foco a cada tecla digitada.
Exercício 4
A aba do navegador trava e a API é chamada milhares de vezes por segundo. O que está errado neste efeito?
function Perfil({ id }) {
const [usuario, setUsuario] = useState(null);
useEffect(() => {
fetch(`/api/usuarios/${id}`)
.then(r => r.json())
.then(setUsuario);
});
return <p>{usuario?.nome}</p>;
}
Ver resposta
✓ Resposta: Falta o array de dependências, e sem ele o efeito roda depois de toda renderização. O ciclo se fecha sozinho: o efeito busca, o setUsuario provoca uma renderização, a renderização dispara o efeito de novo, e assim indefinidamente — um laço infinito movido a requisições HTTP. A correção é declarar do que o efeito depende: }, [id]); faz a busca acontecer na montagem e sempre que o id mudar, que é a intenção. Repare na diferença entre as três formas, porque ela é a fonte da maioria dos problemas com useEffect: sem array, roda sempre; array vazio, roda só ao montar; com valores, roda quando algum deles muda. Dois complementos valem para este código mesmo depois de corrigido. Primeiro, falta o cancelamento: trocando de perfil rapidamente, a resposta de um id antigo pode chegar depois e sobrescrever a nova — a mesma corrida vista no artigo do fetch, resolvida com AbortController no retorno do efeito. Segundo, em desenvolvimento o StrictMode executa cada efeito duas vezes de propósito, justamente para expor efeitos sem limpeza adequada; ver a requisição duplicada no console não é bug, é o aviso.
Exercício 5
O item é adicionado ao array, mas a tela não muda. O console.log mostra a lista crescendo. Por quê?
function Lista() {
const [itens, setItens] = useState([]);
function adicionar(texto) {
itens.push({ id: Date.now(), texto });
setItens(itens);
console.log(itens); // cresce a cada clique
}
return <ul>{itens.map(i => <li key={i.id}>{i.texto}</li>)}</ul>;
}
Ver resposta
✓ Resposta: Porque o array foi mutado, e não substituído. O push altera o array existente, então itens continua sendo exatamente o mesmo objeto na memória. Quando setItens(itens) é chamado, o React compara o valor novo com o antigo por identidade — Object.is — e conclui que nada mudou, então não agenda renderização nenhuma. O dado está lá, como o console.log prova; o que não aconteceu foi a atualização da tela. A correção é criar um array novo: setItens(prev => [...prev, { id: Date.now(), texto }]). O mesmo vale para objetos — { ...prev, campo: valor } — e para toda operação de lista: map e filter devolvem arrays novos e são seguros, enquanto push, splice, sort e reverse alteram no lugar e precisam de uma cópia antes. É a distinção entre métodos que mutam e que não mutam, vista no artigo de arrays, agora com uma consequência visível: no React, mutar o estado não é apenas má prática — é a diferença entre a tela atualizar ou não. E cuidado com sort em particular, que costuma aparecer como itens.sort(...) dentro do JSX e reordena o próprio estado a cada renderização.