Saltar para o conteúdo
Custom Hooks: Extraindo Lógica Reutilizável no React
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.