Saltar al contenido
Todas las entradas

StateFlow vs SharedFlow en Jetpack Compose: modelar el estado de la UI sin el bug de replay

Cuándo usar StateFlow y cuándo usar SharedFlow en un ViewModel de Compose — y el bug de evento único que aparece al confundirlos.

MFKAPPS 5 min de lectura

Cada pantalla de Compose que he publicado termina por plantear la misma pregunta: ¿esta información describe cómo se ve la pantalla ahora mismo, o describe algo que ocurrió una vez? Responder mal produce un bug concreto y molesto — un toast que reaparece tras una rotación, un evento de navegación que se dispara dos veces, una celebración de “sesión completada” que se repite cada vez que el usuario manda la app a segundo plano y la vuelve a abrir. La solución no es un parche. Es elegir el tipo de Flow correcto desde el principio.

Kotlin ofrece dos hot flows que parecen intercambiables desde el punto de llamada — ambos se recolectan con collectAsStateWithLifecycle() o un LaunchedEffect, ambos viven en un ViewModel, ambos sobreviven a los cambios de configuración. No son intercambiables, y la diferencia es exactamente la distinción de arriba.

StateFlow: siempre tiene un valor, siempre lo reproduce

StateFlow está pensado para el estado — datos que tienen un valor actual en todo momento, haya o no alguien observando. Los segundos restantes de un temporizador, los errores de validación de un formulario, el estado loading/error/success de una lista. Los nuevos colectores reciben de inmediato el último valor, y cada colector ve siempre lo mismo.

private val _uiState = MutableStateFlow(TimerUiState.Idle)
val uiState: StateFlow<TimerUiState> = _uiState.asStateFlow()

fun start(durationMinutes: Int) {
    _uiState.value = TimerUiState.Running(
        remainingSeconds = durationMinutes * 60,
    )
}

Ese comportamiento de “siempre reproducir el último valor a los nuevos colectores” es todo el propósito de StateFlow — es lo que hace que una pantalla de Compose sea correcta tras un cambio de configuración. Rotas el dispositivo, el Composable se recompone, se conecta un colector nuevo, y renderiza de inmediato el estado actual en lugar de una pantalla en blanco esperando la próxima emisión.

SharedFlow: describe algo que ocurrió

Ese mismo comportamiento de replay es exactamente incorrecto para un evento puntual. Digamos que una sesión Pomodoro termina y quieres disparar un sonido de “bien hecho” y una animación de celebración sutil — algo que debería ocurrir una vez, en el momento en que sucede, no cada vez que la pantalla se recompone.

Si ese evento se modela como un StateFlow<Boolean> que pasa a true al completarse, cada nuevo colector — incluido el creado tras una rotación, o después de que el usuario salga de la app y vuelva — ve true de inmediato y repite la celebración. El arreglo al que la gente recurre primero suele ser un reset manual (_sessionComplete.value = false justo después de consumirlo), que funciona hasta que dos colectores compiten por leerlo, o el reset se ejecuta en el dispatcher equivocado y la bandera nunca vuelve a tiempo.

SharedFlow sin replay es el primitivo correcto aquí, porque no retiene ningún “valor actual” — solo emite a los colectores activamente suscritos en el momento de la emisión:

private val _events = MutableSharedFlow<TimerEvent>()
val events: SharedFlow<TimerEvent> = _events.asSharedFlow()

private fun onSessionComplete() {
    viewModelScope.launch {
        _events.emit(TimerEvent.SessionComplete)
    }
}

Un colector que no estaba escuchando en el momento del evento simplemente nunca lo ve — lo cual es correcto para una animación de celebración, y sería incorrecto para el estado del temporizador. Esa asimetría es toda la decisión: si un suscriptor tardío debería ver el valor actual, es estado; si debería haberlo perdido, es un evento.

La regla práctica que realmente funciona

Uso una sola pregunta para decidir, y se ha sostenido en cada pantalla que he construido así: ¿sería un bug que un colector completamente nuevo recibiera este valor de inmediato?

  • Estado del temporizador, contenido de un formulario, indicadores de carga, contenido de una lista → no, un colector nuevo debería ver el valor actual → StateFlow.
  • “Muestra este snackbar”, “navega a esta pantalla”, “reproduce este sonido” → sí, reproducírselo a un suscriptor tardío es el bug → SharedFlow(replay = 0).

La única excepción que vale la pena conocer: SharedFlow(replay = 1) existe y parece un término medio, pero en la práctica es solo un StateFlow sin la garantía de “siempre tiene un valor” y sin .value para lecturas síncronas — rara vez hay razón para recurrir a él en lugar de un StateFlow real cuando se modela estado, o un SharedFlow(replay = 0) cuando se modela un evento.

Conectarlo a una pantalla de Compose

Los dos se consumen de forma distinta, y confundir eso es la segunda versión más común de este bug:

@Composable
fun TimerScreen(viewModel: TimerViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            when (event) {
                TimerEvent.SessionComplete -> playCompletionSound()
            }
        }
    }

    TimerContent(uiState)
}

collectAsStateWithLifecycle() es para StateFlow — necesita un valor actual que exponer como estado de Compose. events se recolecta en cambio dentro de un LaunchedEffect, precisamente porque no tiene un valor actual que retener; es un flujo al que reaccionar, no un valor que leer.

La conclusión

El bug que esto evita es lo bastante sutil como para pasar a producción sin que nadie lo note — un toast duplicado tras una rotación, una animación que se vuelve a disparar tras volver del segundo plano — porque en el caso común de un solo colector que nunca se va, todo parece funcionar bien. Solo aparece cuando una pantalla reinicia su colector, que es exactamente lo que Compose hace en cada cambio de configuración. Modela el estado con StateFlow porque se supone que los colectores nuevos deben ponerse al día. Modela los eventos con un SharedFlow sin replay porque no deben. Uso esta separación en el temporizador de Mintly: la cuenta regresiva en curso es estado, que cada recomposición debe reflejar correctamente; la celebración de fin de sesión es un evento que debe dispararse exactamente una vez — nunca dos, nunca en una pantalla recién reabierta.