Técnica de debounce en el buscador
Introducción al problema
Ya tenemos varios filtros implementados: custom hooks, paginación, loading, y más. Pero falta uno clave: el filtro por texto en tiempo real.
El problema es que actualmente, cada tecla que el usuario escribe dispara una llamada a la API. Si escribes “React”, se generan 5 peticiones diferentes (una por cada letra), cuando en realidad solo necesitamos una al final.
¿Por qué es un problema?
Hacer múltiples peticiones seguidas mientras el usuario escribe causa varios problemas:
1. Consumo innecesario de recursos
Cada petición consume:
- Ancho de banda
- Procesamiento del servidor
- Tiempo de respuesta
Si 1000 usuarios buscan simultáneamente, el problema se multiplica.
2. Peticiones duplicadas
// Usuario escribe "React"
fetch('/api?text=R') // Petición 1
fetch('/api?text=Re') // Petición 2
fetch('/api?text=Rea') // Petición 3
fetch('/api?text=Reac') // Petición 4
fetch('/api?text=React') // Petición 5 (la única necesaria)
Solo necesitamos la última, el resto son innecesarias.
3. Race conditions
Las peticiones pueden llegar en diferente orden al que se enviaron. Esto puede causar que:
- La respuesta de “Re” llegue después de “React”
- Se muestren resultados incorrectos
- La UI se actualice con datos obsoletos
¿Qué es debounce?
Debounce es una técnica que retrasa la ejecución de una función hasta que el usuario deja de escribir durante un tiempo específico.
Analogía del ascensor
Imagina un ascensor que espera unos segundos antes de cerrarse. Si alguien más viene, el temporizador se reinicia. Solo se cierra cuando nadie más entra durante esos segundos.
El debounce funciona igual:
- Usuario escribe una letra → temporizador empieza (500ms)
- Usuario escribe otra letra → temporizador se reinicia (500ms)
- Usuario para de escribir → después de 500ms se ejecuta la búsqueda
Ejemplo visual
Usuario escribe: R → e → a → c → t → [pausa 500ms] → ¡BÚSQUEDA!
↓ ↓ ↓ ↓ ↓
Temporizador: ⏱ ⏱ ⏱ ⏱ ⏱ ✅ (se ejecuta)
reset reset reset reset reset
Preparando el custom hook
Primero, vamos al hook useSearchForm donde detectamos que la función handleTextChange dispara búsquedas inmediatamente:
const handleTextChange = (event) => {
const text = event.target.value
setSearchText(text)
onTextFilter(text) // ❌ Se ejecuta inmediatamente en cada tecla
}
Necesitamos cambiar el comportamiento para que:
✅ Actualice el input al momento (para que el usuario vea lo que escribe)
✅ Pero espere antes de llamar a la API (usando debounce)
¿Dónde guardar el ID del timeout?
Para implementar debounce necesitamos guardar el ID del timeout. Pero ¿dónde lo guardamos?
❌ Opción 1: Variable dentro del hook
function useSearchForm() {
let timeoutId // ❌ Se reinicia en cada render
const handleTextChange = (event) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
// buscar...
}, 500)
}
}
Problema: Las variables normales se reinician en cada render. Cuando React vuelve a ejecutar el hook, timeoutId se declara de nuevo y pierdes la referencia al timeout anterior.
Resultado: No puedes cancelar el timeout anterior y acabas con múltiples peticiones.
✅ Opción 2: Variable externa al hook
let timeoutId = null // ✅ Se declara FUERA del hook
function useSearchForm() {
const handleTextChange = (event) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
// buscar...
}, 500)
}
}
Ventaja: La variable persiste entre renders porque está fuera de la función. React no la reinicia.
Limitación: Si tienes dos buscadores en la misma página, compartirían el mismo timeoutId. En nuestro caso, solo tenemos un buscador, así que no es un problema.
💡 Nota: Más adelante en el curso aprenderás sobre
useRef, que es una forma más avanzada de mantener valores entre renders de forma aislada para cada componente. Por ahora, usaremos una variable externa que es más simple de entender.
Demostración del problema con variables internas
Para ilustrar por qué no podemos usar una variable interna:
// ❌ Variable interna
function useSearchForm() {
let timeoutId // Se reinicia en cada render
console.log(timeoutId) // Siempre undefined al inicio
const handleTextChange = (event) => {
clearTimeout(timeoutId) // clearTimeout(undefined) → no hace nada
timeoutId = setTimeout(() => {}, 500)
}
}
// ✅ Variable externa
let timeoutId = null
function useSearchForm() {
console.log(timeoutId) // Mantiene el valor anterior
const handleTextChange = (event) => {
clearTimeout(timeoutId) // ✅ Cancela el timeout anterior
timeoutId = setTimeout(() => {}, 500)
}
}
Implementación del debounce
Ahora implementamos la lógica completa del debounce. Primero, declaramos la variable timeoutId fuera del hook:
import { useId, useState } from 'react'
let timeoutId = null // Variable externa que persiste entre renders
const useSearchForm = ({
idTechnology,
idLocation,
idExperienceLevel,
idText,
onSearch,
onTextFilter,
}) => {
const [searchText, setSearchText] = useState('')
const handleTextChange = (event) => {
const text = event.target.value
// 1. Actualizar el input inmediatamente
setSearchText(text)
// 2. Cancelar el timeout anterior (si existe)
if (timeoutId) {
clearTimeout(timeoutId)
}
// 3. Crear un nuevo timeout
timeoutId = setTimeout(() => {
// 4. Ejecutar la búsqueda después de 500ms
onTextFilter(text)
}, 500)
}
const handleSubmit = (event) => {
event.preventDefault()
const formData = new FormData(event.currentTarget)
// Ignorar si el evento viene del input de texto
if (event.target.name === idText) {
return
}
const filters = {
technology: formData.get(idTechnology),
location: formData.get(idLocation),
experienceLevel: formData.get(idExperienceLevel),
}
onSearch(filters)
}
return {
searchText,
handleSubmit,
handleTextChange,
}
}
Flujo de ejecución
Usuario escribe “React”:
1. Escribe 'R'
- setSearchText('R') ✅ input muestra "R"
- clearTimeout() (no hay timeout previo)
- setTimeout → espera 500ms
2. Escribe 'e' (han pasado 100ms)
- setSearchText('Re') ✅ input muestra "Re"
- clearTimeout() ❌ CANCELA el timeout de 'R'
- setTimeout → espera 500ms
3. Escribe 'a' (han pasado 200ms)
- setSearchText('Rea') ✅ input muestra "Rea"
- clearTimeout() ❌ CANCELA el timeout de 'Re'
- setTimeout → espera 500ms
4. Escribe 'c' (han pasado 300ms)
- setSearchText('Reac') ✅ input muestra "Reac"
- clearTimeout() ❌ CANCELA el timeout de 'Rea'
- setTimeout → espera 500ms
5. Escribe 't' (han pasado 400ms)
- setSearchText('React') ✅ input muestra "React"
- clearTimeout() ❌ CANCELA el timeout de 'Reac'
- setTimeout → espera 500ms
6. Usuario para de escribir
- Pasan 500ms...
- ✅ SE EJECUTA onTextFilter('React')
- ✅ Una sola petición a la API
¿Qué pasaría sin clearTimeout?
Si no cancelamos los timeouts anteriores:
// ❌ Sin clearTimeout
const handleTextChange = (event) => {
const text = event.target.value
setSearchText(text)
// NO cancelamos el anterior
timeoutId = setTimeout(() => {
onTextFilter(text)
}, 500)
}
Resultado al escribir “React”:
Tiempo 0ms: Usuario escribe 'R' → timeout 1 empieza
Tiempo 100ms: Usuario escribe 'e' → timeout 2 empieza
Tiempo 200ms: Usuario escribe 'a' → timeout 3 empieza
Tiempo 300ms: Usuario escribe 'c' → timeout 4 empieza
Tiempo 400ms: Usuario escribe 't' → timeout 5 empieza
Tiempo 500ms: ❌ Se ejecuta timeout 1 → búsqueda 'R'
Tiempo 600ms: ❌ Se ejecuta timeout 2 → búsqueda 'Re'
Tiempo 700ms: ❌ Se ejecuta timeout 3 → búsqueda 'Rea'
Tiempo 800ms: ❌ Se ejecuta timeout 4 → búsqueda 'Reac'
Tiempo 900ms: ❌ Se ejecuta timeout 5 → búsqueda 'React'
¡5 peticiones seguidas! Justo lo que queríamos evitar.
Detalles importantes de la implementación
Veamos algunos puntos clave del código:
1. La comprobación if (timeoutId)
if (timeoutId) {
clearTimeout(timeoutId)
}
Esta verificación asegura que solo intentamos cancelar un timeout si existe uno previo. Aunque clearTimeout(null) no causa errores, es más limpio verificarlo primero.
2. Evitar que el formulario dispare llamadas duplicadas
Observa esta parte del handleSubmit:
const handleSubmit = (event) => {
event.preventDefault()
// Ignorar si el evento viene del input de texto
if (event.target.name === idText) {
return
}
// ... resto del código
}
¿Por qué necesitamos esta validación? Porque el input de texto está dentro de un formulario, y en algunos casos el formulario podría disparar un submit al cambiar el input.
Con esta comprobación, handleSubmit solo se ejecuta cuando:
- ✅ El usuario hace submit explícito (presiona Enter, hace clic en un botón)
- ✅ Cambia un filtro que NO sea el input de texto (tecnología, ubicación, nivel)
3. Uso de event.currentTarget vs event.target
const formData = new FormData(event.currentTarget)
Usamos event.currentTarget en lugar de event.target porque:
event.target: El elemento que disparó el evento (puede ser un input interno)event.currentTarget: El elemento al que está adjunto el listener (el formulario completo)
Necesitamos el formulario completo para obtener todos los campos con FormData.
Cómo usar el hook en el componente
Para usar este hook en tu componente, necesitas pasarle todos los parámetros necesarios:
const { searchText, handleSubmit, handleTextChange } = useSearchForm({
idText: 'search',
idTechnology: 'technology',
idLocation: 'location',
idExperienceLevel: 'experienceLevel',
onSearch: (filters) => {
// Función que se ejecuta cuando se hace submit del formulario
console.log('Filtros:', filters)
},
onTextFilter: (text) => {
// Función que se ejecuta después del debounce
console.log('Buscar texto:', text)
},
})
Y luego en tu JSX:
<form onSubmit={handleSubmit}>
<input
type="text"
name="search"
value={searchText}
onChange={handleTextChange}
placeholder="Buscar empleos..."
/>
<select name="technology">
<option value="">Todas las tecnologías</option>
<option value="react">React</option>
<option value="vue">Vue</option>
</select>
{/* ... más filtros ... */}
</form>
Una vez implementado:
- ✅ El debounce funciona correctamente
- ✅ No hay llamadas repetitivas
- ✅ La API solo se llama cuando el usuario para de escribir
- ✅ El input se actualiza instantáneamente
Recomendaciones sobre el tiempo de debounce
El tiempo de espera ideal depende del caso de uso:
⚡ Demasiado corto (< 200ms)
setTimeout(() => onTextFilter(text), 100) // ❌ Muy rápido
- Aún genera demasiadas peticiones
- Usuario típico escribe una letra cada 200-300ms
- No hay suficiente mejora
🐌 Demasiado largo (> 1000ms)
setTimeout(() => onTextFilter(text), 2000) // ❌ Muy lento
- Se siente lento y poco responsive
- El usuario piensa que la app no funciona
- Mala experiencia de usuario
✅ Tiempo ideal: 300-500ms
setTimeout(() => onTextFilter(text), 500) // ✅ Equilibrado
- Balance perfecto entre rendimiento y UX
- Usuario promedio escribe una palabra completa
- Se siente instantáneo pero optimizado
Ejemplo por tipo de búsqueda
| Tipo de búsqueda | Tiempo recomendado |
|---|---|
| Autocompletado rápido | 200-300ms |
| Buscador normal | 400-500ms |
| Búsqueda compleja/costosa | 700-1000ms |
Verificación final
Ahora el comportamiento es el esperado:
Prueba 1: Escribir rápido
Usuario: R-e-a-c-t (sin parar)
Resultado: ✅ 1 petición a la API con "React"
Prueba 2: Escribir y pausar
Usuario: R-e-a [pausa] c-t [pausa]
Resultado:
✅ 1 petición con "Rea"
✅ 1 petición con "React"
Prueba 3: Combinar con otros filtros
Usuario:
1. Escribe "React"
2. Selecciona "Remote"
3. Selecciona "Junior"
Resultado:
✅ 1 petición con text=React
✅ 1 petición con text=React&type=remote
✅ 1 petición con text=React&type=remote&level=junior
Todo funciona correctamente, con el número mínimo de peticiones necesarias.
Resumen
En esta clase has aprendido:
- ✅ Qué es debounce y por qué es necesario para optimizar peticiones
- ✅ Diferencia entre variables internas y externas en React
- ✅ Por qué las variables internas se reinician en cada render
- ✅ Cómo usar variables externas para mantener valores entre renders
- ✅ Cómo implementar debounce usando
setTimeoutyclearTimeout - ✅ Por qué es crucial usar
clearTimeoutpara cancelar timeouts anteriores - ✅ Diferencia entre
event.targetyevent.currentTarget - ✅ Cómo prevenir llamadas duplicadas desde el formulario
- ✅ Tiempos ideales de debounce según el caso de uso
💡 Nota para el futuro: Más adelante en el curso aprenderás sobre
useRef, que proporciona una forma más avanzada de mantener valores entre renders de forma aislada por componente. Por ahora, la variable externa es suficiente para nuestro caso de uso.
Ventajas finales
La implementación de debounce proporciona:
- 🚀 Mejor rendimiento: Menos peticiones a la API
- 💰 Ahorro de recursos: Menor consumo de ancho de banda
- ⚡ Mejor UX: Evita lag por múltiples peticiones
- 🐛 Sin race conditions: Una sola petición final
- 🎯 Búsquedas más precisas: El usuario termina de escribir antes de buscar
El debounce es una técnica fundamental en desarrollo web moderno, especialmente para inputs de búsqueda, autocompletados y cualquier interacción en tiempo real que dispare operaciones costosas.