Pular para o conteúdo

ADR-0023 Debounce e throttle

Debounce e throttle padrão da empresa — os hooks React do TanStack Pacer, fixados em versões exatas enquanto for pré-1.0; os limites do lado do servidor ficam com o ADR-0010.

Decisão
TanStack Pacer, fixadoConstruir a app
Estado
Aceite
Data
Âmbito
Predefinição da empresa para todas as novas apps

Os eventos do browser chegam mais depressa do que qualquer coisa deveria reagir a eles: uma tecla premida por cada carácter, dezenas de eventos de scroll e de resize por segundo, um autosave a cada edição. Sem um filtro, cada um deles vira uma renderização, uma escrita no URL ou um pedido. As decisões à volta estão tomadas:

  • Leituras e estado do URL: os filtros vivem no URL e chegam ao Query através de loaderDeps e da chave da query (ADR-0012)
  • Escritas: server functions POST chamadas a partir de useMutation; toda a escrita passa pelo limitador do servidor (ADR-0010), cujos limites iniciais são 100 escritas por 60 s por IP e 300 por 60 s por utilizador
  • Componentes: shadcn/ui sobre Base UI, com className, ref e handlers de eventos reencaminhados (ADR-0011)

O que está em aberto: que biblioteca fornece o debounce e o throttle, como se liga ao Query e ao URL, e onde acaba o controlo de tempo no cliente e começam os limites do servidor.

Restrições:

  • Duas coisas diferentes chamam-se “rate limiting”. O limitador do lado do servidor (ADR-0010) trava abusos. Um limitador do lado do cliente apenas molda o que um browser bem-comportado envia — qualquer cliente o pode ignorar, pelo que não protege o servidor de nada
  • O Pacer é pré-1.0 e di-lo. A sua visão geral chama-lhe beta, com uma API “still subject to change”; os pacotes são @tanstack/pacer 0.23.1 e @tanstack/react-pacer 0.24.1 (publicados em 2026-10-05). A política para pré-1.0 é a que o ADR-0008 definiu para o Drizzle v1 enquanto era release candidate: adotar, fixar versões exatas, atualizar de forma deliberada

Debouncing, throttling, rate limiting, queuing e batching numa só API, em variantes síncronas e assíncronas.

Prós

  • Uma só API para debounce e throttle, a três níveis: um callback (useDebouncedCallback), um valor (useDebouncedValue) ou a instância (useDebouncer, com flush, cancel e isPending). As variantes assíncronas acrescentam resultados com await, onError / onSuccess / onSettled, retries e abort através de um AbortSignal
  • Os hooks mantêm uma só instância entre renderizações e cancelam o trabalho pendente quando o componente desmonta — as duas coisas que as versões escritas à mão mais vezes erram
  • Estado pendente que se pode renderizar (isPending, isExecuting), subscrito apenas nos campos que selecionamos; opções predefinidas definidas uma só vez com PacerProvider
  • Da mesma família de projetos do Router, do Query e do Form — a visão geral do Pacer diz que o seu código foi extraído deles. MIT; intervalo de peer react >=16.8; o @tanstack/react-pacer depende exatamente do @tanstack/pacer 0.23.1 e reexporta-o

Contras

  • Beta, e já quebrou dentro de uma minor 0.x: o @tanstack/pacer 0.23.0 (2026-09-28) passou a ser só ESM, deixou de publicar CommonJS e renomeou campos de estado (executeCount para executionCount, settledCount para settleCount). Até o patch 0.23.1 acrescentou seis adaptadores de frameworks e mudou a forma como os snapshots do estado da fila são substituídos
  • Sem maxWait: um fluxo contínuo de chamadas pode adiar um debounce trailing para sempre. O guia do Pacer aponta o throttling para quando o trabalho tem de correr a intervalos limitados
  • O unmount cancela o trabalho pendente; não o executa. Uma chamada trailing perde-se a não ser que a executemos nós com flush
  • Pela sua própria descrição, “mostly a client-side library today” — usamo-lo só para eventos do browser

Hooks useDebounce, useDebouncedCallback e useThrottledCallback; maxWait, cancel, flush e isPending. 10.1.1, MIT.

Prós: faz debounce e throttle para React e mais nada; major estável; tem maxWait. Contras: sem tratamento de erros assíncronos, retry nem abort; nada para rate limiting, queuing ou batching; outro mantenedor e outro ciclo de lançamentos. Só para debounce e throttle é hoje tão bom como o Pacer — e é o nosso plano B se o Pacer desiludir.

