Custom Hook no React: Separar Lógica Reutilizável Corretamente

1. Visão Geral

Quando vários componentes em uma aplicação React precisam de uma mesma lógica — chamar API, ler localStorage, debounce de input — você facilmente cai na armadilha de fazer copy-paste de estado e useEffect de um lado para o outro. Este artigo te mostra como encapsular essa lógica em um custom hook: uma função que começa com use, aproveitando os hooks disponíveis (useState, useEffect, useRef…) para reutilização. Ao terminar, você será capaz de escrever useFetch, useLocalStorage, useDebounce e saberá como testá-los usando renderHook.

2. O Problema: Lógica Repetida Entre Componentes

Suponha que você tem dois componentes que precisam chamar uma API e gerenciar loading/error:

function CustomerList() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/customers')
      .then((res) => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  // ...renderizar
}

function OrderList() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/orders')
      .then((res) => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  // ...renderizar
}

CustomerList e OrderList contêm praticamente a mesma lógica, diferindo apenas na URL. Se amanhã você quiser adicionar retry ou cancelar requisições, precisará editar em todo lugar — este é o sinal de que precisa de um custom hook.

3. O que é Custom Hook

Um custom hook é uma função JavaScript comum, cujo nome começa com use, que chama outros hooks do React dentro dela. Ela não retorna JSX — retorna estado e funções, como um “mini store” acoplado ao componente que a chama.

Nota: Cada vez que um componente chama um custom hook, React cria uma instância de estado separada. Dois componentes usando useFetch não compartilham estado um com o outro — apenas compartilham a lógica.

4. Escrevendo o Primeiro Custom Hook: useFetch

// useFetch.js
import { useState, useEffect } from 'react';

export function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    setError(null);

    fetch(url)
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((json) => {
        if (!cancelled) setData(json);
      })
      .catch((err) => {
        if (!cancelled) setError(err);
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });

    return () => {
      cancelled = true;
    };
  }, [url]);

  return { data, loading, error };
}

Explicação:

  • cancelled é usado para evitar race condition: se url mudar antes da requisição antiga terminar, o resultado antigo não será mais definido no estado.
  • A função de limpeza (return () => { cancelled = true }) executa quando o componente faz unmount ou quando url muda — exatamente no ciclo de vida do useEffect.
  • O hook retorna um objeto { data, loading, error } para que o chamador possa facilmente fazer destructuring.

O componente agora fica mais limpo:

function CustomerList() {
  const { data, loading, error } = useFetch('/api/customers');

  if (loading) return <p>Carregando...</p>;
  if (error) return <p>Erro: {error.message}</p>;
  return <ul>{data.map((c) => <li key={c.id}>{c.name}</li>)}</ul>;
}

function OrderList() {
  const { data, loading, error } = useFetch('/api/orders');
  // lógica de render similar, sem mais lógica de fetch repetida
}

5. Segundo Exemplo: useLocalStorage

A lógica de sincronizar estado com localStorage também é frequentemente copiada e colada:

// useLocalStorage.js
import { useState, useEffect } from 'react';

export function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = window.localStorage.getItem(key);
    return stored !== null ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    window.localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

Explicação:

  • useState(() => ...) usa um lazy initializer — a função executa uma única vez no mount, sem ler localStorage a cada renderização.
  • O hook retorna uma tupla [value, setValue] assim como useState, ajudando o usuário a se familiarizar imediatamente.

Usando no componente:

function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Tema atual: {theme}
    </button>
  );
}

5.1. Quando o Estado Depende do Valor Anterior

Se você precisa atualizar com base no valor anterior (como em um contador), use uma atualização funcional para evitar stale closure:

setValue((prev) => prev + 1); // mais seguro que setValue(value + 1)

6. Terceiro Exemplo: useDebounce

Debounce de input de busca é um problema muito comum:

// useDebounce.js
import { useState, useEffect } from 'react';

export function useDebounce(value, delayMs = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debounced;
}

Explicação:

  • Cada vez que value muda, um novo setTimeout é criado; o setTimeout antigo é cancelado no cleanup.
  • Apenas quando o usuário para de digitar dentro de delayMs é que debounced é atualizado.
