Construyendo una app de radios LATAM (4): por qué partí mi Context en dos

Publicado el 27/05/2026

El problema

En la primera versión tenía un ContextProvider único que exponía todo:

const ContextValue = {
  // favoritos
  favorites,
  toggleFavorite,
  // reproductor
  currentStation,
  isPlaying,
  play,
  pause,
  // bonus: estado de carga, errores...
};

Pegaba el provider en la raíz de la app y todos los componentes hacían useAppContext(). Cómodo de escribir... durante una semana. Después empezaron los problemas:

  1. Renders innecesarios. Cuando cambiaba status del reproductor (de loading a playing), la pantalla de Favoritos se rerenderizaba aunque no le importara. La razón es la de siempre: cualquier cambio en el value del provider rerendea a todos los consumidores. Sí, podía aplicar React.memo y useMemo por todas partes, pero estaba parcheando el síntoma.
  2. Dependencias circulares accidentales. El reproductor quería marcar la emisora actual como "favorita" automáticamente al pulsar play (idea que luego descarté), y de pronto el play() dependía de toggleFavorite, que dependía del provider, que era el mismo. Spaghetti.
  3. Imposible razonar por separado. Cuando intentaba probar el comportamiento del reproductor sin tocar favoritos, no podía. Estaban casados.
  4. Inicialización mezclada. Favoritos necesita hidratar de AsyncStorage. El reproductor necesita configurar Audio.setAudioModeAsync. Mezclarlos en el mismo useEffect era una invitación a bugs sutiles de orden.

La decisión: dos contextos, dos vidas

La pregunta clave que me hice: ¿son conceptos que cambian juntos o conceptos que cambian por separado?

  • Favoritos cambia cuando el usuario pulsa el corazón. Se persiste a AsyncStorage. No le importa lo más mínimo lo que esté sonando.
  • El reproductor cambia constantemente (carga, error, play, pause). No le importa nada qué emisoras estén marcadas como favoritas.

Conclusión: dos contextos completamente separados. La excepción son los componentes que de verdad necesitan ambos (la StationCard necesita saber si es favorita y si está sonando), y esos consumen los dos hooks. Ese acoplamiento es explícito en el sitio donde de verdad existe, no implícito en un provider monolítico.

// app/_layout.tsx
<FavoritesProvider>
  <PlayerProvider>
    {/* resto de la app */}
  </PlayerProvider>
</FavoritesProvider>

FavoritesContext: el caso "fácil" que no era tan fácil

Sobre el papel, favoritos es trivial: una lista de UUIDs persistida. La realidad fue un poco más interesante.

const STORAGE_KEY = '@latam_radios:favorites:v2';
const LEGACY_KEY = 'favorites';

async function loadInitial(): Promise<string[]> {
  try {
    const stored = await AsyncStorage.getItem(STORAGE_KEY);
    if (stored) {
      try { return sanitize(JSON.parse(stored)); }
      catch {
        await AsyncStorage.removeItem(STORAGE_KEY).catch(() => undefined);
        return [];
      }
    }
    const legacy = await AsyncStorage.getItem(LEGACY_KEY);
    if (legacy) {
      try {
        const migrated = sanitize(JSON.parse(legacy));
        await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(migrated));
        await safeRemoveLegacy();
        return migrated;
      } catch {
        await safeRemoveLegacy();
        return [];
      }
    }
  } catch { /* ignore */ }
  return [];
}

Tres cosas que aprendí escribiendo esto:

1. La hidratación es asíncrona, hay que avisar

Al arrancar, durante un parpadeo el array favorites está vacío aunque el usuario tenga 30 emisoras guardadas. Si la pantalla de favoritos pinta "no tienes favoritos" durante 200 ms, la experiencia es horrible.

Solución: una flag hydrated en el contexto.

const value = { favorites, hydrated, isFavorite, toggleFavorite, clearFavorites };

Y en la pantalla:

if (!hydrated) return <SkeletonList />;
if (favorites.length === 0) return <EmptyState ... />;

Pequeñísimo detalle, gran diferencia.

2. Versionar la clave de storage es barato y te salva

Cuando cambias el formato de lo que guardas, no sobrescribas la misma clave. Usa una nueva (@latam_radios:favorites:v2) e intenta migrar desde la antigua (favorites) una sola vez. Si la migración falla, te tragas el coste y empiezas de cero, pero no pierdes los datos del usuario por un bug tonto de parseo.

El prefijo @latam_radios: también es importante: si un día el dispositivo tiene varias apps de Expo en desarrollo, el namespace ayuda a no pisarte con otras.

3. Sanitizar lo que viene de storage como si viniera de fuera

sanitize() filtra cualquier cosa que no sea un UUID válido y dedupa. ¿Por qué? Porque AsyncStorage es un archivo en disco y un usuario malicioso (o yo en un bug en una build vieja) podría haber escrito basura. Lo trato como entrada no confiable, igual que la respuesta de una API.

4. Optimistic update con rollback

