useDebounce Hook

Hook personalizado para implementar debounce em valores e funções, muito útil para otimizar chamadas de API e melhorar a performance da aplicação.

Implementação do Hook

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

/**
 * Hook para debounce de valores
 * @param value - Valor a ser "debouncado"
 * @param delay - Delay em milissegundos
 * @returns Valor debouncado
 */
export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    // Cria um timer que atualiza o valor debouncado após o delay
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // Limpa o timer se o valor mudar antes do delay
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debouncedValue;
}

/**
 * Hook para debounce de funções
 * @param callback - Função a ser executada
 * @param delay - Delay em milissegundos
 * @param deps - Dependências da função
 * @returns Função debouncada
 */
export function useDebouncedCallback<T extends (...args: any[]) => any>(
  callback: T,
  delay: number,
  deps: React.DependencyList = []
): T {
  const [debouncedCallback, setDebouncedCallback] = useState<T | null>(null);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedCallback(() => callback);
    }, delay);

    return () => {
      clearTimeout(handler);
    };
  }, [callback, delay, ...deps]);

  return debouncedCallback || callback;
}

Exemplo Prático - Busca com API

components/SearchInput.tsxtypescript
import React, { useState, useEffect } from 'react';
import { useDebounce } from '../hooks/useDebounce';

interface SearchResult {
  id: string;
  title: string;
  description: string;
}

export function SearchInput() {
  const [searchTerm, setSearchTerm] = useState('');
  const [results, setResults] = useState<SearchResult[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  
  // Debounce do termo de busca com 500ms de delay
  const debouncedSearchTerm = useDebounce(searchTerm, 500);

  useEffect(() => {
    if (debouncedSearchTerm) {
      searchAPI(debouncedSearchTerm);
    } else {
      setResults([]);
    }
  }, [debouncedSearchTerm]);

  const searchAPI = async (term: string) => {
    setIsLoading(true);
    try {
      const response = await fetch(`/api/search?q=${encodeURIComponent(term)}`);
      const data = await response.json();
      setResults(data.results);
    } catch (error) {
      console.error('Erro na busca:', error);
      setResults([]);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div className="search-container">
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Digite para buscar..."
        className="search-input"
      />
      
      {isLoading && <div className="loading">Buscando...</div>}
      
      <div className="results">
        {results.map((result) => (
          <div key={result.id} className="result-item">
            <h3>{result.title}</h3>
            <p>{result.description}</p>
          </div>
        ))}
      </div>
    </div>
  );
}
Dica
No exemplo acima, a API só é chamada 500ms após o usuário parar de digitar, evitando chamadas desnecessárias e melhorando a performance.

Versão Avançada com Cancelamento

hooks/useAdvancedDebounce.tstypescript
import { useState, useEffect, useRef, useCallback } from 'react';

export function useAdvancedDebounce<T>(
  value: T,
  delay: number,
  options: {
    leading?: boolean; // Executa imediatamente na primeira chamada
    trailing?: boolean; // Executa após o delay (padrão)
    maxWait?: number; // Tempo máximo para aguardar
  } = {}
) {
  const { leading = false, trailing = true, maxWait } = options;
  
  const [debouncedValue, setDebouncedValue] = useState<T>(value);
  const timeoutRef = useRef<NodeJS.Timeout>();
  const maxTimeoutRef = useRef<NodeJS.Timeout>();
  const lastCallTimeRef = useRef<number>();
  const lastInvokeTimeRef = useRef<number>(0);

  const invokeFunc = useCallback(() => {
    setDebouncedValue(value);
    lastInvokeTimeRef.current = Date.now();
  }, [value]);

  const leadingEdge = useCallback(() => {
    lastInvokeTimeRef.current = Date.now();
    if (leading) {
      invokeFunc();
    }
  }, [leading, invokeFunc]);

  const remainingWait = useCallback((time: number) => {
    const timeSinceLastCall = time - (lastCallTimeRef.current || 0);
    const timeSinceLastInvoke = time - lastInvokeTimeRef.current;
    const timeWaiting = delay - timeSinceLastCall;

    return maxWait !== undefined
      ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
      : timeWaiting;
  }, [delay, maxWait]);

  const shouldInvoke = useCallback((time: number) => {
    const timeSinceLastCall = time - (lastCallTimeRef.current || 0);
    const timeSinceLastInvoke = time - lastInvokeTimeRef.current;

    return (
      lastCallTimeRef.current === undefined ||
      timeSinceLastCall >= delay ||
      timeSinceLastCall < 0 ||
      (maxWait !== undefined && timeSinceLastInvoke >= maxWait)
    );
  }, [delay, maxWait]);

  const timerExpired = useCallback(() => {
    const time = Date.now();
    if (shouldInvoke(time)) {
      if (trailing) {
        invokeFunc();
      }
      if (maxTimeoutRef.current) {
        clearTimeout(maxTimeoutRef.current);
      }
      return;
    }
    
    const remaining = remainingWait(time);
    timeoutRef.current = setTimeout(timerExpired, remaining);
  }, [shouldInvoke, trailing, invokeFunc, remainingWait]);

  useEffect(() => {
    const time = Date.now();
    lastCallTimeRef.current = time;

    if (shouldInvoke(time)) {
      leadingEdge();
    }

    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
    
    timeoutRef.current = setTimeout(timerExpired, delay);

    if (maxWait !== undefined && !maxTimeoutRef.current) {
      maxTimeoutRef.current = setTimeout(() => {
        if (trailing) {
          invokeFunc();
        }
      }, maxWait);
    }

    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
      if (maxTimeoutRef.current) {
        clearTimeout(maxTimeoutRef.current);
      }
    };
  }, [value, delay, shouldInvoke, leadingEdge, timerExpired, trailing, invokeFunc, maxWait]);

  const cancel = useCallback(() => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
    if (maxTimeoutRef.current) {
      clearTimeout(maxTimeoutRef.current);
    }
    lastInvokeTimeRef.current = 0;
    lastCallTimeRef.current = undefined;
  }, []);

  const flush = useCallback(() => {
    if (timeoutRef.current) {
      invokeFunc();
      cancel();
    }
  }, [invokeFunc, cancel]);

  return {
    debouncedValue,
    cancel,
    flush
  };
}