4.18.1, MIT. Opções leading, trailing e maxWait; cancel() e flush() na função devolvida.

Prós: omnipresente e testado em batalha; tem maxWait. Contras: funções simples, sem noção de React. Cada utilização precisa de manter a instância estável entre renderizações, cancelá-la ao desmontar e manter o callback atualizado — o problema do hook escrito à mão outra vez. Sem estado pendente, sem tratamento assíncrono, sem abort.

1.52.0, MIT. O debounce aceita um AbortSignal e edges (leading / trailing) e devolve cancel() e flush(); o throttle vem ao lado.

Prós: moderno e tipado; suporte para abort. Contras: outra vez funções simples, com o mesmo trabalho de ciclo de vida do React que o lodash-es. Adotá-lo para duas funções significa adotar uma biblioteca de utilitários geral, o que é uma decisão à parte que não tomámos.

Cerca de quinze linhas de setTimeout, useRef e limpeza.

Prós: zero dependências e zero risco de pré-1.0. Contras: cada produto reescreve as extremidades leading/trailing, cancel, flush, estado pendente e abort, e erra uma delas de forma subtil. Sem throttle, a não ser que alguém o escreva também. O comportamento difere de produto para produto.

Não depende do tempo: o React renderiza o valor diferido em segundo plano, de forma interrompível, sem atraso fixo.

Prós: nada para afinar, adapta-se ao dispositivo, já vem incluído. Contras: a documentação do React é explícita: “does not by itself prevent extra network requests”. Chega quando a parte cara é renderizar a partir de dados que já estão em memória, como filtrar uma lista local grande. Não chega quando o input dispara um pedido, uma escrita no URL ou uma mutação — esses precisam de um atraso a sério.

O TanStack Pacer, através do @tanstack/react-pacer fixado em versões exatas, é o padrão da empresa para debounce e throttle em todas as novas apps. O rate limiter, o queuer e o batcher do Pacer estão disponíveis, mas só do lado do cliente.

Em concreto:

  • Pacote: @tanstack/react-pacer 0.24.1, instalado com bun add --exact. Reexporta o core e fixa ele próprio o @tanstack/pacer 0.23.1, pelo que o core não se instala à parte
  • Fixar versões exatas (sem ^) enquanto o Pacer for pré-1.0; atualizar de forma deliberada, como no ADR-0008. O Renovate (ADR-0016) abre o PR mas nunca o faz merge automático; uma pessoa lê primeiro as notas de lançamento. Reavaliar na 1.0
  • Escolher o hook mais estreito. useDebouncedValue ou useDebouncedCallback por predefinição; useDebouncer só quando o código precisa de flush, cancel ou isPending; os hooks assíncronos só para efeitos secundários assíncronos, nunca para obter dados
  • Campos de pesquisa: o rascunho fica em useState local, para que escrever seja instantâneo. O useDebouncedValue produz o valor confirmado. Os filtros partilháveis escrevem-no nos search params do URL com navigate({ search, replace: true }) — replace para que cada pausa não acrescente uma entrada ao histórico — e seguem por loaderDeps até à chave da query (ADR-0012). Os filtros só locais passam o valor confirmado diretamente para a chave da query. Acrescentar placeholderData: keepPreviousData para que a lista não fique em branco entre chaves. Fazer debounce da escrita no URL; nunca do loader
  • O Pacer nunca guarda dados do servidor. Não usar useAsyncDebouncer para obter dados para estado de componente, como faz o próprio quick start do Pacer. O Query é dono do estado do servidor, com cache e deduplicação (ADR-0012); o Pacer só decide quando a chave muda
  • Escritas com debounce não são escritas garantidas. O autosave é uma chamada de useMutation com debounce. O trabalho pendente perde-se ao desmontar e ao navegar, pelo que se faz flush() explícito no blur e à saída da rota. As escritas continuam a passar por server functions POST e pelo limitador do servidor (ADR-0010); tratar o 429 com o seu Retry-After. Nunca repetir uma escrita automaticamente a não ser que seja idempotente — o guia de retry do próprio Pacer avisa que as escritas podem criar duplicados
  • Throttle para eventos contínuos (scroll, resize, movimento do ponteiro) com useThrottledCallback: a primeira chamada corre logo e a última chamada seguinte corre no trailing edge. Usar throttle, não debounce, sempre que o trabalho tem de continuar a correr enquanto chegam eventos, porque o debounce não tem maxWait
  • Fronteira do rate limiting. Os limites do servidor (ADR-0010) são os únicos que contam; a proteção contra inundações é do reverse proxy (ADR-0017). O rate limiter do Pacer é um contador em memória, no browser; o seu guia diz que não é “a distributed quota or enforcement system”. Usá-lo só para UX — por exemplo, desativar um botão de envio com “tente de novo daqui a N s” — e nunca para substituir ou relaxar um limite do servidor, nem para barrar o que quer que seja relacionado com segurança
  • Os valores de espera definem-se uma vez, não se espalham. Definir defaultOptions no PacerProvider na raiz (debouncer.wait de 300 ms para começar); os hooks sobrepõem com constantes nomeadas onde um caso de uso difere (cerca de 1000 ms para autosave, 200 ms para throttles de scroll e resize). São valores iniciais tirados dos próprios exemplos do Pacer, não medições — ajustar por produto
  • SOLID: os hooks são donos do tempo e do estado; os componentes ficam apresentacionais. Um hook de funcionalidade (por exemplo useSearchFilter) é dono do rascunho, do debounce e da escrita no URL, e devolve { value, onChange, isPending }. O input, um Input do shadcn/ui (ADR-0011), reencaminha value, onChange, ref e className e não sabe nada de atrasos. Os componentes nunca importam @tanstack/react-pacer; só os hooks o fazem, pelo que sair do Pacer é uma alteração dentro de poucos hooks. Um hook fino por caso de uso, não um wrapper genérico
  • O useDeferredValue continua disponível só para o custo de renderização. Usá-lo por cima de um valor com debounce apenas quando a renderização depois do atraso continua cara

