Construyendo una app de radios LATAM (7): componentes reutilizables, MiniPlayer y un mini design system sin pretensiones
Publicado el 30/05/2026
El problema
En el primer commit tenía un StationItem.tsx solo. Todo el resto era pantallas tirando estilos directamente. A los pocos días la app tenía:
- Tres versiones distintas de cómo se renderiza una emisora (en lista, en favoritos, en detalle).
- Cuatro maneras de pintar el botón de play (con tamaños y colores ligeramente distintos).
- Headers ad-hoc en cada pantalla, todos parecidos pero ninguno igual.
- Mensajes de "no hay resultados" copiados y pegados con pequeñas variaciones.
Cada vez que iteraba visualmente, tenía que recordar tocar tres sitios. Inviable.
La decisión: componentes "del proyecto", no librería
No quería hacer un design system reutilizable para varios proyectos. Quería un conjunto pequeño de componentes acordados que vivieran en components/ y se usaran consistentemente. Las reglas que me autoimpuse:
- Cada componente acepta props mínimas y predecibles. No
style, noclassName, no escotillas de escape. Si necesito una variante, la pongo en el componente y la elijo con una prop. - Sin "smart" / "dumb" formal. Si un componente necesita el reproductor o favoritos, los pide directamente con el hook. No paso 15 props.
- Accesibilidad por defecto.
accessibilityRole,accessibilityLabel,accessibilityState. Sin esto, los tests con lector de pantalla son una pesadilla y la app es excluyente. - Sin sobre-abstracción. Si solo tengo un sitio donde se usa, no lo extraigo. Tres usos = extraigo. La famosa regla de tres.
El listado final que acabé creando:
| Componente | Para qué |
|---|---|
Background |
Fondo con gradiente y blur consistente entre pantallas. |
Header |
Barra superior con título y acciones. |
Screen |
Wrapper común con safe area y padding. |
StationCard |
La fila de emisora en listas. |
MiniPlayer |
El reproductor flotante en la parte inferior. |
Selector / SelectorTrigger |
Picker reusable (país, género, etc.). |
SearchBar |
Buscador con debounce y limpiar. |
Chip |
Pequeño badge para tags y filtros. |
Pagination |
Navegación de páginas. |
EmptyState |
"No hay resultados" / "Aún no tienes favoritos". |
SkeletonRow |
Placeholder mientras carga. |
12 componentes. Para una app de 4 pantallas no es poco, pero cada uno cubre un patrón que aparece más de una vez.
StationCard: el componente más rico
Es el más complejo porque tiene que:
- Mostrar info de la emisora (logo, nombre, país, bitrate, codec, tags).
- Indicar si es la actual en reproducción.
- Permitir marcar como favorita.
- Permitir play/pause directo.
- Navegar al detalle al pulsar el cuerpo.
Eso son cinco "estados" cruzados en un solo componente. Si lo escribiera mal, sería un lío. Lo que me funcionó:
1. Consumir los hooks directamente
function StationCardImpl({ station, onPress }: Props) {
const { isFavorite, toggleFavorite } = useFavorites();
const { current, status, isPlaying, isBusy, play, toggle } = usePlayer();
const fav = isFavorite(station.uuid);
const isCurrent = current?.uuid === station.uuid;
const isCurrentBusy = isCurrent && isBusy;
const isCurrentPlaying = isCurrent && isPlaying;
// ...
}
No paso 15 props del componente padre. La StationCard sabe lo que necesita y pide lo que necesita. El acoplamiento es explícito y vive en un solo sitio.
2. Variables derivadas con nombres claros
Antes hacía cosas como current?.uuid === station.uuid && status === 'loading' repetidas por el JSX. Acabé extrayendo:
const isCurrent = current?.uuid === station.uuid;
const isCurrentBusy = isCurrent && isBusy;
const isCurrentPlaying = isCurrent && isPlaying;
Esto no es solo estética: el JSX se lee como prosa.
3. React.memo y useCallback donde de verdad importa
const handlePlay = useCallback(async () => {
bumpHaptic();
if (isCurrent) await toggle();
else await play(station);
}, [isCurrent, play, station, toggle]);
const handleFav = useCallback(() => {
bumpHaptic();
void toggleFavorite(station.uuid);
}, [station.uuid, toggleFavorite]);
// ...
export const StationCard = memo(StationCardImpl);
¿Por qué aquí sí? Porque las listas tienen 20-50 cards visibles. Cualquier rerender del padre (cambio de query, paginación) sin memo me rerenderea todas. Con memo y handlers estables, solo se rerenderea la card que de verdad cambió.
Regla práctica: memo solo si el componente vive en una lista. En el resto suele ser ruido.
4. Accesibilidad bien hecha
<Pressable
onPress={() => onPress?.(station)}
accessibilityRole="button"
accessibilityLabel={`Abrir ${station.name}`}
accessibilityState={{ selected: isCurrent }}
// ...
>
{/* ... */}
<Pressable
onPress={handleFav}
accessibilityRole="button"
accessibilityLabel={fav ? `Quitar ${station.name} de favoritos` : `Añadir ${station.name} a favoritos`}
accessibilityState={{ selected: fav }}
>
Lo importante:
- El label es dinámico y específico. No "favorito", sino "Quitar Radio X de favoritos". El usuario con TalkBack sabe exactamente qué va a pasar.
accessibilityStatecomunica el estado actual al lector de pantalla.selected: trueen la card actual es lo que hace que el lector diga "seleccionado".accessibilityRole="button"convierte unPressableen algo que el sistema de accesibilidad anuncia como botón.
Esto cuesta cinco líneas por componente y abre la app a un montón de gente.
5. Feedback háptico condicional por plataforma
function bumpHaptic() {
if (Platform.OS === 'web') return;
Haptics.selectionAsync().catch(() => undefined);
}
expo-haptics no funciona en web (no hay vibración). Si llamo sin condición, no peta pero hace una llamada inútil. El primer return evita ruido innecesario.
MiniPlayer: el componente que sobrevuela todo
El MiniPlayer es la barra de control que aparece en la parte inferior de la app cuando hay una emisora actual, independientemente de en qué pantalla estés. Es el "now playing" de la app.
Decisiones clave:
1. Vive en el layout raíz, no en cada pantalla
// app/(tabs)/_layout.tsx (ilustrativo)
<Tabs ...>
<Tabs.Screen ... />
</Tabs>
<MiniPlayer />
Si pongo el MiniPlayer dentro de cada pantalla, al cambiar de tab desaparece y reaparece, perdiendo cualquier animación y sintiéndose discontinuo. Vivirlo en el layout = continuidad real.
2. Self-rendering vs. conditional
if (!current) return null;
El componente decide solo si renderizarse. No tengo que envolverlo en {playerHasCurrent && <MiniPlayer />} desde fuera. Eso simplifica el layout padre.
3. Pulso animado para "está sonando"
const pulse = useSharedValue(0);
useEffect(() => {
if (isPlaying) {
pulse.value = withRepeat(
withTiming(1, { duration: 900, easing: Easing.inOut(Easing.quad) }),
-1,
true,
);
} else {
pulse.value = withTiming(0, { duration: 200 });
}
}, [isPlaying, pulse]);
const pulseStyle = useAnimatedStyle(() => ({
transform: [{ scale: 0.85 + pulse.value * 0.25 }],
opacity: 0.55 + pulse.value * 0.45,
}));
Tres cosas que aprendí con react-native-reanimated:
- Las animaciones viven en el thread de UI, no en el thread de JS. Eso significa que aunque el JS esté ocupado, el pulso no se atasca.
withRepeat(..., -1, true)repite infinito y alterna (autoreverse). Sin esto, el pulso se ve como un "tic" repetido en vez de respirar.- Detener limpiamente al pausar.
withTiming(0, { duration: 200 })lleva el pulso a 0 con suavidad. Sin esto, parar el pulso es un corte abrupto que se nota mal.
4. Botón de stop además de pause
<Pressable onPress={() => void stop()} accessibilityLabel="Detener reproducción">
<Ionicons name="close" size={16} color="#a5acc4" />
</Pressable>
Pausa y detener son acciones distintas:
- Pause mantiene la emisora cargada y el MiniPlayer visible.
- Stop descarga el audio, libera recursos y oculta el MiniPlayer.
El usuario que termina de escuchar quiere "salir del player", no quiere ver una barra fantasma sin sonido. Es el botón pequeño pero importante.
expo-image vs. Image de RN: lo elegí por dos razones
import { Image } from 'expo-image';
<Image
source={{ uri: station.favicon }}
style={{ width: 56, height: 56 }}
contentFit="cover"
transition={150}
/>
expo-image me da:
- Caché en disco automática. Las favicons de las emisoras se repiten entre pantallas y entre sesiones. Sin caché, descargo 50 PNG cada vez que abro favoritos.
transition. Fade-in al cargar. Pequeño pero hace que la lista no se sienta como un parpadeo de placeholders.contentFitdeclarativo, sin las particularidades delresizeModede RN.
Para imágenes pequeñas y repetidas (avatares, favicons), expo-image es claramente superior. Para casos triviales, Image de RN sigue valiendo.
Selector y SelectorTrigger: separar la cabeza del cuerpo
Tenía un picker para país y otro para género. Empezaron como dos componentes distintos. Al notar que eran 90% iguales, los unifiqué partiendo la responsabilidad:
SelectorTrigger— el botón que muestra la selección actual. Cualquier pantalla lo usa.Selector— el modal/sheet con la lista de opciones, búsqueda interna y "limpiar".
El truco es que Selector recibe items, value, onChange. No sabe si está mostrando países, géneros, o el día de la semana. Lo único específico es lo que le pasas. Componente tonto, contenedor inteligente — el clásico que funciona.
EmptyState y SkeletonRow: la diferencia entre vacío y cargando
Dos errores típicos:
- Mostrar "no hay resultados" mientras todavía está cargando. Es lo peor: el usuario cree que la app no tiene datos.
- Mostrar un spinner mientras los datos ya están vacíos. Es lo segundo peor: el usuario espera algo que no va a pasar.
La regla:
if (loading) return <SkeletonRow ... />; // está pasando algo
if (data.length === 0) return <EmptyState ... />; // no hay nada y no va a haber
return <List data={data} />;
SkeletonRow son tres placeholders con el shape de una StationCard real. EmptyState es un icono, un mensaje y una acción opcional. Ambos son un componente cada uno, una vez, reutilizados en todas las pantallas que listan algo.
Lo que no extraje (y bien hecho)
- El layout de tabs. Vive en
app/(tabs)/_layout.tsxy no merecía la pena abstraerlo: expo-router ya hace el trabajo. - La pantalla de splash/index. Es código muy específico de arranque, no se reutiliza.
- El detail de estación. Aunque comparte muchos elementos con la card, su layout es muy distinto.
La tentación de extraer siempre está. Resistirla cuando solo hay un uso es más sabio que crear componentes que tienen una sola llamada y diez props para "configurarlo".
La conclusión que me llevo
Un mini design system de 10-12 componentes en una app pequeña no es sobreingeniería: es el punto justo. Te da consistencia visual, te ahorra duplicaciones, y cuando rediseñas iteras tocando un componente, no diez sitios.
Pero la clave no es el listado de componentes. La clave es el acuerdo común: accesibilidad por defecto, props mínimas, hooks dentro del componente cuando aplica, memo solo en listas. Si todos los componentes siguen esas reglas, leer cualquiera de ellos cuesta lo mismo, y eso es el verdadero ahorro.
En el próximo capítulo: cómo monté el buscador con debounce, abort y paginación honesta, y por qué los favoritos viven en AsyncStorage en vez de en una base de datos local.