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
useFetchnã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: seurlmudar 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 quandourlmuda — exatamente no ciclo de vida douseEffect. - 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 lerlocalStoragea cada renderização.- O hook retorna uma tupla
[value, setValue]assim comouseState, 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
valuemuda, um novosetTimeouté criado; osetTimeoutantigo é cancelado no cleanup. - Apenas quando o usuário para de digitar dentro de
delayMsé quedebouncedé 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:
- O nome começa com
use— o plugin ESLinteslint-plugin-react-hooksse baseia neste prefixo para verificar a regra, semuseele não identificará como um hook. - Chame hooks apenas no nível superior da função, não dentro de
if,for, ou callbacks. - 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:
renderHookrecebe uma função que chama o hook, retornaresult.current— o valor que o hook retorna na renderização atual.rerendersimula uma re-renderização do componente com novas props (como quandovaluemuda 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: comuseFetch, sem a flagcancelled, uma requisição antiga que retorna depois pode sobrescrever dados novos — causando exibição de dados incorretos. - Dependências faltando: se o
useEffectno 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 usandoReact.memoabaixo pode re-renderizar desnecessariamente porque a referência mudou. Você pode usaruseMemose 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’.