const toggleFavorite = useCallback(async (uuidRaw: string) => {
  if (!hydrated) return false;
  const uuid = uuidRaw.toLowerCase();
  if (!UUID_RE.test(uuid)) return false;
  const previous = favorites;
  const idx = previous.indexOf(uuid);
  const next = idx >= 0 ? previous.filter(id => id !== uuid) : [uuid, ...previous];
  setFavorites(next);
  try {
    await persist(next);
  } catch {
    setFavorites(previous);  // rollback en caso de fallo de IO
    return !nowActive;
  }
  return nowActive;
}, [favorites, hydrated, persist]);

El UX correcto es actualizar UI primero, persistir después. Si la persistencia falla (raro pero pasa), rollback. La alternativa —esperar AsyncStorage antes de pintar— hace que la app se sienta torpe.

Y un detalle más: un writeLockRef para serializar las escrituras. Si el usuario pulsa el corazón rápido tres veces, no quiero dos escrituras concurrentes pisándose.

PlayerContext: la máquina de estados que no queréis volver a escribir

El reproductor es el componente más complejo de la app. Tiene cinco estados:

type PlayerStatus = 'idle' | 'loading' | 'playing' | 'paused' | 'error';

Y opera sobre un recurso externo (expo-av o HTMLAudioElement en web) que se carga asíncronamente, puede fallar, y vive más allá del ciclo de un render.

Tres patrones que acabaron salvándome:

1. Token de generación para invalidar peticiones en curso

const tokenRef = useRef(0);

const loadAndPlay = useCallback(async (station: Station) => {
  const token = ++tokenRef.current;
  // ...
  await ensureAudioMode();
  await unloadInternal();
  if (tokenRef.current !== token) return;   // alguien pidió otra cosa mientras tanto

  try {
    const handle = await createHandle(resolved.url);
    if (tokenRef.current !== token) {       // y aquí también
      await handle.unload().catch(() => undefined);
      return;
    }
    handleRef.current = handle;
    handle.onStatus(s => {
      if (tokenRef.current !== token) return;  // y aquí también
      // ...
    });
    await handle.play();
  } catch (err) {
    if (tokenRef.current !== token) return;
    // ...
  }
}, [...]);

¿Por qué? Porque createHandle puede tardar 2 segundos (descargar metadata HLS, abrir el stream). En esos 2 segundos el usuario puede haber pulsado otra emisora. Si no comparo el token, acabo con el handle viejo activo y la UI mostrando datos contradictorios.

Es el patrón que se conoce como "stale closure / race condition". En lugar de cancelar la promesa (que no es cancelable nativa en JS), comparo un token en cada punto de continuación y, si ha cambiado, descarto el resultado y libero recursos.

2. Cola de operaciones serializadas

const queueRef = useRef<Promise<void>>(Promise.resolve());

const enqueue = useCallback((op: () => Promise<void>) => {
  const next = queueRef.current.catch(() => undefined).then(op);
  queueRef.current = next.catch(() => undefined);
  return next;
}, []);

const play = useCallback(
  (station: Station) => enqueue(() => loadAndPlay(station)),
  [enqueue, loadAndPlay],
);

play, toggle y stop se serializan. Si el usuario hace play→pause→play en rápido, las tres operaciones se ejecutan en orden, no concurrentemente. Esto evita estados intermedios absurdos como "está reproduciéndose dos veces a la vez" o "el unload del primero pisa el load del segundo".

3. Refs para "lo que está pasando ahora", state para "lo que la UI debe mostrar"

const handleRef = useRef<PlaybackHandle | null>(null);
const currentRef = useRef<Station | null>(null);
const [current, setCurrentState] = useState<Station | null>(null);

const setCurrent = useCallback((next: Station | null) => {
  currentRef.current = next;
  setCurrentState(next);
}, []);

¿Por qué duplicar? Porque dentro de callbacks asíncronos (el onStatus de expo-av, por ejemplo) no quiero capturar el current antiguo de un closure viejo. Quiero leer el valor más reciente, que vive en la ref. El state es solo para que React rerenderee.

Es un patrón que en clases era natural (this.current), y que en hooks recupera su utilidad cuando trabajas con recursos externos asíncronos.

La diferencia que se notó al separar

Después de partir el contexto:

  • Renders. La pantalla de favoritos no se rerenderea cuando cambia el status del reproductor. La StationCard solo se rerenderea cuando cambia su favoritedad o cuando cambia el reproductor y ella es la actual.
  • Lectura. Abres PlayerContext.tsx y entiendes el reproductor entero sin distracciones de favoritos. Lo mismo al revés.
  • Tests. Aún no he escrito muchos, pero ya pude testear FavoritesContext con un AsyncStorage mockeado sin tener que mockear expo-av.

La conclusión que me llevo

La regla práctica que me he sacado: un Context = una preocupación. Si tienes dudas, parte. Renombrar y partir contextos React es trivial; rejuntar es trivial también. Pero vivir con un Context monolítico durante seis meses te erosiona la app.

Y el patrón de token + cola de operaciones para recursos externos asíncronos no es solo para audio. Lo he usado en uploads de archivos, en sockets que se reconectan, en cualquier sitio donde tienes un "lo último que pidió el usuario" y un "lo que ya está en marcha". Si esto fuera un tweet sería: si trabajas con recursos asíncronos cancelables-pero-no-realmente, ten un tokenRef.

En el próximo capítulo entro al detalle de cómo reproduzco los streams en Android con expo-av, qué permisos necesité y por qué los streams en http:// plano dan tantos quebraderos de cabeza.