Introdução ao React

[120] Introdução ao React

Manipular o DOM à mão significa lembrar de atualizar tudo que depende de cada mudança. O React inverte isso: você descreve como a tela deve ser em cada estado e ele calcula a diferença. Aqui estão JSX, componentes, props com children, useState, a prop key, useEffect com dependências e renderização condicional.
Javascript

32 min de leitura

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

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.

Comentários

Mais em Javascript

Criando e Removendo Elementos Dinamicamente
Criando e Removendo Elementos Dinamicamente

Boa parte de uma interface não está no HTML quando a página carrega — quem a…

Express.js: o framework web do Node
Express.js: o framework web do Node

O mesmo endpoint que ocupava trinta linhas de if e regex cabe em quatro com…

Objetos: estruturando dados do mundo real
Objetos: estruturando dados do mundo real

Atribuir um objeto a outra variável não copia coisa nenhuma: as duas passam a…