Os fatores decisivos: um ciclo de vida testado para debounce e throttle que limpa depois de si, trabalho assíncrono com abort e estado pendente quando um caso de uso precisa, e a mesma família de projetos do Router, do Query e do Form. Aceitamos o risco de pré-1.0, a falta de maxWait e um ecossistema mais pequeno do que o do lodash.

Mais fácil:

  • Uma só API e um só ciclo de vida para debounce e throttle em todos os produtos; o trabalho pendente é cancelado ao desmontar
  • Pesquisa e filtros: escrita instantânea, um pedido por pausa, um URL linkável e a cache do Query a tratar dos dados
  • O estado pendente pode ser renderizado (“A pesquisar…”, “Alterações por guardar…”) sem flags feitas à mão
  • Sair é barato: o Pacer fica atrás de poucos hooks, e o use-debounce cobre a mesma superfície de debounce e throttle

Mais difícil:

  • Risco de pré-1.0, dito às claras. A documentação do Pacer chama-lhe beta, e as suas minors alteram APIs (a 0.23.0 renomeou campos de estado e passou a só ESM em 2026-09-28). Versões fixadas significam sem correções automáticas: cada atualização é um PR deliberado, verificado contra as notas de lançamento e os testes
  • Gatilhos de reavaliação: o Pacer chega à 1.0 (planear a janela de atualização e rever a política de fixação, como com o Drizzle no ADR-0008); uma atualização quebra uma API que usamos e corrigir custa mais do que mover os poucos hooks para o use-debounce; ou o projeto deixa de lançar versões
  • As chamadas trailing perdem-se ao desmontar a não ser que se faça flush, e os hooks síncronos não apanham erros lançados pelo callback. Nas variantes assíncronas, a promise de uma chamada substituída resolve logo com o lastResult anterior — só a promise da última chamada traz o novo resultado
  • Sem maxWait: uma rajada longa de chamadas pode adiar indefinidamente uma execução com debounce; escolher throttle quando importa haver um limite
  • Os exemplos online podem ser anteriores às renomeações; consultar a documentação da versão fixada, não a latest
  • Um limitador do lado do cliente pode dar uma falsa sensação de segurança. Rever qualquer alteração que remova ou afrouxe um limite do servidor “porque o cliente já limita”
  • O código dependente de tempo é difícil de testar; manter os valores de espera em constantes nomeadas e testar o hook da funcionalidade, não a biblioteca

Do que abdicamos: dos majors estáveis e do maxWait do use-debounce e do lodash-es, e da simplicidade sem risco de um hook escrito à mão. Aceite — uma API com noção de ciclo de vida, com suporte para async e abort, da família de projetos que já usamos, justifica a fixação em pré-1.0 e uma atualização planeada na 1.0.