function SearchBox() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 500);

  useEffect(() => {
    if (debouncedQuery) {
      fetch(`/api/search?q=${debouncedQuery}`);
    }
  }, [debouncedQuery]);

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

7. Regras de Nomeação e Rules of Hooks

Custom hooks devem seguir as Rules of Hooks assim como os hooks built-in:

  1. O nome começa com use — o plugin ESLint eslint-plugin-react-hooks se baseia neste prefixo para verificar a regra, sem use ele não identificará como um hook.
  2. Chame hooks apenas no nível superior da função, não dentro de if, for, ou callbacks.
  3. Chame hooks apenas em componentes funcionais ou em outros custom hooks, não em funções normais.
npm install eslint-plugin-react-hooks --save-dev
{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

Ativar exhaustive-deps te ajuda a não esquecer dependências no useEffect do custom hook — a causa mais comum de bugs de ‘dados obsoletos’.

8. Testando Custom Hook

Como um custom hook não retorna JSX, você não pode renderizá-lo como um componente. A biblioteca @testing-library/react fornece renderHook para chamar hooks em um ambiente de teste.

<!-- Não se aplica a projetos React; dependência instalada via npm, veja abaixo -->
npm install --save-dev vitest @testing-library/react
// useDebounce.test.jsx
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useDebounce } from './useDebounce';

describe('useDebounce', () => {
  it('atualiza o valor apenas após terminar o delay', () => {
    vi.useFakeTimers();
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: 'a', delay: 300 } }
    );

    expect(result.current).toBe('a');

    rerender({ value: 'ab', delay: 300 });
    expect(result.current).toBe('a'); // ainda não mudou

    act(() => {
      vi.advanceTimersByTime(300);
    });

    expect(result.current).toBe('ab'); // debounce concluído
    vi.useRealTimers();
  });
});

Explicação:

  • renderHook recebe uma função que chama o hook, retorna result.current — o valor que o hook retorna na renderização atual.
  • rerender simula uma re-renderização do componente com novas props (como quando value muda enquanto o usuário digita).
  • vi.useFakeTimers() + act(() => vi.advanceTimersByTime(...)) permite controlar o tempo sem precisar esperar de verdade, tornando o teste rápido e estável.
✓ useDebounce > atualiza o valor apenas após terminar o delay

9. Erros Comuns e Quando Não Usar

Erros comuns:

  • Esquecer cleanup no useEffect: com useFetch, sem a flag cancelled, uma requisição antiga que retorna depois pode sobrescrever dados novos — causando exibição de dados incorretos.
  • Dependências faltando: se o useEffect no hook usa uma variável externa mas não a declara no dependency array, o hook usará o valor ‘antigo’ (stale closure).
  • Retornar um novo objeto a cada renderização desnecessariamente: se o hook retorna { data, loading, error } como um object literal cada vez, um componente usando React.memo abaixo pode re-renderizar desnecessariamente porque a referência mudou. Você pode usar useMemo se conseguir medir o problema de verdade.

Quando NÃO escrever um custom hook:

  • Lógica usada em um único lugar e sem potencial de reutilização — separar apenas torna o código mais difícil de acompanhar (você precisa pular para outro arquivo para entender).
  • Lógica que é puramente computacional (pure function), sem relação com estado/efeito — você deve escrever uma função JS comum, não precisa de hook.
  • Quando você precisa compartilhar estado (não apenas lógica) entre vários componentes em diferentes posições da árvore — custom hooks não conseguem fazer isso, você precisa de Context ou uma biblioteca de gerenciamento de estado (Zustand, Redux…).

Nota: Custom hooks resolvem o problema de duplicação de lógica, não resolvem o problema de compartilhar estado entre componentes. Não confunda estes dois problemas.

10. Conclusão

Custom hooks são ferramentas para extrair lógica com estado dos componentes, mantendo o código limpo, fácil de testar e reutilizável. Você só precisa lembrar: nomeie começando com use, siga as Rules of Hooks, e teste usando renderHook em vez de render. Com useFetch, useLocalStorage, useDebounce acima, você já tem uma base para escrever outros hooks como useMediaQuery, useForm seguindo o verdadeiro espírito de ‘escrever uma vez, usar em muitos lugares’.

reactcustom-hookjavascriptfrontendtesting