Corregir las condiciones de carrera: el debounce no puede resolverlo en las interfaces de búsqueda
Aprenda por qué el desenrrollado por sí solo no puede evitar que las respuestas obsoletas de la API sobrescriban el estado actual de la interfaz, y explore cuatro soluciones prácticas para garantizar el orden de las solicitudes.
Añadí un retraso de 300 milisegundos a un campo de búsqueda en un proyecto el año pasado y consideré que el problema estaba resuelto.
Más tarde, un probador informó de algo extraño: si escribían rápidamente, los resultados parpadeaban brevemente mostrando algo no relacionado antes de establecerse correctamente.
La reacción inicial fue la incredulidad. Al revisar la pestaña de red, se veía que el retraso funcionaba tal como debía: una solicitud por cada pausa, sin ruido adicional.
No obstante, el error era real. Simplemente no era el error que se había corregido.
Cualquiera que haya creado una barra de búsqueda probablemente haya pasado por esta situación. Cada tecla presionada dispara una llamada a la red, lo que acaba sobrecargando el servidor posterior.
El consejo habitual es “simplemente añade un retraso”. Se introduce un breve intervalo, la cantidad de solicitudes disminuye y el caso se cierra.
Las decisiones pequeñas como esta se acumulan de maneras que es fácil subestimar. Esta decisión en particular parece menor, pero puede causar más daño del que parece.
Esto es lo que el mecanismo de debounce en realidad no garantiza: controla cuándo se envía una solicitud. No tiene nada que ver con qué respuesta llega primero.
Qué soluciona realmente el debounce en las barras de búsqueda
El debounce en una barra de búsqueda aborda exactamente un problema: un exceso de solicitudes enviadas.
Ese es todo el alcance de su función. El orden de las respuestas no se ve afectado.
Aquí está precisamente cómo puede fallar. Supongamos una ventana de debounce de 300 ms.
- Escribe
rea, luego dejas de escribir durante 300 ms. Se envía la Solicitud A pararea. - Continúas escribiendo:
react. Después de otra pausa, se envía la Solicitud B parareact.
react.rea.El campo de entrada indica react, pero la lista inferior muestra resultados para rea. Esa discrepancia es la parpadeo que observó el probador.
El rebote reduce la frecuencia con la que ocurre esto, pero no elimina la posibilidad. Y “con menos frecuencia” es precisamente el tipo de defecto que permanece oculto durante el desarrollo, cuando la API responde al instante y solo una persona está realizando pruebas.
No está claro si este comportamiento es idéntico en todas las combinaciones de frameworks y herramientas.
En los proyectos de React que utilizan fetch o axios, sin embargo, este patrón se ha presentado de forma consistente, en todas las ocasiones.
Esto no es solo un problema de la barra de búsqueda
Una vez que reconoces este modo de fallo, comienzas a notarlo en muchos otros lugares. Es el mismo error subyacente con diferentes manifestaciones:
- Menús desplegables de filtrado. Un usuario selecciona “Zapatos” y luego cambia rápidamente a “Bolsos”. Si la solicitud de zapatos se resuelve por último, la página termina mostrando listados de zapatos bajo la etiqueta “Bolsos”.
- Pestañas. Alguien hace clic en “Pedidos” y luego inmediatamente en “Facturas”. Si la respuesta de los pedidos es más lenta, sigue ganando la competencia, dejando los datos incorrectos bajo la pestaña correcta.
Cada vez que un usuario puede cambiar su intención más rápido de lo que el servidor backend puede responder, esta misma condición de carrera está presente.
Las aplicaciones reales pueden hacer que esto sea aún más difícil de resolver.
Imagínese un panel de control administrativo donde alguien selecciona:
- Últimos 30 días
- Luego últimos 7 días
- Luego hoy
En ese momento, tres solicitudes separadas podrían estar en proceso simultáneamente.
Incluso los datos precisos se convierten en datos incorrectos una vez que corresponden a una acción que el usuario ya ha superado.
Por qué un retraso mayor no ayuda
El primer instinto suele ser aumentar el período de debounce: pruebe 500 ms y, luego, quizás 800 ms.
Ese ajuste, hecho más bien por costumbre que por razonamiento real, no sirvió para reducir la frecuencia con la que ocurría el parpadeo. Solo hizo que la interfaz pareciera lenta. Una solicitud que no encuentra en el caché y tarda 900 ms seguirá superando a un período de debounce de 800 ms.
Ajustar un valor de tiempo no puede resolver un problema cuya raíz está en la secuenciación.
Es en este momento cuando se hace evidente la verdadera naturaleza del error.
El debounce es un mecanismo de control de tráfico.
Una condición de carrera es un fallo en la secuenciación.
Alargar el tiempo que una luz de tráfico permanece en rojo no determina qué coche llegará primero al destino.
Para resolver eso se necesita una regla completamente distinta.
La solución real: solo la solicitud más reciente puede actualizar la interfaz de usuario
Esa única oración constituye toda la solución. Existen varias técnicas para implementarla, que van desde las simples hasta las más completas.
Solución 1: Asignar un número de ticket a cada solicitud
Esta es la versión más fácil de explicar primero, ya que deja claras las mecánicas subyacentes.
import { useState, useRef } from "react";
function useSearch() {
const [results, setResults] = useState([]);
const latestRequestId = useRef(0);
async function search(query) {
const myId = ++latestRequestId.current;
const res = await fetch(`/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
// If a newer request started while we were waiting, throw this away.
if (myId === latestRequestId.current) {
setResults(data.results);
}
}
return { results, search };
}
A cada solicitud enviada se le asigna un número de ticket.
Cuando retorna una respuesta, esta se pregunta: “¿Todavía tengo el ticket más reciente?”
Si la respuesta es no, la respuesta se descarta. Los datos en sí no están incorrectos; simplemente están desactualizados. Y los datos desactualizados se ven exactamente igual que los datos erróneos una vez que se muestran en la pantalla.
Solución 2: Cancelar la solicitud antigua con AbortController
Al verificar los números de ticket simplemente se ignoran las respuestas obsoletas una vez que llegan. AbortController impide que esas respuestas lleguen en primer lugar. Aquí está la idea en JavaScript puro, sin ningún framework involucrado:
let controller = null;
async function search(query) {
// Kill whatever is still in flight.
controller?.abort();
controller = new AbortController();
try {
const res = await fetch(`/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
const data = await res.json();
renderResults(data.results);
} catch (err) {
// An aborted fetch throws. That's expected, not an error.
if (err.name === "AbortError") return;
showError(err);
}
}
Un detalle que es fácil pasar por alto: una llamada a fetch que se aborta rechaza con un AbortError. Si se omite manejar eso, terminarás registrando un error falso con cada tecla pulsada.
También vale recordar que abortar solo evita que el navegador espere la respuesta; el servidor podría seguir procesando la solicitud de todos modos. Eso no supone un problema para una función de búsqueda, pero se vuelve más significativo cuando la solicitud implica escribir datos.
Solución 3: La forma de React — limpieza con useEffect
Cuando tu lógica de búsqueda se encuentra dentro de un efecto, React ya proporciona un mecanismo integrado para este escenario exacto: la función de limpieza. Este es el enfoque recomendado directamente en la propia documentación de React.
import { useState, useEffect } from "react";
function useDebouncedValue(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
function SearchBox() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const debouncedQuery = useDebouncedValue(query, 300);
useEffect(() => {
const q = debouncedQuery.trim();
if (!q) {
setResults([]);
return;
}
let ignore = false;
const controller = new AbortController();
fetch(`/search?q=${encodeURIComponent(q)}`, { signal: controller.signal })
.then((r) => r.json())
.then((data) => {
if (!ignore) setResults(data.results);
})
.catch((err) => {
if (err.name !== "AbortError") console.error(err);
});
// Runs when debouncedQuery changes again, or the component unmounts.
return () => {
ignore = true;
controller.abort();
};
}, [debouncedQuery]);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{results.map((r) => (
<li key={r.id}>{r.name}</li>
))}
</ul>
</>
);
}
Fíjate en lo que este patrón te ofrece automáticamente. Se debuja el value en sí, no la función que envía la solicitud.
Eso significa que cada vez que cambia el valor debujado, primero se ejecuta la función de limpieza del ciclo anterior, cancelando esa solicitud obsoleta. Al desmontar el componente también se activa la misma función de limpieza. No necesitas refs ni contadores incrementados manualmente para que funcione.
Solución 4: Deja que una biblioteca de obtención de datos se encargue
Si tu proyecto ya depende de TanStack Query (React Query), esta garantía de ordenamiento viene incluida sin costo adicional; no se requiere lógica personalizada.
Cada vez que cambia la clave de consulta, la consulta anterior se vuelve obsoleta, y la biblioteca pasa automáticamente a tu función de obtención un signal que la interrumpe por ti.
import { useQuery, keepPreviousData } from "@tanstack/react-query";
function useSearch(debouncedQuery) {
return useQuery({
queryKey: ["search", debouncedQuery],
queryFn: ({ signal }) =>
fetch(`/search?q=${encodeURIComponent(debouncedQuery)}`, { signal })
.then((r) => r.json()),
enabled: debouncedQuery.trim().length > 0,
placeholderData: keepPreviousData, // v5 replacement for keepPreviousData: true
});
}
El trabajo real ocurre en dos lugares: el signal que se pasa a queryFn, y la opción placeholderData: keepPreviousData, que mantiene visible la lista anterior en lugar de dejar la pantalla en blanco mientras se cargan los siguientes resultados.
SWR ofrece el mismo comportamiento a través de su propia configuración keepPreviousData.
Hábitos simples que facilitaron enormemente hacerlo bien
A continuación se detallan hábitos que vale la pena incorporar en toda implementación de búsquedas. Ninguno de ellos es especialmente ingenioso, pero cada uno ha evitado algún error real en algún momento.
1. Siempre codifique la cadena de consulta. Un espacio extraño o un carácter & dentro del término de búsqueda podrá dañar su URL. Envuelva el valor en encodeURIComponent(query) cada vez que cree la solicitud. Un cuadro de búsqueda en entorno de producción puede fallar realmente con una entrada tan simple como “R&D”.
2. Ignore las consultas vacías o excesivamente cortas. Elimine los espacios en blanco de la entrada y evite enviar solicitudes para cadenas vacías. Establecer una longitud mínima, como dos caracteres, reduce las solicitudes innecesarias y disminuye las posibilidades de conflictos desde el principio.
3. Mantén mostrando los resultados anteriores mientras se carga una nueva solicitud. Evita borrar la lista con cada tecla pulsada. Un pequeño indicador giratorio junto a el campo de entrada resulta mucho menos molesto que una pantalla vacía que cambia constantemente entre búsquedas; una lista ligeramente desactualizada es mejor que no tener nada. Este es exactamente el comportamiento que ofrece keepPreviousData.
4. Almacena en caché los resultados que ya has obtenido. La gente suele borrar caracteres y volver a escribirlos. Un simple Map indexado por el término de búsqueda garantiza que al escribir rea, luego react y, finalmente, borrar para volver a escribir rea, el servidor no sea consultado dos veces por la misma búsqueda.
const cache = new Map();
async function searchWithCache(query, signal) {
if (cache.has(query)) return cache.get(query);
const res = await fetch(`/search?q=${encodeURIComponent(query)}`, { signal });
const data = await res.json();
cache.set(query, data);
return data;
}
5. Hacer que el backend envíe la consulta junto con la respuesta. En lugar de devolver un array simple, responder con algo como { query: "react", results: [...] }. Eso permite al cliente realizar una verificación adicional de validez — if (data.query !== currentQuery) return; — lo cual funciona como una medida de seguridad secundaria económica y hace que cualquier error sea inmediatamente visible en la pestaña de red.
6. Recrear deliberadamente la condición de carrera. Es poco probable que notes este error en localhost frente a una API que responde en cinco milisegundos. Introduce un retraso aleatorio artificial en tu servidor de simulación y el problema aparecerá casi de inmediato:
app.get("/search", async (req, res) => {
await new Promise((r) => setTimeout(r, Math.random() * 2000));
res.json({ query: req.query.q, results: fakeResults(req.query.q) });
});
Otra opción es abrir las Herramientas de Desarrollo de Chrome, ir a Red, activar el control de velocidad, seleccionar “3G lento” y escribir rápidamente. Verás que falla.
7. Permitir que la tecla Enter supere el retraso de debounce. Cuando el usuario presiona Enter, se debe iniciar la búsqueda de inmediato; los retrasos de debounce están diseñados para escritura continua, no para una señal explícita de intención.
8. Cancelar siempre al desmontar el componente. Si el usuario abandona la página mientras se realiza una búsqueda, la respuesta resultante intentará llamar a setState en un componente que ya no existe. La función de limpieza mostrada en la Solución 3 ya aborda este caso. Si en su lugar utiliza el método basado en números de ticket, asegúrese de que la función de limpieza al desmontar también incremente el contador.
9. Utilice useDeferredValue al filtrar de forma local, no a través de la red. Si está filtrando una lista que ya está cargada en memoria, no necesita debouncing ni cancelación de solicitudes. useDeferredValue mantiene una experiencia de escritura fluida mientras React se encarga del trabajo de filtrado en segundo plano; sin embargo, no reemplaza la lógica de cancelación cuando está involucrada una solicitud al servidor.
10. Escriba una sola prueba que aborde directamente la condición de carrera. Simule dos llamadas a fetch y resuelva la segunda antes que la primera. Verifique que la interfaz muestre el resultado de la segunda llamada. Es una prueba breve, pero permitirá detectar regresiones que de lo contrario podrían pasar desapercibidas durante la revisión del código.
Si su barra de búsqueda ha mostrado brevemente resultados incorrectos antes de corregirse, la demora por atenuación no es la causa, y añadir más demoras tampoco lo solucionará.
La atenuación de las entradas de búsqueda sigue siendo una buena práctica, ya que reduce significativamente la cantidad de solicitudes que envía. Simplemente no resuelve todo el problema por sí sola.
Lo que también necesita es una regla de ordenamiento explícita: solo se permite que la solicitud más reciente actualice la interfaz de usuario. Puede hacerlo valer con un ID de solicitud, un AbortController, una función de limpieza useEffect, o una biblioteca que ya implemente este patrón por usted. Cualquiera de estos enfoques funciona; lo importante es elegir uno y utilizarlo de manera consistente.
Lecturas relacionadas
- Dejar de Sincronizar el Estado con useEffect: Un Patrón Más Seguro en React — Aprenda por qué usar useEffect para sincronizar el estado derivado causa condiciones de carrera y renders adicionales, y cómo reemplazarlo con la derivación en tiempo de renderizado y el atributo key.
- Reducir el Problema de Prop-Drilling y las Componentes God en React — Conozca siete patrones concretos de refactorización para descomponer componentes grandes en React al aislar el estado, la obtención de datos, los permisos y la lógica de carga, en lugar de simplemente dividir los archivos.