React Hooks em profundidade

[121] React Hooks em profundidade

Depois do useState e do useEffect vem o resto: useRef para o que persiste sem renderizar, useContext contra o prop drilling, useReducer quando as transições têm regra, useMemo e useCallback para estabilizar referência, useId para formulário acessível — e o hook customizado, que é só uma função chamando outros hooks.
Javascript

30 min de leitura

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

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.

Comentários

Mais em Javascript

Async/Await: escrevendo código assíncrono de forma limpa
Async/Await: escrevendo código assíncrono de forma limpa

A cadeia de .then resolveu a pirâmide mas espalhou os valores por escopos…

Condicionais: if, else e switch
Condicionais: if, else e switch

O JavaScript acha que 0 é igual a false e que "5" é igual a 5 — duas…

O que é o DOM e como o JavaScript interage com o HTML
O que é o DOM e como o JavaScript interage com o HTML

O navegador não guarda o seu HTML como texto: ele o transforma numa árvore de…