Construyendo una app de radios LATAM (8): búsqueda con debounce, hook propio y persistencia honesta
Publicado el 01/06/2026
El problema
Una vez tienes pantallas que listan emisoras, los próximos requisitos llegan solos:
- Buscar por nombre sin que cada tecla dispare una petición a la API.
- Filtrar por país y por género combinables con la búsqueda.
- Paginar con un número de página visible al usuario.
- Cancelar peticiones obsoletas cuando el usuario cambia los filtros antes de que llegue la respuesta.
- Persistir los favoritos entre sesiones de forma fiable.
La complejidad real no está en cada uno por separado, sino en componerlos sin que se peleen entre ellos.
useStationSearch: un hook para mandarlos a todos
Lo que quería desde el componente de pantalla:
const { data, loading, error } = useStationSearch({
enabled: true,
countryCode,
name: debouncedQuery,
tag,
page,
pageSize: 20,
});
Una sola línea. Si cambia cualquier dependencia, refetch automático y cancelación de la anterior. Si enabled es false, no hace nada. Si llega una respuesta de una query vieja, la descarto.
La implementación es honesta y compacta:
export function useStationSearch(input: StationSearchInput): StationSearchState {
const [state, setState] = useState<StationSearchState>({
data: EMPTY,
loading: false,
error: null,
});
const { enabled, countryCode, name, tag, page, pageSize = 20 } = input;
useEffect(() => {
if (!enabled) {
setState({ data: EMPTY, loading: false, error: null });
return;
}
let active = true;
const ctrl = new AbortController();
setState((s) => ({ ...s, loading: true, error: null }));
searchStations({
countryCode, name, tag, page, limit: pageSize,
signal: ctrl.signal,
})
.then((data) => {
if (!active) return;
setState({ data, loading: false, error: null });
})
.catch((err) => {
if (!active) return;
if (err?.name === 'AbortError') return;
setState({ data: EMPTY, loading: false, error: 'No se pudieron cargar las emisoras.' });
});
return () => {
active = false;
ctrl.abort();
};
}, [enabled, countryCode, name, tag, page, pageSize]);
return state;
}
Tres patrones que uso aquí y que he reutilizado en muchos otros proyectos.
1. Doble guarda: active + AbortController
Una sola no basta:
AbortControllercancela la petición HTTP en curso. Pero si la respuesta ya ha vuelto y está esperando en el microtask queue, elthense ejecuta igual.active = falsees la salvaguarda final: aunque el.thense ejecute, no actualizo state.
Por qué importa: si el usuario tipea "rock", "rock j", "rock ja", "rock jaz", "rock jazz", lanzas cinco peticiones. Cuatro las cancelas, pero la primera ya puede haber respondido y estar en cola. Sin la active flag, el resultado de "rock" sobrescribe al final el de "rock jazz". Bug clásico.
2. EMPTY congelado
const EMPTY: PaginatedStations = Object.freeze({
stations: [], count: 0, page: 1, pageSize: 20, hasNext: false, hasPrev: false,
}) as PaginatedStations;
¿Por qué congelar? Porque exporto la misma referencia cada vez que reseteo el state, y Object.freeze me asegura que ningún componente que la reciba la pueda mutar accidentalmente. El sentido es contractual, no de performance: el objeto es "limpio" y nadie debe tocarlo.
3. Ignorar AbortError explícitamente
.catch((err) => {
if (!active) return;
if (err?.name === 'AbortError') return;
setState({ data: EMPTY, loading: false, error: '...' });
});
Un AbortError no es un error, es algo que tú mismo provocaste. Si lo metes en state.error, la UI pinta "no se pudieron cargar" cuando en realidad solo cancelaste para pedir otra cosa. Pequeño bug clásico también: filtrar abort explícitamente.
useDebounced: un hook diminuto que paga su peso
export function useDebounced<T>(value: T, ms: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return debounced;
}
Lo uso así:
const [query, setQuery] = useState('');
const debouncedQuery = useDebounced(query, 350);
// ...
useStationSearch({ name: debouncedQuery, ... });
Esto es lo único que separa una UX civilizada de un buscador que machaca la API. Sin debounce, cada tecla = una petición. Con 350ms, solo cuando dejas de teclear se dispara. La API agradece, el usuario también.
Considé lodash.debounce durante medio segundo. Para esto sobra: el hook es 5 líneas, no añade dependencia y se entiende de un vistazo.
La trampa de las dependencias del useEffect
Una cosa que me costó la primera vez: si una de las dependencias es un objeto literal, se considera nueva en cada render y el efecto se vuelve a disparar.
Mal:
const params = { name: query, countryCode: 'AR' };
const result = useStationSearch({ enabled: true, ...params, page });
// params es nuevo cada render → reefetch infinito
Bien:
const result = useStationSearch({
enabled: true,
name: query,
countryCode: 'AR',
page,
});
// Cada string/number es primitivo → solo cambia cuando cambia su valor
Por eso useStationSearch recibe primitivos (string, number, boolean) y los pasa a la dependencia del effect directamente. Resiste la tentación de aceptar un objeto "params" "para que sea más limpio" — pagas con refetches accidentales.
Paginación: la decisión de mostrar página y no infinite scroll
Tenía dos opciones obvias:
- Infinite scroll. El usuario hace scroll hacia abajo y se cargan más.
- Paginación clásica. Botones "anterior" y "siguiente", con número de página visible.
Elegí paginación clásica para v1. Razones:
- Predecible. El usuario sabe que en la página 2 está lo que estaba en la página 2 ayer. En infinite scroll, según cuándo entres, los resultados cambian.
- Más fácil de probar. Página 1, página 2, fin. Sin lógica de "ya cargado todo".
- Más fácil con AbortController. Infinite scroll necesita gestionar concatenación de resultados — abrir esa puerta justo cuando estaba peleando con cancelación me parecía pedir problemas.
El componente Pagination es ridículamente simple: "anterior" deshabilitado en página 1, "siguiente" deshabilitado si !hasNext, número visible. Lo importante es que hasNext lo calcula el stationService desde el count total del servidor, no lo invento yo en el cliente.
function buildPaginated(raw, page, limit): PaginatedStations {
const list = unwrapList(raw.results);
const stations = list.map(normalizeStation).filter(Boolean);
const count = typeof raw.count === 'number' ? raw.count : stations.length;
return {
stations,
count,
page,
pageSize: limit,
hasNext: page * limit < count,
hasPrev: page > 1,
};
}
hasNext: page * limit < count es la regla pura: si he visto menos de los que hay, hay más.
Favoritos en AsyncStorage: por qué no SQLite
Tuve la tentación de meter expo-sqlite o WatermelonDB para los favoritos. Lo descarté:
- El dato es trivial. Una lista de UUIDs. Eso es un array.
- Las operaciones son trivialísimas. Añadir, quitar, comprobar pertenencia. Ningún join, ningún índice secundario, ningún reporte.
- El tamaño es ridículo. 1000 favoritos = ~36 KB de JSON. AsyncStorage devora eso sin pestañear.
- Una librería más = más superficie de bugs y más cosas que actualizar.
AsyncStorage es perfecto para esto. La complejidad real fue:
1. Versionar la clave
const STORAGE_KEY = '@latam_radios:favorites:v2';
const LEGACY_KEY = 'favorites';
La :v2 ya la justifiqué en el capítulo 4 (contextos): si cambias el formato, no pises el dato. Migra una vez y olvida.
2. Sanitizar lo que viene del disco
function sanitize(list: unknown): string[] {
if (!Array.isArray(list)) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const item of list) {
if (typeof item !== 'string') continue;
const lower = item.toLowerCase();
if (!UUID_RE.test(lower)) continue;
if (seen.has(lower)) continue;
seen.add(lower);
out.push(lower);
}
return out;
}
El disco es un input no confiable. Lo trato como tal: filtro tipos, valido el formato del UUID, dedupo.
3. Hidratación visible
const [hydrated, setHydrated] = useState(false);
// ...
useEffect(() => {
let cancelled = false;
loadInitial().then((list) => {
if (!cancelled) {
setFavorites(list);
setHydrated(true);
}
});
return () => { cancelled = true; };
}, []);
El bool hydrated permite a la pantalla decidir si pintar skeleton o pintar lista real. Lo justifiqué a fondo en el capítulo 4 — aquí solo destaco que es una pieza inseparable de cualquier persistencia local.
4. Cargar las stations completas bajo demanda
// En la pantalla de favoritos:
useEffect(() => {
if (!hydrated || favorites.length === 0) {
setStations([]);
return;
}
const ctrl = new AbortController();
getStationsByUUIDs(favorites, ctrl.signal)
.then(setStations)
.catch(() => { /* ignore */ });
return () => ctrl.abort();
}, [hydrated, favorites]);
Almaceno UUIDs, no estaciones enteras. Cuando entro a Favoritos, hago un POST bulk al backend y pinto. Si una emisora ha cambiado de URL, lo veo. Si una ha desaparecido, también.
Esto es la pelea entre "fast local" y "fresh remote", y aquí la decisión fue clara: para una app de radio en vivo, la frescura manda. Una emisora favorita que ya no transmite no es un favorito útil.
Lo que mejoraría
- React Query / TanStack Query. Lo dije ya en el capítulo 2: cuando la app crezca, sustituiría
useStationSearchpor unuseQuery. Gana caché, refetch on focus, dedup. Para ahora, mi hook se sostiene. - Snapshot defensivo de favoritos. Si el bulk al backend falla repetidamente, podría guardar un pequeño snapshot (nombre + favicon) para mostrar algo en lugar de "no se pudo cargar". Compromiso entre frescura y resiliencia.
- Infinite scroll opcional. Más bonito en móvil. Lo cambiaría si tuviera estadísticas de uso que lo justificaran.
La conclusión que me llevo
Búsqueda y persistencia son los dos puntos donde los bugs sutiles se acumulan en cualquier app. Los patrones que se sostienen son siempre los mismos: debounce + abort + active flag para búsqueda; versión de clave + sanitización + hidratación visible para persistencia. Una vez los tienes incorporados, los aplicas de memoria y dejas de pelearte con race conditions.
Y una idea más general que he ido repitiendo a lo largo de toda la serie: trata cada input no confiable —la API, el disco, el navegador, el usuario— con la misma higiene. Filtra lo inválido, normaliza el formato, valida los tipos en el borde. Lo que llega al render debe ser limpio.
En el último capítulo: retrospectiva honesta sobre lo que sale bien, lo que aún no, y los próximos pasos.