No artigo Introdução ao React você aprendeu useState e useEffect — os dois hooks mais usados. Mas o React tem um ecossistema rico de hooks que resolvem problemas específicos de performance, compartilhamento de estado e lógica reutilizável.
Neste artigo vamos explorar useContext, useReducer, useMemo, useCallback, useRef, useId e — o mais poderoso de todos — como criar seus próprios hooks customizados.
Regras dos Hooks
Antes de começar, as regras que nunca podem ser quebradas:
// ✅ Correto — hooks no nível do componente
function MeuComponente() {
const [estado, setEstado] = useState(0);
useEffect(() => {}, []);
return <div>{estado}</div>;
}
// ❌ Nunca dentro de condicionais
function Errado() {
if (condicao) {
const [estado, setEstado] = useState(0); // ❌
}
}
// ❌ Nunca dentro de loops
function Errado() {
for (let i = 0; i < 3; i++) {
const [estado, setEstado] = useState(0); // ❌
}
}
// ❌ Nunca em funções comuns — apenas em componentes e hooks customizados
function minhaFuncao() {
const [estado, setEstado] = useState(0); // ❌
}
// A razão: React depende da ordem de chamada dos hooks
// para associar estado ao componente correto
useRef — referências sem re-renderização
useRef cria um objeto mutável que persiste entre renderizações sem causar re-renderização:
import { useRef, useState, useEffect } from 'react';
// ── Caso 1: Referência ao elemento DOM ─────────────
function CampoAutoFoco() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current?.focus(); // acessa o elemento DOM diretamente
}, []);
return <input ref={inputRef} placeholder="Foca automaticamente" />;
}
// ── Caso 2: Guardar valor sem re-renderizar ─────────
function Cronometro() {
const [tempo, setTempo] = useState(0);
const [rodando, setRodando] = useState(false);
const intervalRef = useRef(null);
function iniciar() {
if (rodando) return;
setRodando(true);
intervalRef.current = setInterval(() => {
setTempo(t => t + 1);
}, 1000);
}
function pausar() {
clearInterval(intervalRef.current);
setRodando(false);
}
function resetar() {
clearInterval(intervalRef.current);
setRodando(false);
setTempo(0);
}
// Limpa o intervalo quando o componente desmonta
useEffect(() => () => clearInterval(intervalRef.current), []);
return (
<div>
<p>{String(Math.floor(tempo / 60)).padStart(2, '0')}:{String(tempo % 60).padStart(2, '0')}</p>
<button onClick={iniciar} disabled={rodando}>▶ Iniciar</button>
<button onClick={pausar} disabled={!rodando}>⏸ Pausar</button>
<button onClick={resetar}>↺ Resetar</button>
</div>
);
}
// ── Caso 3: Valor anterior ─────────────────────────
function usePrevious(valor) {
const ref = useRef(undefined);
useEffect(() => { ref.current = valor; });
return ref.current; // retorna o valor da renderização anterior
}
function Contador() {
const [count, setCount] = useState(0);
const anterior = usePrevious(count);
return (
<div>
<p>Atual: {count} | Anterior: {anterior ?? 'nenhum'}</p>
<button onClick={() => setCount(c => c + 1)}>+</button>
</div>
);
}
useContext — estado global sem prop drilling
O problema que o Context resolve:
// ── Prop drilling — o problema ─────────────────────
// App passa tema para Layout que passa para Sidebar
// que passa para Menu que passa para MenuItem
// só para que MenuItem use o tema. Insuportável.
function App() {
const [tema, setTema] = useState('claro');
return <Layout tema={tema} setTema={setTema} />;
}
function Layout({ tema, setTema }) {
return <Sidebar tema={tema} setTema={setTema} />;
}
function Sidebar({ tema, setTema }) {
return <Menu tema={tema} setTema={setTema} />;
}
// ... cinco níveis depois ...
function MenuItem({ tema }) {
return <div className={`item tema-${tema}`}>...</div>;
}
// ── Context — a solução ────────────────────────────
import { createContext, useContext, useState } from 'react';
// 1. Criar o contexto com valor padrão
const TemaContext = createContext({
tema: 'claro',
alternarTema: () => {},
});
// 2. Criar o Provider — envolve os componentes que precisam do valor
function TemaProvider({ children }) {
const [tema, setTema] = useState('claro');
function alternarTema() {
setTema(t => t === 'claro' ? 'escuro' : 'claro');
}
return (
<TemaContext.Provider value={{ tema, alternarTema }}>
{children}
</TemaContext.Provider>
);
}
// Hook customizado para facilitar o uso
function useTema() {
const contexto = useContext(TemaContext);
if (!contexto) throw new Error('useTema deve ser usado dentro de TemaProvider');
return contexto;
}
// 3. Usar em qualquer componente dentro do Provider — sem props
function BotaoTema() {
const { tema, alternarTema } = useTema();
return (
<button onClick={alternarTema}>
{tema === 'claro' ? '🌙 Modo escuro' : '☀️ Modo claro'}
</button>
);
}
function MenuItem() {
const { tema } = useTema();
return <div className={`item tema-${tema}`}>...</div>;
}
// 4. Aplicar o Provider no topo
function App() {
return (
<TemaProvider>
<Layout /> {/* BotaoTema e MenuItem acessam o contexto diretamente */}
</TemaProvider>
);
}
useReducer — estado complexo com lógica centralizada
Quando o estado tem muitas transições inter-relacionadas, useReducer é mais adequado que múltiplos useState:
import { useReducer } from 'react';
// Estado inicial
const estadoInicial = {
tarefas: [],
filtro: 'todas',
carregando: false,
erro: null,
};
// Reducer — função pura: (estado, ação) → novo estado
function tarefasReducer(estado, acao) {
switch (acao.type) {
case 'ADICIONAR':
return {
...estado,
tarefas: [
{ id: crypto.randomUUID(), texto: acao.payload, feita: false },
...estado.tarefas,
],
};
case 'ALTERNAR':
return {
...estado,
tarefas: estado.tarefas.map(t =>
t.id === acao.payload ? { ...t, feita: !t.feita } : t
),
};
case 'REMOVER':
return {
...estado,
tarefas: estado.tarefas.filter(t => t.id !== acao.payload),
};
case 'DEFINIR_FILTRO':
return { ...estado, filtro: acao.payload };
case 'LIMPAR_CONCLUIDAS':
return { ...estado, tarefas: estado.tarefas.filter(t => !t.feita) };
case 'CARREGAR_INICIO':
return { ...estado, carregando: true, erro: null };
case 'CARREGAR_SUCESSO':
return { ...estado, carregando: false, tarefas: acao.payload };
case 'CARREGAR_ERRO':
return { ...estado, carregando: false, erro: acao.payload };
default:
return estado;
}
}
// Componente usando useReducer
function AppTarefas() {
const [estado, dispatch] = useReducer(tarefasReducer, estadoInicial);
// Ações tipadas — evita erros de digitação
const acoes = {
adicionar: (texto) => dispatch({ type: 'ADICIONAR', payload: texto }),
alternar: (id) => dispatch({ type: 'ALTERNAR', payload: id }),
remover: (id) => dispatch({ type: 'REMOVER', payload: id }),
filtrar: (filtro) => dispatch({ type: 'DEFINIR_FILTRO', payload: filtro }),
limparConcluidas: () => dispatch({ type: 'LIMPAR_CONCLUIDAS' }),
};
const tarefasFiltradas = estado.tarefas.filter(t => {
if (estado.filtro === 'pendentes') return !t.feita;
if (estado.filtro === 'concluidas') return t.feita;
return true;
});
return (
<div>
{estado.erro && <p className="erro">{estado.erro}</p>}
{estado.carregando && <p>Carregando...</p>}
<ul>
{tarefasFiltradas.map(t => (
<li key={t.id}>
<input
type="checkbox"
checked={t.feita}
onChange={() => acoes.alternar(t.id)}
/>
{t.texto}
<button onClick={() => acoes.remover(t.id)}>🗑</button>
</li>
))}
</ul>
</div>
);
}
useMemo — memorizando valores calculados
import { useState, useMemo } from 'react';
function ListaFiltrada({ produtos }) {
const [busca, setBusca] = useState('');
const [ordenar, setOrdenar] = useState('nome');
// SEM useMemo — filtra e ordena em TODA renderização
// (mesmo quando só a UI mudou, não os produtos ou busca)
const produtosProcessados = produtos
.filter(p => p.nome.toLowerCase().includes(busca.toLowerCase()))
.sort((a, b) => a[ordenar] > b[ordenar] ? 1 : -1);
// COM useMemo — só recalcula quando produtos, busca ou ordenar mudam
const produtosMemo = useMemo(() => {
console.log('Recalculando lista...');
return produtos
.filter(p => p.nome.toLowerCase().includes(busca.toLowerCase()))
// Comparador que nunca devolve 0 é inconsistente: com valores iguais o
// resultado varia conforme a ordem de entrada. Devolva -1, 0 ou 1.
.sort((a, b) => (a[ordenar] > b[ordenar]) - (a[ordenar] < b[ordenar]));
}, [produtos, busca, ordenar]); // dependências
return (
<div>
<input value={busca} onChange={e => setBusca(e.target.value)} />
<select value={ordenar} onChange={e => setOrdenar(e.target.value)}>
<option value="nome">Nome</option>
<option value="preco">Preço</option>
</select>
<ul>
{produtosMemo.map(p => <li key={p.id}>{p.nome} — R$ {p.preco}</li>)}
</ul>
</div>
);
}
// useMemo para objetos passados como props (evita re-renders desnecessários)
function Configuracao({ usuario }) {
const config = useMemo(() => ({
tema: usuario.preferencias.tema,
idioma: usuario.preferencias.idioma,
formato: usuario.preferencias.formatoData,
}), [usuario.preferencias]);
return <Painel config={config} />;
}
// ⚠️ Não use useMemo para tudo — tem custo de memória
// Use apenas quando:
// 1. O cálculo é realmente custoso (lista grande, agregação complexa)
// 2. O valor é passado para componentes filhos memorizados
useCallback — memorizando funções
import { useState, useCallback, memo } from 'react';
// React.memo — componente que só re-renderiza se as props mudarem
const BotaoItem = memo(function BotaoItem({ id, onRemover }) {
console.log(`BotaoItem ${id} renderizou`);
return <button onClick={() => onRemover(id)}>Remover {id}</button>;
});
function ListaItens() {
const [itens, setItens] = useState([1, 2, 3, 4, 5]);
const [contador, setContador] = useState(0);
// SEM useCallback — nova função a cada renderização
// → BotaoItem re-renderiza mesmo sem mudança nos itens!
const removerSemMemo = (id) => setItens(prev => prev.filter(i => i !== id));
// COM useCallback — mesma referência de função entre renderizações
// → BotaoItem só re-renderiza quando itens muda
const removerComMemo = useCallback((id) => {
setItens(prev => prev.filter(i => i !== id));
}, []); // [] porque usa o padrão funcional do setState
return (
<div>
<button onClick={() => setContador(c => c + 1)}>
Contador: {contador} {/* clique aqui não deve re-renderizar BotaoItem */}
</button>
{itens.map(id => (
<BotaoItem key={id} id={id} onRemover={removerComMemo} />
))}
</div>
);
}
// useCallback para funções passadas como dependência de useEffect
function BuscaAoVivo({ termo }) {
const [resultados, setResultados] = useState([]);
const buscar = useCallback(async () => {
if (!termo) return;
const res = await fetch(`/api/busca?q=${termo}`);
setResultados(await res.json());
}, [termo]); // nova função apenas quando termo muda
useEffect(() => {
buscar();
}, [buscar]); // ESLint fica feliz — dependência explícita
return <ul>{resultados.map(r => <li key={r.id}>{r.nome}</li>)}</ul>;
}
useId — IDs únicos acessíveis
import { useId } from 'react';
// Gera IDs únicos consistentes entre servidor e cliente (SSR-safe)
function CampoFormulario({ label, tipo = 'text', ...props }) {
const id = useId(); // ex: ":r0:", ":r1:", ":r2:"...
return (
<div className="campo">
<label htmlFor={id}>{label}</label>
<input id={id} type={tipo} {...props} />
</div>
);
}
// Múltiplos IDs em um componente
function RangeComLabel({ label, min, max, value, onChange }) {
const baseId = useId();
const inputId = `${baseId}-input`;
const listaId = `${baseId}-lista`;
return (
<div>
<label htmlFor={inputId}>{label}: {value}</label>
<input
id={inputId}
type="range"
min={min}
max={max}
value={value}
onChange={onChange}
aria-describedby={listaId}
/>
</div>
);
}
Hooks customizados — reutilizando lógica
Hooks customizados são funções que começam com use e podem chamar outros hooks. São a forma mais poderosa de reutilização de lógica no React:
// ── useFetch — busca de dados reutilizável ─────────
function useFetch(url) {
const [dados, setDados] = useState(null);
const [carregando, setCarregando] = useState(true);
const [erro, setErro] = useState(null);
useEffect(() => {
let cancelado = false;
const controller = new AbortController();
async function buscar() {
setCarregando(true);
setErro(null);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`Erro ${res.status}`);
const json = await res.json();
if (!cancelado) setDados(json);
} catch (e) {
if (!cancelado && e.name !== 'AbortError') {
setErro(e.message);
}
} finally {
if (!cancelado) setCarregando(false);
}
}
buscar();
return () => {
cancelado = true;
controller.abort();
};
}, [url]);
return { dados, carregando, erro };
}
// Uso
function PerfilUsuario({ id }) {
const { dados, carregando, erro } = useFetch(
`https://jsonplaceholder.typicode.com/users/${id}`
);
if (carregando) return <p>Carregando...</p>;
if (erro) return <p>Erro: {erro}</p>;
return <div><h2>{dados?.name}</h2><p>{dados?.email}</p></div>;
}
// ── useLocalStorage — persistência automática ──────
function useLocalStorage(chave, valorInicial) {
const [valor, setValor] = useState(() => {
try {
const item = localStorage.getItem(chave);
return item !== null ? JSON.parse(item) : valorInicial;
} catch {
return valorInicial;
}
});
const definir = useCallback((novoValor) => {
try {
// Permite passar função (igual ao setState)
// A forma funcional é obrigatória aqui: ler `valor` do escopo pegaria o
// estado da renderização em que o callback nasceu, e duas chamadas seguidas
// gravariam o mesmo valor. Com prev, o callback também deixa de depender de
// `valor` — e volta a ter a referência estável que justifica o useCallback.
setValor((prev) => {
const valorParaSalvar =
typeof novoValor === 'function' ? novoValor(prev) : novoValor;
localStorage.setItem(chave, JSON.stringify(valorParaSalvar));
return valorParaSalvar;
});
} catch (e) {
console.warn(`useLocalStorage: erro ao salvar "${chave}"`, e);
}
}, [chave]);
return [valor, definir];
}
// Uso — idêntico ao useState, mas persiste!
function Preferencias() {
const [tema, setTema] = useLocalStorage('tema', 'claro');
const [idioma, setIdioma] = useLocalStorage('idioma', 'pt-BR');
return (
<div>
<select value={tema} onChange={e => setTema(e.target.value)}>
<option value="claro">☀️ Claro</option>
<option value="escuro">🌙 Escuro</option>
</select>
</div>
);
}
// ── useDebounce — atraso de valor ──────────────────
function useDebounce(valor, delay = 500) {
const [valorAtrasado, setValorAtrasado] = useState(valor);
useEffect(() => {
const timer = setTimeout(() => setValorAtrasado(valor), delay);
return () => clearTimeout(timer);
}, [valor, delay]);
return valorAtrasado;
}
// ── useWindowSize — dimensões da janela ────────────
function useWindowSize() {
const [tamanho, setTamanho] = useState({
largura: window.innerWidth,
altura: window.innerHeight,
});
useEffect(() => {
function handleResize() {
setTamanho({ largura: window.innerWidth, altura: window.innerHeight });
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return tamanho;
}
// ── useOnClickOutside — fechar ao clicar fora ──────
function useOnClickOutside(ref, handler) {
useEffect(() => {
function listener(evento) {
if (!ref.current || ref.current.contains(evento.target)) return;
handler(evento);
}
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
}
// ── useMediaQuery — responsive no código ───────────
function useMediaQuery(query) {
const [corresponde, setCorresponde] = useState(
() => window.matchMedia(query).matches
);
useEffect(() => {
const media = window.matchMedia(query);
function handleChange(e) { setCorresponde(e.matches); }
media.addEventListener('change', handleChange);
return () => media.removeEventListener('change', handleChange);
}, [query]);
return corresponde;
}
// Uso
function Layout() {
const eMobile = useMediaQuery('(max-width: 768px)');
const prefereEscuro = useMediaQuery('(prefers-color-scheme: dark)');
return (
<div>
{eMobile ? <MenuMobile /> : <MenuDesktop />}
{prefereEscuro && <p>Você prefere o modo escuro!</p>}
</div>
);
}
// ── useForm — gerenciamento completo de formulário ──
function useForm(camposIniciais, validar) {
const [valores, setValores] = useState(camposIniciais);
const [erros, setErros] = useState({});
const [tocados, setTocados] = useState({});
const [enviando, setEnviando] = useState(false);
function handleChange(e) {
const { name, value, type, checked } = e.target;
const novoValor = type === 'checkbox' ? checked : value;
setValores(prev => ({ ...prev, [name]: novoValor }));
// Valida campo em tempo real se já foi tocado
if (tocados[name] && validar) {
const novosErros = validar({ ...valores, [name]: novoValor });
setErros(prev => ({ ...prev, [name]: novosErros[name] }));
}
}
function handleBlur(e) {
const { name } = e.target;
setTocados(prev => ({ ...prev, [name]: true }));
if (validar) {
const novosErros = validar(valores);
setErros(prev => ({ ...prev, [name]: novosErros[name] }));
}
}
async function handleSubmit(onSubmit) {
return async (e) => {
e.preventDefault();
// Marca todos como tocados e valida
const todosTocados = Object.keys(camposIniciais).reduce(
(acc, k) => ({ ...acc, [k]: true }), {}
);
setTocados(todosTocados);
if (validar) {
const novosErros = validar(valores);
setErros(novosErros);
if (Object.values(novosErros).some(Boolean)) return;
}
setEnviando(true);
try {
await onSubmit(valores);
} finally {
setEnviando(false);
}
};
}
function resetar() {
setValores(camposIniciais);
setErros({});
setTocados({});
}
return {
valores,
erros,
tocados,
enviando,
handleChange,
handleBlur,
handleSubmit,
resetar,
};
}
// Uso
function FormularioCadastro() {
const { valores, erros, tocados, enviando, handleChange, handleBlur, handleSubmit } =
useForm(
{ nome: '', email: '', senha: '' },
(v) => ({
nome: !v.nome ? 'Nome é obrigatório.' : '',
email: !v.email.includes('@') ? 'Email inválido.' : '',
senha: v.senha.length < 6 ? 'Mínimo 6 caracteres.' : '',
})
);
const aoEnviar = handleSubmit(async (dados) => {
await fetch('/api/usuarios', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dados),
});
});
return (
<form onSubmit={aoEnviar}>
<input
name="nome"
value={valores.nome}
onChange={handleChange}
onBlur={handleBlur}
/>
{tocados.nome && erros.nome && <span className="erro">{erros.nome}</span>}
<input
name="email"
type="email"
value={valores.email}
onChange={handleChange}
onBlur={handleBlur}
/>
{tocados.email && erros.email && <span className="erro">{erros.email}</span>}
<input
name="senha"
type="password"
value={valores.senha}
onChange={handleChange}
onBlur={handleBlur}
/>
{tocados.senha && erros.senha && <span className="erro">{erros.senha}</span>}
<button type="submit" disabled={enviando}>
{enviando ? 'Enviando...' : 'Cadastrar'}
</button>
</form>
);
}
Quando usar cada hook
┌──────────────────────────────────────────────────────────┐
│ GUIA DE DECISÃO │
│ │
│ useState → estado simples, independente │
│ useReducer → estado complexo com múltiplas ações │
│ useContext → estado global / compartilhado │
│ useRef → DOM direto / valor sem re-render │
│ useEffect → efeitos colaterais / fetch / timers │
│ useMemo → cálculo custoso memorizado │
│ useCallback → função memorizada (para memo/Effect) │
│ useId → IDs únicos para acessibilidade │
│ Hook custom → lógica reutilizável entre componentes │
│ │
│ ⚠️ Não otimize prematuramente: │
│ useMemo e useCallback têm custo. │
│ Só use quando há problema de performance real. │
└──────────────────────────────────────────────────────────┘
Tarefa para você
Construa uma biblioteca de hooks customizados para o projeto:
// 1. useContador(inicial, { min, max, passo })
// - increment, decrement, resetar
// - respeita min e max
// - passo configurável
// 2. useToggle(valorInicial)
// - toggle, ativar, desativar
// - retorna [valor, { toggle, ativar, desativar }]
// 3. useAsync(funcaoAssincrona, dependencias)
// - executa a função automaticamente
// - retorna { dados, carregando, erro, executar }
// - executar() permite refazer manualmente
// 4. useCopiarTexto()
// - retorna [copiado, copiar]
// - copiar(texto) copia para clipboard
// - copiado fica true por 2 segundos depois volta false
// 5. useHistorico(valorInicial)
// - rastreia histórico de mudanças
// - retorna [atual, { definir, desfazer, refazer, limpar }]
// - desfazer e refazer navegam pelo histórico
// Para cada hook: escreva testes com Jest
// (simule o hook com @testing-library/react-hooks
// ou renderize um componente de teste)
Ver solução — os cinco hooks, com os 20 testes que os provam
// ---- src/hooks/useContador.js
// 1 — useContador
import { useState, useCallback } from "react";
export function useContador(inicial = 0, { min = -Infinity, max = Infinity, passo = 1 } = {}) {
const limitar = useCallback((n) => Math.min(Math.max(n, min), max), [min, max]);
const [valor, setValor] = useState(() => limitar(inicial));
// Atualização por função, não por valor: dois incrementos no mesmo evento
// dariam +1 em vez de +2 se lessem `valor` do closure.
const incrementar = useCallback(() => setValor((v) => limitar(v + passo)), [limitar, passo]);
const decrementar = useCallback(() => setValor((v) => limitar(v - passo)), [limitar, passo]);
const resetar = useCallback(() => setValor(limitar(inicial)), [limitar, inicial]);
const definir = useCallback((n) => setValor(limitar(n)), [limitar]);
return {
valor,
incrementar,
decrementar,
resetar,
definir,
noMinimo: valor <= min,
noMaximo: valor >= max,
};
}
// ---- src/hooks/useToggle.js
// 2 — useToggle
import { useState, useMemo } from "react";
export function useToggle(valorInicial = false) {
const [valor, setValor] = useState(Boolean(valorInicial));
// useMemo com [] : o objeto de ações é estável entre renders, então
// componentes filhos memoizados não re-renderizam à toa.
const acoes = useMemo(
() => ({
toggle: () => setValor((v) => !v),
ativar: () => setValor(true),
desativar: () => setValor(false),
}),
[]
);
return [valor, acoes];
}
// ---- src/hooks/useAsync.js
// 3 — useAsync
import { useState, useEffect, useCallback, useRef } from "react";
export function useAsync(funcaoAssincrona, dependencias = []) {
const [estado, setEstado] = useState({ dados: null, carregando: true, erro: null });
// Guarda a função num ref para que trocá-la não dispare a execução:
// quem controla o disparo são as dependências declaradas.
const fnRef = useRef(funcaoAssincrona);
useEffect(() => { fnRef.current = funcaoAssincrona; });
const montado = useRef(true);
useEffect(() => () => { montado.current = false; }, []);
const executar = useCallback(async (...args) => {
setEstado((e) => ({ ...e, carregando: true, erro: null }));
try {
const dados = await fnRef.current(...args);
// Sem esta checagem: "Can't perform a React state update on an
// unmounted component" toda vez que o usuário sai antes da resposta.
if (montado.current) setEstado({ dados, carregando: false, erro: null });
return dados;
} catch (erro) {
if (montado.current) setEstado({ dados: null, carregando: false, erro });
throw erro;
}
}, []);
useEffect(() => {
executar().catch(() => {}); // o erro já está no estado; não relançar aqui
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencias);
return { ...estado, executar };
}
// ---- src/hooks/useCopiarTexto.js
// 4 — useCopiarTexto
import { useState, useCallback, useRef, useEffect } from "react";
export function useCopiarTexto(duracao = 2000) {
const [copiado, setCopiado] = useState(false);
const timer = useRef(null);
useEffect(() => () => clearTimeout(timer.current), []);
const copiar = useCallback(
async (texto) => {
try {
await navigator.clipboard.writeText(texto);
setCopiado(true);
// Limpar o timer anterior: dois cliques seguidos não podem
// encurtar a janela do segundo.
clearTimeout(timer.current);
timer.current = setTimeout(() => setCopiado(false), duracao);
return true;
} catch {
setCopiado(false);
return false;
}
},
[duracao]
);
return [copiado, copiar];
}
// ---- src/hooks/useHistorico.js
// 5 — useHistorico
import { useState, useCallback, useMemo } from "react";
export function useHistorico(valorInicial) {
const [estado, setEstado] = useState({ passado: [], atual: valorInicial, futuro: [] });
const definir = useCallback((novo) => {
setEstado((e) => {
const valor = typeof novo === "function" ? novo(e.atual) : novo;
if (Object.is(valor, e.atual)) return e; // não empilha "mudança" que não mudou
return { passado: [...e.passado, e.atual], atual: valor, futuro: [] };
});
}, []);
const desfazer = useCallback(() => {
setEstado((e) => {
if (e.passado.length === 0) return e;
const anterior = e.passado[e.passado.length - 1];
return {
passado: e.passado.slice(0, -1),
atual: anterior,
futuro: [e.atual, ...e.futuro],
};
});
}, []);
const refazer = useCallback(() => {
setEstado((e) => {
if (e.futuro.length === 0) return e;
const [proximo, ...resto] = e.futuro;
return { passado: [...e.passado, e.atual], atual: proximo, futuro: resto };
});
}, []);
const limpar = useCallback(() => {
setEstado((e) => ({ passado: [], atual: e.atual, futuro: [] }));
}, []);
const acoes = useMemo(
() => ({ definir, desfazer, refazer, limpar }),
[definir, desfazer, refazer, limpar]
);
return [estado.atual, { ...acoes, podeDesfazer: estado.passado.length > 0, podeRefazer: estado.futuro.length > 0 }];
}
// ---- testes/hooks121.test.jsx
// OS TESTES — 20, todos passando
//
// `renderHook` vem do próprio @testing-library/react desde a v13; o pacote
// @testing-library/react-hooks que o enunciado cita está descontinuado.
import { renderHook, act, waitFor } from "@testing-library/react";
import { useContador } from "../src/hooks/useContador";
import { useToggle } from "../src/hooks/useToggle";
import { useAsync } from "../src/hooks/useAsync";
import { useCopiarTexto } from "../src/hooks/useCopiarTexto";
import { useHistorico } from "../src/hooks/useHistorico";
describe("1 — useContador", () => {
it("incrementa e decrementa com o passo configurado", () => {
const { result } = renderHook(() => useContador(0, { passo: 5 }));
act(() => result.current.incrementar());
expect(result.current.valor).toBe(5);
act(() => result.current.decrementar());
expect(result.current.valor).toBe(0);
});
it("respeita min e max", () => {
const { result } = renderHook(() => useContador(9, { min: 0, max: 10 }));
act(() => { result.current.incrementar(); result.current.incrementar(); });
expect(result.current.valor).toBe(10);
expect(result.current.noMaximo).toBe(true);
});
it("limita já o valor inicial fora da faixa", () => {
const { result } = renderHook(() => useContador(99, { min: 0, max: 10 }));
expect(result.current.valor).toBe(10);
});
it("dois incrementos no mesmo act somam 2", () => {
const { result } = renderHook(() => useContador(0));
act(() => { result.current.incrementar(); result.current.incrementar(); });
expect(result.current.valor).toBe(2);
});
it("resetar volta ao inicial", () => {
const { result } = renderHook(() => useContador(3));
act(() => result.current.incrementar());
act(() => result.current.resetar());
expect(result.current.valor).toBe(3);
});
});
describe("2 — useToggle", () => {
it("alterna, ativa e desativa", () => {
const { result } = renderHook(() => useToggle(false));
act(() => result.current[1].toggle());
expect(result.current[0]).toBe(true);
act(() => result.current[1].desativar());
expect(result.current[0]).toBe(false);
act(() => result.current[1].ativar());
expect(result.current[0]).toBe(true);
});
it("o objeto de ações é estável entre renders", () => {
const { result, rerender } = renderHook(() => useToggle());
const antes = result.current[1];
act(() => result.current[1].toggle());
rerender();
expect(result.current[1]).toBe(antes);
});
});
describe("3 — useAsync", () => {
it("executa sozinho e expõe os dados", async () => {
const buscar = jest.fn().mockResolvedValue({ nome: "Ana" });
const { result } = renderHook(() => useAsync(buscar, []));
expect(result.current.carregando).toBe(true);
await waitFor(() => expect(result.current.carregando).toBe(false));
expect(result.current.dados).toEqual({ nome: "Ana" });
expect(result.current.erro).toBeNull();
expect(buscar).toHaveBeenCalledTimes(1);
});
it("guarda o erro sem quebrar o componente", async () => {
const buscar = jest.fn().mockRejectedValue(new Error("500"));
const { result } = renderHook(() => useAsync(buscar, []));
await waitFor(() => expect(result.current.carregando).toBe(false));
expect(result.current.erro).toBeInstanceOf(Error);
expect(result.current.dados).toBeNull();
});
it("executar() refaz manualmente", async () => {
const buscar = jest.fn().mockResolvedValue(1);
const { result } = renderHook(() => useAsync(buscar, []));
await waitFor(() => expect(result.current.carregando).toBe(false));
await act(async () => { await result.current.executar(); });
expect(buscar).toHaveBeenCalledTimes(2);
});
it("reexecuta quando a dependência muda", async () => {
const buscar = jest.fn().mockResolvedValue("ok");
const { rerender } = renderHook(({ id }) => useAsync(() => buscar(id), [id]), {
initialProps: { id: 1 },
});
await waitFor(() => expect(buscar).toHaveBeenCalledWith(1));
rerender({ id: 2 });
await waitFor(() => expect(buscar).toHaveBeenCalledWith(2));
});
it("não atualiza estado depois de desmontar", async () => {
const erroNoConsole = jest.spyOn(console, "error").mockImplementation(() => {});
let resolver;
const buscar = () => new Promise((r) => { resolver = r; });
const { unmount } = renderHook(() => useAsync(buscar, []));
unmount();
await act(async () => { resolver("tarde demais"); });
expect(erroNoConsole).not.toHaveBeenCalled();
erroNoConsole.mockRestore();
});
});
describe("4 — useCopiarTexto", () => {
beforeEach(() => {
Object.defineProperty(navigator, "clipboard", {
value: { writeText: jest.fn().mockResolvedValue(undefined) },
configurable: true,
});
});
it("copia e volta a false depois de 2s", async () => {
jest.useFakeTimers();
const { result } = renderHook(() => useCopiarTexto());
await act(async () => { await result.current[1]("npm install"); });
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("npm install");
expect(result.current[0]).toBe(true);
act(() => { jest.advanceTimersByTime(2000); });
expect(result.current[0]).toBe(false);
jest.useRealTimers();
});
it("devolve false quando o navegador recusa", async () => {
navigator.clipboard.writeText.mockRejectedValue(new Error("sem permissão"));
const { result } = renderHook(() => useCopiarTexto());
let ok;
await act(async () => { ok = await result.current[1]("x"); });
expect(ok).toBe(false);
expect(result.current[0]).toBe(false);
});
});
describe("5 — useHistorico", () => {
it("desfaz e refaz", () => {
const { result } = renderHook(() => useHistorico("a"));
act(() => result.current[1].definir("b"));
act(() => result.current[1].definir("c"));
expect(result.current[0]).toBe("c");
act(() => result.current[1].desfazer());
expect(result.current[0]).toBe("b");
act(() => result.current[1].desfazer());
expect(result.current[0]).toBe("a");
act(() => result.current[1].refazer());
expect(result.current[0]).toBe("b");
});
it("uma alteração nova descarta o futuro", () => {
const { result } = renderHook(() => useHistorico(1));
act(() => result.current[1].definir(2));
act(() => result.current[1].desfazer());
act(() => result.current[1].definir(9));
expect(result.current[1].podeRefazer).toBe(false);
expect(result.current[0]).toBe(9);
});
it("desfazer no começo não quebra", () => {
const { result } = renderHook(() => useHistorico("só"));
act(() => result.current[1].desfazer());
expect(result.current[0]).toBe("só");
expect(result.current[1].podeDesfazer).toBe(false);
});
it("definir o mesmo valor não empilha", () => {
const { result } = renderHook(() => useHistorico(5));
act(() => result.current[1].definir(5));
expect(result.current[1].podeDesfazer).toBe(false);
});
it("aceita função atualizadora", () => {
const { result } = renderHook(() => useHistorico(10));
act(() => result.current[1].definir((n) => n + 5));
expect(result.current[0]).toBe(15);
});
it("limpar zera o histórico mas mantém o valor", () => {
const { result } = renderHook(() => useHistorico("a"));
act(() => result.current[1].definir("b"));
act(() => result.current[1].limpar());
expect(result.current[0]).toBe("b");
expect(result.current[1].podeDesfazer).toBe(false);
});
});
// ---- saída real
// Test Suites: 1 passed, 1 total
// Tests: 20 passed, 20 total
// Time: 1.021 s
O hook que mais ensina é o useAsync, por causa de duas linhas que parecem paranoia. A primeira é a guarda de montagem: sem ela, sair da tela antes de a resposta chegar produz o clássico state update on an unmounted component. A segunda é a função guardada num useRef — se ela entrasse nas dependências do useEffect, uma arrow function nova a cada render dispararia uma requisição a cada render, em laço infinito. E vale saber: o @testing-library/react-hooks que o enunciado cita está descontinuado; renderHook vem do @testing-library/react desde a v13.
As regras dos hooks parecem arbitrárias até se saber o motivo: o React os identifica pela ordem em que são chamados, não pelo nome, e um hook dentro de um if muda essa ordem entre uma renderização e outra. Sabendo disso, o resto é escolha — useRef para o que persiste sem provocar renderização, useReducer quando as transições de estado têm regras próprias, useMemo e useCallback apenas depois de medir. E o hook customizado, que não passa de uma função chamando outros hooks, acaba sendo o mecanismo de reúso mais útil de todos.
Fontes e Referências
- React — Hooks Reference: https://react.dev/reference/react
- React — useContext: https://react.dev/reference/react/useContext
- React — useReducer: https://react.dev/reference/react/useReducer
- React — useMemo: https://react.dev/reference/react/useMemo
- React — useCallback: https://react.dev/reference/react/useCallback
- React — Custom Hooks: https://react.dev/learn/reusing-logic-with-custom-hooks
- usehooks.com — Coleção de hooks customizados: https://usehooks.com
- React Hooks in Action — John Larsen (Manning)
- Learning React — Alex Banks e Eve Porcello (O'Reilly)
- React — referência dos Hooks: https://react.dev/reference/react/hooks
Exercícios
Exercício 1
Este useCallback tem duas coisas erradas ao mesmo tempo. Quais?
const definir = useCallback((novoValor) => {
const valorParaSalvar =
typeof novoValor === 'function' ? novoValor(valor) : novoValor;
setValor(valorParaSalvar);
localStorage.setItem(chave, JSON.stringify(valorParaSalvar));
}, [chave, valor]);
Ver resposta
✓ Resposta: A primeira é o valor obsoleto: novoValor(valor) lê a variável do escopo da renderização em que o callback foi criado. Duas chamadas seguidas no mesmo manipulador — definir(v => v + 1) duas vezes — recebem o mesmo valor e gravam o mesmo resultado, exatamente como acontece com setState chamado em sequência. A segunda é consequência da primeira: como o callback precisa de valor, ele entra no array de dependências, e a função passa a ser recriada a cada mudança de estado — o que anula o propósito do useCallback, que é manter a referência estável. Um filho envolvido em memo volta a re-renderizar sempre, e um useEffect que dependa dessa função volta a executar sempre. As duas se resolvem com a mesma mudança: usar a forma funcional do setValor e fazer o trabalho lá dentro, com prev. Aí a dependência cai para [chave] e a referência fica estável. A regra geral vale para qualquer hook: se você precisa do estado atual dentro de um callback memorizado, pegue-o pelo prev, não pelo escopo — senão a memorização e a correção entram em conflito, e uma das duas perde.
Exercício 2
O BotaoItem está envolvido em memo, mas re-renderiza toda vez que o contador muda. O que anula a otimização?
const BotaoItem = memo(function BotaoItem({ item, onRemover }) { /* ... */ });
function Lista() {
const [contador, setContador] = useState(0);
const [itens, setItens] = useState([{ id: 1, nome: 'a' }]);
const remover = useCallback((id) => {
setItens(prev => prev.filter(i => i.id !== id));
}, []);
return (
<>
<button onClick={() => setContador(c => c + 1)}>{contador}</button>
{itens.map(item => (
<BotaoItem key={item.id} item={item} onRemover={remover} estilo={{ cor: 'azul' }} />
))}
</>
);
}
Ver resposta
✓ Resposta: A prop estilo. O useCallback está correto e mantém onRemover estável, mas {{ cor: 'azul' }} é um objeto literal criado durante a renderização — a cada passagem do pai, um objeto novo, com conteúdo idêntico e identidade diferente. O memo compara as props de forma rasa, com Object.is, e conclui que a prop mudou. Basta uma prop assim para inutilizar toda a memorização do componente, e é por isso que otimizar com memo costuma frustrar na primeira tentativa: a atenção vai para as funções, que são o caso famoso, e o objeto ou array literal passa despercebido. As correções são três, conforme o caso: mover o objeto para fora do componente quando é constante; envolvê-lo em useMemo quando depende de algo; ou passar os campos como props separadas, o que costuma ser o mais simples. O mesmo vale para arrays — lista={[]} — e para JSX passado como prop. E fica a regra que resume o assunto: memo só funciona se todas as props forem estáveis, e estabilidade se perde por identidade, não por conteúdo.
Exercício 3
Este componente lança Rendered more hooks than during the previous render. Onde está a violação?
function Perfil({ usuarioId }) {
if (!usuarioId) return <p>Selecione um usuário</p>;
const [dados, setDados] = useState(null);
useEffect(() => {
buscarUsuario(usuarioId).then(setDados);
}, [usuarioId]);
return <p>{dados?.nome}</p>;
}
Ver resposta
✓ Resposta: No early return antes dos hooks. Quando usuarioId está vazio, o componente retorna na primeira linha e nenhum hook é chamado; quando o id aparece, dois hooks passam a ser chamados. O React identifica cada hook pela ordem em que foi chamado naquela renderização — é uma lista posicional, não um registro por nome —, então uma renderização com zero hooks seguida de outra com dois quebra a correspondência e o React aborta com essa mensagem. É a mesma razão pela qual hook dentro de if, de laço ou depois de um return condicional é proibido: qualquer coisa que mude a quantidade ou a ordem entre renderizações. A correção é simples e vale como padrão: todos os hooks primeiro, condicionais depois. Os hooks sobem para o topo, o if (!usuarioId) return ... desce para depois deles, e o efeito trata internamente o caso vazio com um if (!usuarioId) return; logo na primeira linha. Vale notar que o plugin eslint-plugin-react-hooks pega esse erro em tempo de escrita, o que o torna um dos poucos casos em que o linter evita mesmo um bug de execução — motivo suficiente para instalá-lo em qualquer projeto React.
Exercício 4
Todo componente que usa useTema re-renderiza quando qualquer parte do app muda o contexto — mesmo quem só lê o tema e nunca o altera. Por quê?
function TemaProvider({ children }) {
const [tema, setTema] = useState('claro');
function alternarTema() {
setTema(t => (t === 'claro' ? 'escuro' : 'claro'));
}
return (
<TemaContext.Provider value={{ tema, alternarTema }}>
{children}
</TemaContext.Provider>
);
}
Ver resposta
✓ Resposta: Porque o value é um objeto novo a cada renderização do Provider. O React avisa os consumidores quando o valor do contexto muda, e a comparação é por identidade: { tema, alternarTema } nunca é o mesmo objeto duas vezes, mesmo com o tema inalterado. Qualquer renderização do Provider — provocada por outro estado dele, ou por um pai — propaga atualização para todos os consumidores, e o memo deles não ajuda, porque contexto não passa por comparação de props. Além disso, alternarTema é redeclarada a cada renderização, o que sozinho já invalidaria o objeto. A correção padrão tem duas partes: envolver a função em useCallback(..., []), possível porque ela usa a forma funcional do setTema, e o objeto em useMemo(() => ({ tema, alternarTema }), [tema, alternarTema]). Aí o valor só muda quando o tema muda de verdade. Há uma solução estrutural melhor para contextos que crescem: separar em dois — um com o estado, outro com as ações. Quem só dispara ações deixa de re-renderizar quando o estado muda, porque o contexto de ações é constante. É o desenho que bibliotecas como o Zustand oferecem pronto, com seletores.
Exercício 5
Quando usar useState e quando usar useReducer? Analise os dois trechos e diga qual problema o segundo resolve.
// A
const [carregando, setCarregando] = useState(false);
const [erro, setErro] = useState(null);
const [dados, setDados] = useState(null);
// B
const [estado, dispatch] = useReducer(reducer, {
status: 'ocioso', dados: null, erro: null,
});
Ver resposta
✓ Resposta: O segundo elimina os estados impossíveis. Com três useState independentes há oito combinações, e várias não deveriam existir: carregando com erro preenchido, dados e erro ao mesmo tempo, carregando falso antes de qualquer requisição. Nada impede que o código chegue nelas — basta esquecer um setErro(null) antes de nova tentativa e a tela passa a exibir a mensagem de erro antiga sobre os dados novos. É um dos defeitos mais comuns em interface, e ele nasce de modelar como três variáveis o que é, na verdade, uma máquina de estados: ocioso, carregando, sucesso ou erro — e só um por vez. O useReducer centraliza as transições numa função pura, testável isoladamente, e torna o estado inválido inexpressável. O critério para escolher costuma ser este: useState enquanto os estados forem independentes entre si e as atualizações, simples; useReducer quando um valor depende do outro, quando a mesma ação mexe em vários campos, ou quando as transições já viraram um emaranhado de if. Vale acrescentar que o dispatch tem referência estável por construção — o React garante isso —, então ele pode ser passado a filhos memorizados e usado em dependências de efeito sem nenhum useCallback.