Hooks Personalizados: Guia Passo a Passo
Como construir hooks personalizados do zero - começando de forma simples, evoluindo para complexidade intermediária.
Busque em todas as páginas da documentação
Como construir hooks personalizados do zero - começando de forma simples, evoluindo para complexidade intermediária.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
use - O React depende dessa convenção de nomenclatura para aplicar as Regras de Hooks.useState, useEffect, useRef, useCallback, etc., vão para sua função.useCallback e retorne objetos em useMemo para evitar re-renderizações desnecessárias nos consumidores.useEffect.O hook útil mais simples. Envolve useState(boolean) e retorna uma função toggle estável.
import { useState, useCallback } from "react";
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue((v) => !v), []);
return [value, toggle] as const;
}Uso:
function DarkModeButton() {
const [isDark, toggleDark] = useToggle(false);
return <button onClick={toggleDark}>{isDark ? "Claro" : "Escuro"}</button>;
}Por que funciona: useCallback com um array de dependências vazio cria uma referência de função estável. A forma atualizadora (v) => !v evita closures obsoletas.
import { useEffect } from "react";
function useDocumentTitle(title: string) {
useEffect(() => {
document.title = title;
}, [title]);
}Uso:
function ProfilePage({ user }: { user: { name: string } }) {
useDocumentTitle(`${user.name} - Perfil`);
return <h1>{user.name}</h1>;
}Por que funciona: O efeito é executado apenas quando title muda. Nenhuma limpeza é necessária porque definir document.title é idempotente.
import { useState, useCallback, useMemo } from "react";
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = useCallback(() => setCount((c) => c + 1), []);
const decrement = useCallback(() => setCount((c) => c - 1), []);
const reset = useCallback(() => setCount(initial), [initial]);
return useMemo(
() => ({ count, increment, decrement, reset }),
[count, increment, decrement, reset]
);
}Uso:
function CartQuantity() {
const { count, increment, decrement } = useCounter(1);
return (
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
);
}Por que funciona: Retornar um objeto memoizado significa que os consumidores que desestruturam não causam re-renderizações de filhos devido a uma nova referência de objeto a cada renderização.
Útil para proteger callbacks assíncronos que resolvem após o desmontar.
import { useRef, useEffect, useCallback } from "react";
function useIsMounted() {
const mounted = useRef(false);
useEffect(() => {
mounted.current = true;
return () => { mounted.current = false; };
}, []);
return useCallback(() => mounted.current, []);
}Uso:
function AsyncButton({ onClick }: { onClick: () => Promise<void> }) {
const isMounted = useIsMounted();
const [loading, setLoading] = useState(false);
const handleClick = async () => {
setLoading(true);
await onClick();
if (isMounted()) setLoading(false); // guarda de segurança
};
return <button onClick={handleClick} disabled={loading}>Ir</button>;
}import { useState, useEffect, useCallback } from "react";
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
if (typeof window === "undefined") return initialValue;
try {
const stored = localStorage.getItem(key);
return stored ? (JSON.parse(stored) as T) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// storage cheio ou bloqueado
}
}, [key, value]);
const remove = useCallback(() => {
setValue(initialValue);
localStorage.removeItem(key);
}, [key, initialValue]);
return [value, setValue, remove] as const;
}Uso:
function ThemeSelector() {
const [theme, setTheme] = useLocalStorage("theme", "light");
return (
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="light">Claro</option>
<option value="dark">Escuro</option>
</select>
);
}Detalhes chave:
useState evita ler localStorage em cada renderização.typeof window === "undefined" o torna seguro para SSR.<T> permite armazenar strings, números, objetos, arrays.import { useState, useEffect } from "react";
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}Uso:
function SearchInput() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) fetchResults(debouncedQuery);
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}Detalhes chave:
delay ms de inatividade.import { useEffect, useRef } from "react";
function useClickOutside<T extends HTMLElement>(
handler: () => void
) {
const ref = useRef<T>(null);
useEffect(() => {
const listener = (e: MouseEvent | TouchEvent) => {
if (!ref.current || ref.current.contains(e.target as Node)) return;
handler();
};
document.addEventListener("mousedown", listener);
document.addEventListener("touchstart", listener);
return () => {
document.removeEventListener("mousedown", listener);
document.removeEventListener("touchstart", listener);
};
}, [handler]);
return ref;
}Uso:
function Dropdown() {
const [open, setOpen] = useState(false);
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
return (
<div ref={ref}>
<button onClick={() => setOpen(true)}>Menu</button>
{open && <ul><li>Opção A</li><li>Opção B</li></ul>}
</div>
);
}Detalhes chave:
mousedown (não click) para que o dropdown feche antes que o clique seja concluído.handler deve ser envolvido em useCallback no consumidor para evitar reanexar listeners a cada renderização.import { useState, useEffect } from "react";
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window === "undefined") return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
const mql = window.matchMedia(query);
const onChange = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", onChange);
setMatches(mql.matches); // sincroniza caso tenha mudado antes do efeito rodar
return () => mql.removeEventListener("change", onChange);
}, [query]);
return matches;
}Uso:
function ResponsiveLayout() {
const isMobile = useMediaQuery("(max-width: 768px)");
return isMobile ? <MobileNav /> : <DesktopNav />;
}import { useState, useEffect, useRef } from "react";
interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
})
.then(setData)
.catch((err) => {
if (err.name !== "AbortError") setError(err);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [url]);
return { data, loading, error };
}Uso:
function UserProfile({ id }: { id: string }) {
const { data, loading, error } = useFetch<User>(`/api/users/${id}`);
if (loading) return <Spinner />;
if (error) return <p>Erro: {error.message}</p>;
return <h1>{data?.name}</h1>;
}Detalhes chave:
url muda - previne condições de corrida.AbortError é capturado silenciosamente para não aparecer como estado de erro.import { useEffect, useRef } from "react";
function useEventListener<K extends keyof WindowEventMap>(
event: K,
handler: (e: WindowEventMap[K]) => void,
element?: HTMLElement | null
) {
const handlerRef = useRef(handler);
useEffect(() => {
handlerRef.current = handler;
}, [handler]);
useEffect(() => {
const target = element ?? window;
const listener = (e: Event) => handlerRef.current(e as WindowEventMap[K]);
target.addEventListener(event, listener);
return () => target.removeEventListener(event, listener);
}, [event, element]);
}Uso:
function EscapeHandler({ onEscape }: { onEscape: () => void }) {
useEventListener("keydown", (e) => {
if (e.key === "Escape") onEscape();
});
return null;
}Detalhes chave:
handlerRef: o listener é estável (nunca reanexado), mas sempre chama o último handler. Isso evita closures obsoletas sem exigir handler no array de dependências.K extends keyof WindowEventMap fornece autocompletar completo em nomes de eventos e objetos de evento tipados.O prefixo use é obrigatório. O linter do React e as regras de hooks dependem dele. toggleState() não receberá avisos do linter se você quebrar as regras de hooks dentro dela.
Não chame hooks condicionalmente dentro do seu hook personalizado. As mesmas regras se aplicam - hooks devem ser chamados na mesma ordem a cada renderização.
Closures obsoletas em efeitos. Se seu hook captura um callback prop em useEffect, use um ref (useRef + useEffect para mantê-lo atualizado) em vez de adicionar o callback ao array de dependências.
Retornar novos objetos/arrays a cada renderização. Se seu hook retorna { value, toggle } sem useMemo, cada consumidor re-renderiza quando o pai do hook re-renderiza - mesmo que nada tenha mudado.
Limpeza ausente. Esquecer de limpar timers, remover listeners ou abortar fetches causa vazamentos de memória e atualizações de estado em componentes desmontados.
Discrepâncias de hidratação SSR. Hooks que leem window, document, localStorage ou matchMedia precisam de guardas (typeof window === "undefined") e, às vezes, uma estratégia de renderização em duas passagens.
Uso excessivo de hooks personalizados. Nem tudo precisa ser um hook. Se a lógica for pura (sem hooks dentro), torne-a uma função simples. Se for usada apenas em um componente e improvável de ser reutilizada, mantenha-a inline.
Inferência de tipo genérico. Ao usar genéricos como useLocalStorage<T>, certifique-se de que o valor inicial corresponda ao genérico ou o TypeScript inferirá unknown. Forneça argumentos de tipo explícitos quando o valor inicial for ambíguo (por exemplo, null).
Dependências de efeito com objetos/arrays. Passar um objeto ou array como dependência para useEffect dentro de um hook personalizado faz com que ele seja reexecutado a cada renderização (nova referência). Desestruture para primitivos ou use uma utilidade de comparação profunda.
Testando hooks personalizados. Use renderHook de @testing-library/react - você não pode chamar hooks fora de um componente. Envolva em act() ao acionar atualizações de estado.
Revisado por Chris St. John·Última atualização: 10 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