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úsquedaTiempo recomendado
Autocompletado rápido200-300ms
Buscador normal400-500ms
Búsqueda compleja/costosa700-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:

  1. Qué es debounce y por qué es necesario para optimizar peticiones
  2. Diferencia entre variables internas y externas en React
  3. Por qué las variables internas se reinician en cada render
  4. Cómo usar variables externas para mantener valores entre renders
  5. Cómo implementar debounce usando setTimeout y clearTimeout
  6. Por qué es crucial usar clearTimeout para cancelar timeouts anteriores
  7. Diferencia entre event.target y event.currentTarget
  8. Cómo prevenir llamadas duplicadas desde el formulario
  9. 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.