Casos de Uso Comuns

1. Validação de Formulário

components/FormValidation.tsxtypescript
function FormValidation() {
  const [email, setEmail] = useState('');
  const [isValid, setIsValid] = useState<boolean | null>(null);
  const debouncedEmail = useDebounce(email, 300);

  useEffect(() => {
    if (debouncedEmail) {
      validateEmail(debouncedEmail);
    }
  }, [debouncedEmail]);

  const validateEmail = async (email: string) => {
    const isValidEmail = /^[^s@]+@[^s@]+.[^s@]+$/.test(email);
    setIsValid(isValidEmail);
  };

  return (
    <div>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Digite seu email"
      />
      {isValid === false && <span>Email inválido</span>}
      {isValid === true && <span>Email válido</span>}
    </div>
  );
}

2. Redimensionamento de Janela

hooks/useWindowSize.tstypescript
function useWindowSize() {
  const [windowSize, setWindowSize] = useState({
    width: typeof window !== 'undefined' ? window.innerWidth : 0,
    height: typeof window !== 'undefined' ? window.innerHeight : 0,
  });

  const debouncedSize = useDebounce(windowSize, 150);

  useEffect(() => {
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }

    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return debouncedSize;
}

Testes

__tests__/useDebounce.test.tstypescript
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from '../hooks/useDebounce';

jest.useFakeTimers();

describe('useDebounce', () => {
  it('should debounce value changes', () => {
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: 'initial', delay: 500 } }
    );

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

    // Muda o valor
    rerender({ value: 'updated', delay: 500 });
    expect(result.current).toBe('initial'); // Ainda não mudou

    // Avança o tempo
    act(() => {
      jest.advanceTimersByTime(500);
    });

    expect(result.current).toBe('updated'); // Agora mudou
  });

  it('should cancel previous timeout on rapid changes', () => {
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: 'first', delay: 500 } }
    );

    rerender({ value: 'second', delay: 500 });
    
    act(() => {
      jest.advanceTimersByTime(250); // Meio caminho
    });
    
    rerender({ value: 'third', delay: 500 });
    
    act(() => {
      jest.advanceTimersByTime(500); // Completa o segundo timeout
    });

    expect(result.current).toBe('third'); // Deve ser o último valor
  });
});

Conclusão

O hook useDebounce é uma ferramenta essencial para otimização de performance, especialmente útil em:

  • Campos de busca e autocomplete
  • Validação de formulários em tempo real
  • Eventos de scroll e resize
  • Chamadas de API frequentes