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
Ligações
Contexto
Seção intitulada “Contexto”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
loaderDepse da chave da query (ADR-0012) - Escritas: server functions
POSTchamadas a partir deuseMutation; 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,refe 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/pacer0.23.1 e@tanstack/react-pacer0.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
Opções consideradas
Seção intitulada “Opções consideradas”Biblioteca
Seção intitulada “Biblioteca”1. TanStack Pacer (adaptador React)
Seção intitulada “1. TanStack Pacer (adaptador React)”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, comflush,canceleisPending). As variantes assíncronas acrescentam resultados comawait,onError/onSuccess/onSettled, retries e abort através de umAbortSignal - 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 comPacerProvider - 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-pacerdepende exatamente do@tanstack/pacer0.23.1 e reexporta-o
Contras
- Beta, e já quebrou dentro de uma minor 0.x: o
@tanstack/pacer0.23.0 (2026-09-28) passou a ser só ESM, deixou de publicar CommonJS e renomeou campos de estado (executeCountparaexecutionCount,settledCountparasettleCount). 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
2. use-debounce
Seção intitulada “2. use-debounce”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.
3. lodash-es (debounce, throttle)
Seção intitulada “3. lodash-es (debounce, throttle)”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.
4. es-toolkit
Seção intitulada “4. es-toolkit”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.
Não é uma biblioteca
Seção intitulada “Não é uma biblioteca”A. Um hook escrito à mão
Seção intitulada “A. Um hook escrito à mão”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.
B. useDeferredValue do React
Seção intitulada “B. useDeferredValue do React”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.
Decisão
Seção intitulada “Decisão”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-pacer0.24.1, instalado combun add --exact. Reexporta o core e fixa ele próprio o@tanstack/pacer0.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.
useDebouncedValueouuseDebouncedCallbackpor predefinição;useDebouncersó quando o código precisa deflush,cancelouisPending; os hooks assíncronos só para efeitos secundários assíncronos, nunca para obter dados - Campos de pesquisa: o rascunho fica em
useStatelocal, para que escrever seja instantâneo. OuseDebouncedValueproduz o valor confirmado. Os filtros partilháveis escrevem-no nos search params do URL comnavigate({ search, replace: true })—replacepara que cada pausa não acrescente uma entrada ao histórico — e seguem porloaderDepsaté à chave da query (ADR-0012). Os filtros só locais passam o valor confirmado diretamente para a chave da query. AcrescentarplaceholderData: keepPreviousDatapara 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
useAsyncDebouncerpara 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
useMutationcom debounce. O trabalho pendente perde-se ao desmontar e ao navegar, pelo que se fazflush()explícito no blur e à saída da rota. As escritas continuam a passar por server functionsPOSTe pelo limitador do servidor (ADR-0010); tratar o429com o seuRetry-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 temmaxWait - 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
defaultOptionsnoPacerProviderna raiz (debouncer.waitde 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, umInputdo shadcn/ui (ADR-0011), reencaminhavalue,onChange,refeclassNamee 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
useDeferredValuecontinua 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.
Consequências
Seção intitulada “Consequências”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-debouncecobre 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
lastResultanterior — 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.

