Otimização de buscas aplicando debounce em inputs

Reduza a carga de trabalho na sua aplicação e melhore a experiência dos usuários.

Atualizado em

Provavelmente você já deve ter se deparado com inputs de buscas como o do Twitter ou Spotify: você digita sua pesquisa e, após um curto período, a aplicação retorna os resultados. Esse comportamento, conhecido como debounce, auxilia as aplicações a reduzirem a sobrecarga no front e back-end, uma vez que o front-end limita a quantidade de execução de funções ou mesmo requisições ao servidor a cada tecla digitada. De modo geral, o front-end observa se há um intervalo, como por exemplo 500ms, sem nenhuma tecla ser digitada, e só então executa uma função como uma chamada à API. Dessa forma, você pode aumentar a performance da sua aplicação, principalmente em cenários com muitos usuários utilizando simultaneamente.

Um pouco de contexto

Apesar do debounce ser comumente implementado em inputs de buscas e filtros, o termo tem sua origem na eletrônica. Com um comportamento fundamentalmente similar ao aplicado no desenvolvimento de software, o debounce surgiu como uma solução para o comportamento indesejado de múltiplos cliques de um botão. Por exemplo, ao apertar o botão de um dispositivo eletrônico, como um controle de video game, o sinal é processado diversas de vezes antes que o usuário consiga parar de pressionar o botão seja conta de imperfeições na placa de circuito eletrônico ou mesmo pela velocidade dos microprocessadores.

Imagem exemplificando o efeito de debounce aplicado em circuitos eletrônicos.

Exemplo de input com debounce

Nenhuma explicação será tão eficaz quando a experiência, então deixo abaixo um exemplo comparativo entre um input comum e um input com debounce. Caso queria ver o código fonte, ele está disponível nesse repositório, mas, de ante mão adianto que toda a lógica utilizada nele será abordada ao longo desse artigo.

Resultado:
Resultado:

Implementando debounce com JavaScript

Para iniciar, vamos trabalhar com JavaScript puro utilizando a API do DOM e exibir o resultado no console do navegador. Para isso precisamos ter acesso ao nosso input e criar uma função para lidar com o evento keyup:

index.js
function handleKeyUp(event) {
	console.log(event.target.value)
}
 
document.querySelector('input').addEventListener('keyup', handleKeyUp)

Após isso é necessário adicionar um delay à nossa função, dessa forma a função para lidar com esse evento só será executada após um intervalo de tempo:

index.js
function handleKeyUp(event) {
	setTimeout(() => {
		console.log(event.target.value)
	}, 500)
}
 
document.querySelector('input').addEventListener('keyup', handleKeyUp)

No entanto, perceba que isso gerou um bug, pois ao passar o intervalo de 500ms o texto do input é exibido diversas vezes no console. Para corrigir esse problema, é necessário cancelar o timeout criado anteriormente, caso uma nova tecla seja pressionada. Para isso, podemos utilizar o timeoutID retornado pela função setTimeout() e passá-lo como parâmetro na função clearTimeout(), da forma recomendada pela documentação da MDN:

index.js
let timeoutId
 
function handleKeyUp(event) {
	clearTimeout(timeoutId)
 
	timeoutId = setTimeout(() => {
		console.log(event.target.value)
	}, 500)
}
 
document.querySelector('input').addEventListener('keyup', handleKeyUp)

Observe que a variável timeoutId foi mantida fora da função handleKeyUp() para que seu valor seja preservado mesmo que a função seja chamada mais de uma vez, como no caso de novas teclas sendo pressionadas.

Dessa forma, terminamos de implementar o debounce no nosso input. Contudo, não conseguimos reutilizar o mesmo comportamento em outros contextos uma vez que ele está acoplado à lógica da função handleKeyUp(). Sendo assim, podemos melhorar nossa implementação abstraindo o código responsável pelo debounce:

index.js
function debounce(callback, delay = 500) {
	let timeoutId
 
	return (...args) => {
		clearTimeout(timeoutId)
 
		timeoutId = setTimeout(() => {
			callback(...args)
		}, delay)
	}
}
 
function handleKeyUp(event) {
	console.log(event.target.value)
}
 
document.querySelector('input').addEventListener('keyup', debounce(handleKeyUp))

Desse modo, desacoplamos toda a lógica de debounce em uma função chamada debounce()que pode ser reutilizada em outros contextos da aplicação, como cliques em botão e mudanças da dimensão da janela da aplicação. Perceba também que a função debounce() utiliza os conceitos de escopo léxico e closures. Sendo assim, ela retorna uma arrow function enquanto protege o valor a variável local timeoutId, uma vez que o valor de timeoutId vai persistir mesmo sendo reutilizado durante as chamadas da arrow function retornada.

Implementando debounce com React

Agora que vimos como implementar o debounce com JavaScript vanilla, chegou a hora que aprofundar um pouco mais e implementar um hook de debounce para uma aplicação desenvolvida com React. Para isso, precisamos do nosso input para aplicar o debounce:

App.jsx
import { useState } from 'react'
 
export function App() {
	const [value, setValue] = useState('')
 
	function handleInputChange(event) {
		setValue(event.target.value)
	}
 
	return <input type="text" value={value} onChange={handleInputChange} />
}

No código acima criamos um input que utiliza o conceito de controlled components, utilizando um estado para persistir seu valor durante as renderizações da aplicação. Após isso podemos focar na implementação do hook:

usDebounce.js
import { useRef } from 'react'
 
export function useDebounce(callback, delay = 1000) {
	const timeoutId = useRef(null)
 
	return (...args) => {
		if (timeoutId.current) {
			clearTimeout(timeoutId.current)
		}
 
		timeoutId.current = setTimeout(() => {
			callback(...args)
		}, delay)
	}
}

O hook useDebounce() segue a mesma lógica desenvolvida anteriormente para a função debounce(). A sua diferença fica por conta do uso de uma referência utilizada para persistir o valor do timeoutId sem disparar novas renderizações a cada mudança em seu valor. Agora podemos utilizar o hook useDebounce() para atualizar um novo estado, uma vez que o estado value continuará sendo utilizado para o feedback em tempo real do preenchimento do input:

App.jsx
import { useEffect, useState } from 'react'
import { useDebounce } from './useDebounce.js'
 
export function App() {
	const [value, setValue] = useState('')
	const [debouncedValue, setDebouncedValue] = useState('')
 
	const handleDebouncedInputChange = useDebounce(setDebouncedValue)
 
	function handleInputChange(event) {
		setValue(event.target.value)
		handleDebouncedInputChange(event.target.value)
	}
 
	useEffect(() => {
		console.log(debouncedValue)
	}, [debouncedValue])
 
	return <input type="text" value={value} onChange={handleInputChange} />
}

Dessa forma, a cada 500ms sem digitar uma tecla, o valor do estado debouncedValue será atualizado e exibido no console do navegador. Vale notar que é necessário o uso de dois estados diferentes: uma para o preenchimento e feedback visual do input, e outro para uso em outras funções, como chamadas à uma API externa.

Conclusão

Ao implementar o debounce para eventos que ocorrem com frequência, como preenchimento de inputs de busca, é possível otimizar a performance da sua aplicação ao evitar múltiplas execuções de uma função. Vimos como criar uma função de debounce com JavaScript puro e um hook para aplicações em React. Contudo, essa mesma solução já foi desenvolvida por outras bibliotecas, como a lodash, prevendo outros contextos e caso de uso. Sendo assim, é possível avaliar se há a necessidade de implementar esse efeito, ou se é melhor utilizar uma biblioteca de terceiros para solucionar o seu problema.