React
Custom Hooks: Extraindo Lógica Reutilizável no React
25 de agosto de 2024·Paulo de Paula
Custom hooks não são magia — são funções JavaScript que começam com use e podem chamar outros hooks. A regra de ouro: extraia lógica stateful, não UI. Se não há estado ou efeito, uma função comum resolve.
useDebounce — atrasa a execução
import { useState, useEffect } from 'react';
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// Uso: evita busca a cada tecla pressionada
function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 400);
useEffect(() => {
if (debouncedQuery) fetchResults(debouncedQuery);
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}useLocalStorage — persistência com segurança SSR
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
// SSR safety: localStorage não existe no servidor
if (typeof window === 'undefined') return initialValue;
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.warn(`useLocalStorage: erro ao salvar chave "${key}"`, error);
}
};
return [storedValue, setValue] as const;
}
// Uso
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'dark');useFetch — busca com AbortController
interface FetchState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
function useFetch<T>(url: string | null) {
const [state, setState] = useState<FetchState<T>>({
data: null, loading: false, error: null,
});
useEffect(() => {
if (!url) return;
const controller = new AbortController();
setState({ data: null, loading: true, error: null });
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
})
.then(data => setState({ data, loading: false, error: null }))
.catch(err => {
if (err.name === 'AbortError') return; // ignorar cancelamento
setState({ data: null, loading: false, error: err });
});
// Cancela a requisição se o componente desmontar ou url mudar
return () => controller.abort();
}, [url]);
return state;
}
// Uso
function UserProfile({ userId }: { userId: string }) {
const { data, loading, error } = useFetch<User>(`/api/users/${userId}`);
if (loading) return <Skeleton />;
if (error) return <p>Erro: {error.message}</p>;
if (!data) return null;
return <div>{data.name}</div>;
}useIntersectionObserver — lazy load e scroll infinito
interface IntersectionOptions extends IntersectionObserverInit {
freezeOnceVisible?: boolean;
}
function useIntersectionObserver(
elementRef: React.RefObject<Element>,
options: IntersectionOptions = {},
): IntersectionObserverEntry | undefined {
const { threshold = 0, root = null, rootMargin = '0%', freezeOnceVisible = false } = options;
const [entry, setEntry] = useState<IntersectionObserverEntry>();
const frozen = entry?.isIntersecting && freezeOnceVisible;
useEffect(() => {
const node = elementRef?.current;
if (!node || frozen) return;
const observer = new IntersectionObserver(
([entry]) => setEntry(entry),
{ threshold, root, rootMargin },
);
observer.observe(node);
return () => observer.disconnect();
}, [elementRef, threshold, root, rootMargin, frozen]);
return entry;
}
// Scroll infinito
function PostList() {
const loaderRef = useRef<HTMLDivElement>(null);
const entry = useIntersectionObserver(loaderRef, { threshold: 0.1 });
useEffect(() => {
if (entry?.isIntersecting) loadMorePosts();
}, [entry?.isIntersecting]);
return (
<>
{posts.map(p => <PostCard key={p.id} post={p} />)}
<div ref={loaderRef} />
</>
);
}useMediaQuery — lógica responsiva em JS
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
const mediaQuery = window.matchMedia(query);
setMatches(mediaQuery.matches);
const listener = (e: MediaQueryListEvent) => setMatches(e.matches);
mediaQuery.addEventListener('change', listener);
return () => mediaQuery.removeEventListener('change', listener);
}, [query]);
return matches;
}
// Uso
const isMobile = useMediaQuery('(max-width: 768px)');
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');Quando NÃO criar um hook
// ❌ Hook desnecessário — não há estado ou efeito
function useFormatCurrency(value: number, currency = 'BRL'): string {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency }).format(value);
}
// ✅ Função comum — mais simples e igualmente reutilizável
function formatCurrency(value: number, currency = 'BRL'): string {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency }).format(value);
}Regra prática: se a função não chama useState, useEffect ou outro hook, não precisa do prefixo use.
Testando com renderHook
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from './useDebounce';
describe('useDebounce', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it('retorna o valor imediatamente na primeira renderização', () => {
const { result } = renderHook(() => useDebounce('hello', 400));
expect(result.current).toBe('hello');
});
it('não atualiza antes do delay', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 400),
{ initialProps: { value: 'hello' } },
);
rerender({ value: 'world' });
act(() => jest.advanceTimersByTime(300));
expect(result.current).toBe('hello'); // ainda não atualizou
});
it('atualiza após o delay', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 400),
{ initialProps: { value: 'hello' } },
);
rerender({ value: 'world' });
act(() => jest.advanceTimersByTime(400));
expect(result.current).toBe('world');
});
});A principal vantagem de custom hooks bem projetados: você testa a lógica independentemente do componente que a usa.