StateFlow: el estado observable con valor actual
StateFlow es el flujo caliente que siempre tiene un valor y siempre te lo da al suscribirte, y por eso es el hogar natural del Model en MVI. Esta lección explica sus tres rasgos definitorios y las consecuencias de cada uno: que exige un valor inicial y expone value de forma síncrona, lo que elimina por construcción el estado nulo o vacío que obligaba a defenderse en cada lectura; que está conflado, de modo que un suscriptor lento nunca ve los valores intermedios pero siempre acaba viendo el último, que es justo lo que una interfaz necesita; y que deduplica por igualdad, lo que convierte la inmutabilidad de la data class de estado en un requisito operativo y no en un gusto estético. Muestra por qué asignar sobre value con copy abre una ventana de actualización perdida y por qué update la cierra con una comparación atómica, cómo se expone el estado como solo lectura con asStateFlow, y cómo se colecciona en Compose respetando el ciclo de vida.
Un Flow frío describe valores que existirían si alguien los pidiera. El estado de una pantalla es otra cosa: no es una posibilidad, es un hecho presente. Cuando la interfaz se recrea tras una rotación no quiere que le vuelvan a calcular el estado, quiere que le digan cuál es ahora mismo, inmediatamente y sin esperar. StateFlow es exactamente esa figura: un flujo caliente que siempre tiene un valor, que lo entrega en el acto a quien se suscriba y que no termina nunca. Tres rasgos que suenan modestos y que, tomados en serio, convierten a StateFlow en el sitio donde el Model de MVI quería vivir desde el principio.
- Comprender los tres rasgos de
StateFlow: valor inicial obligatorio, conflación y deduplicación por igualdad. - Ver por qué exigir un valor inicial elimina de raíz el estado nulo o indefinido en la interfaz.
- Actualizar el estado sin perder escrituras usando
updateen lugar de asignar sobrevalue. - Exponer el estado como solo lectura y coleccionarlo en la interfaz respetando el ciclo de vida.
Siempre hay un valor, y siempre te lo dan
La firma lo dice todo: MutableStateFlow(valorInicial) no admite construirse vacío. No existe un StateFlow que todavía no tenga valor, igual que no existe una pantalla que todavía no tenga aspecto. Esa obligación, que al principio parece una molestia burocrática, es la que borra una familia entera de defensas: no hay que comprobar si el estado es nulo, no hay que dibujar un caso “aún no ha llegado nada”, no hay carrera entre la primera composición y la primera emisión. La pantalla arranca con el estado inicial que tú escribiste, que además es la mejor documentación posible de cómo se ve la pantalla antes de que ocurra nada.
data class TareasState(
val cargando: Boolean = false,
val tareas: List<Tarea> = emptyList(),
val error: String? = null,
)
class TareasViewModel(private val repo: TareasRepo) : ViewModel() {
private val _state = MutableStateFlow(TareasState()) // valor inicial obligatorio
val state: StateFlow<TareasState> = _state.asStateFlow() // solo lectura hacia fuera
fun leerAhora(): TareasState = state.value // acceso sincrono, sin suspender
}
Dos gestos merecen atención. El primero es asStateFlow: expone el flujo sin su capacidad de escritura, de modo que la interfaz puede leer y observar pero no puede mutar. La asimetría es deliberada y es la misma que estudiaste en el ciclo unidireccional: el estado se modifica en un solo sitio, y desde fuera solo se mira. El segundo es value, que devuelve el estado actual sin suspender: es la puerta síncrona que permite consultar el estado en un test, en un manejador de eventos o en una comprobación puntual sin montar una corrutina.
Ese value es también la frontera conceptual con el mundo frío. Un Flow frío no tiene un valor actual porque no hay nada corriendo; preguntar “¿cuál es tu valor?” no significa nada. Un StateFlow está caliente: hay un valor guardado en memoria, con o sin observadores, y sigue ahí cuando el último se va. Por eso no termina jamás —no tiene sentido que una fotografía se acabe— y por eso collect sobre un StateFlow es una suspensión perpetua que solo la cancelación interrumpe.
Conflado y deduplicado: la semántica que la interfaz necesita
El segundo rasgo es la conflación. Si el estado cambia diez veces mientras un coleccionista está ocupado dibujando, ese coleccionista no recibirá diez emisiones al reanudarse: recibirá una, la última. Los valores intermedios se pierden, y esa pérdida es una virtud, no un defecto. Una interfaz no necesita repintar los nueve fotogramas que ya caducaron; necesita mostrar el actual. StateFlow no tiene búfer ni cola de pendientes: tiene una casilla que se sobrescribe.
El tercer rasgo es la deduplicación por igualdad. Si asignas un valor igual —según equals— al que ya había, no se emite nada. De ahí se sigue una consecuencia práctica de primer orden: la inmutabilidad de tu clase de estado deja de ser una preferencia estética y pasa a ser un requisito operativo. Si tu data class de estado contiene una lista mutable y la modificas en el sitio, el equals seguirá diciendo que nada cambió y la interfaz no se enterará; peor aún, será un fallo silencioso, sin excepción ni aviso. La regla que lo evita es la del nivel uno: estado inmutable, cambios siempre con copy.
flowchart TD
S[MutableStateFlow con un unico valor guardado] --> C{Llega un valor nuevo}
C -->|Igual al actual segun equals| N[No se emite nada]
C -->|Distinto| E[Se sobrescribe y se emite el ultimo]
E --> L[Coleccionista lento ve solo el ultimo]
E --> R[Suscriptor nuevo recibe el actual al instante]
style S fill:#89b4fa,color:#11111b
style N fill:#f9e2af,color:#11111b
style R fill:#a6e3a1,color:#11111bEsa conflación tiene una implicación que conviene aceptar sin resistencia: StateFlow no es un registro fiel de la historia. Si el estado pasa por A, B y C mientras nadie mira, quien llegue después verá C y no sabrá jamás que existieron A y B. Para una interfaz eso es exactamente lo correcto, porque nadie quiere ver fotogramas caducados; pero significa que no debes apoyar en el estado ninguna lógica que dependa de haber visto todas las transiciones. Si necesitas contar cuántas veces pasó algo, o reaccionar a cada paso individual, ese dato no era estado y estás pidiéndole al tipo una garantía que su contrato niega explícitamente.
La distinción importa porque a veces asusta: conflar nunca significa perder el valor final. Por lento que sea el coleccionista y por rápido que cambie el estado, el último valor siempre acaba entregándose, y por eso una pantalla lenta se ve retrasada pero jamás desincronizada. Esa propiedad —convergencia garantizada al valor actual, sin garantía sobre el camino— es justo la que hace que una interfaz pueda ir por detrás sin mentir nunca.
Los tres errores que producen el mismo síntoma —el estado cambia y la pantalla no se mueve— son: usar una class normal en vez de una data class, de modo que equals compara identidad; guardar dentro del estado una colección mutable y editarla en el sitio; y meter un campo cuyo equals no refleja su contenido, como un array. Cuando veas un cambio que no llega a la interfaz, sospecha de la igualdad antes que de la corrutina. Y si de verdad necesitas emitir dos veces el mismo valor, esa señal no era estado: era un evento, y le corresponde el canal de la lección siguiente.
update: cerrar la ventana de la actualización perdida
Hay una trampa que casi todo el mundo pisa una vez. La forma intuitiva de cambiar el estado es leerlo, copiarlo y asignarlo: _state.value = _state.value.copy(cargando = true). Cada una de esas operaciones es atómica por separado, pero la secuencia entera no lo es. Entre la lectura y la escritura cabe otra corrutina que también leyó el valor viejo, copió sobre él y escribió; cuando la primera escriba, sobrescribirá el trabajo de la segunda sin enterarse. Es la actualización perdida clásica, y en un ViewModel con varias corrutinas concurrentes ocurre de verdad, aunque de forma intermitente y por eso difícil de diagnosticar.
// Fragil: leer, copiar y escribir son tres pasos, y otra corrutina cabe en medio
_state.value = _state.value.copy(cargando = true)
// Correcto: comparacion e intercambio atomicos, reintenta si alguien se adelanto
_state.update { actual -> actual.copy(cargando = true) }
fun cargar() = viewModelScope.launch {
_state.update { it.copy(cargando = true, error = null) }
val resultado = runCatching { repo.tareas() }
_state.update { actual ->
resultado.fold(
onSuccess = { lista -> actual.copy(cargando = false, tareas = lista) },
onFailure = { e -> actual.copy(cargando = false, error = e.message) },
)
}
}
update recibe una función del estado viejo al nuevo y la aplica con una comparación e intercambio atómicos: si al ir a escribir descubre que el valor ha cambiado desde que lo leyó, descarta el resultado y vuelve a ejecutar la lambda con el valor fresco. De ahí sale la única regla que hay que respetar: esa lambda debe ser pura y barata, porque puede ejecutarse más de una vez. Nada de llamadas de red dentro, nada de efectos observables; solo transformar datos.
Hay una segunda ventaja de update que se aprecia al leer código ajeno: expresa la actualización como una función del estado anterior, que es exactamente la forma en que hay que pensar el estado en MVI. Al escribir la asignación directa se pierde esa lectura, porque el estado nuevo aparece como un valor cualquiera que alguien decidió poner ahí; con update queda claro que el estado siguiente se deriva del anterior y que ninguna otra fuente participa en la decisión. La diferencia es de intención tanto como de atomicidad.
Merece subrayar la simetría con lo que ya sabes de Orbit. reduce es esta misma idea elevada a convención: una transformación pura del estado anterior al siguiente, serializada por la librería para que nunca haya dos reducciones pisándose. Cuando escribes MVI a mano sobre MutableStateFlow, update es la pieza que te da esa garantía; cuando usas Orbit, ya la tienes puesta. Reconocer que son la misma idea con dos nombres es entender qué parte del trabajo te está quitando la librería.
El hogar natural del Model
Junta ahora los tres rasgos y compáralos con lo que MVI pide de su Model. El Model debe existir siempre y ser total, sin estados indefinidos: StateFlow exige valor inicial. El Model es una fotografía de la que solo importa la más reciente: StateFlow conflaciona. El Model debe sobrevivir a que la vista muera y renazca, y entregarse íntegro al nuevo observador: StateFlow retiene el valor y lo emite al suscribirse. El Model se reemplaza entero, nunca se muta por partes: StateFlow deduplica por igualdad, lo que solo funciona bien si respetas esa disciplina. No es que StateFlow sirva para el Model; es que el contrato de StateFlow y el contrato del Model coinciden punto por punto.
@Composable
fun TareasScreen(viewModel: TareasViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
when {
state.cargando -> Indicador()
state.error != null -> ErrorView(state.error!!)
else -> Lista(state.tareas)
}
}
Hay un beneficio de esa coincidencia que solo se aprecia al escribir pruebas. Como el estado es un valor consultable y no una secuencia de notificaciones, un test no necesita observadores falsos, ni esperas, ni verificaciones de llamadas: invoca la función, lee value y compara con el estado esperado. Y como la transformación es pura, el test es determinista por construcción. Esa facilidad no es un premio adicional: es el mismo rasgo —una sola verdad consultable— visto desde el banco de pruebas.
@Test
fun `cargar deja la lista y apaga el indicador`() = runTest {
val vm = TareasViewModel(RepoFalso(listOf(Tarea("uno"))))
vm.cargar()
val estado = vm.state.value // sin observadores ni esperas
assertEquals(false, estado.cargando)
assertEquals(1, estado.tareas.size)
}
collectAsStateWithLifecycle es la forma correcta en Android de cerrar el circuito: colecciona mientras la pantalla está al menos iniciada y deja de hacerlo cuando pasa a segundo plano, sin que tú escribas una línea de ciclo de vida. Al volver, el valor actual se entrega de inmediato porque StateFlow nunca dejó de tenerlo. La rotación deja de ser un caso especial: la vista muere, el ViewModel sobrevive con su estado intacto y la vista nueva pregunta y recibe. Ese es el final feliz que las banderas y los envoltorios de la era anterior intentaban simular a mano.
La tentación de exponer varios flujos pequeños —uno para la lista, otro para el indicador, otro para el error— parece más modular y es peor: la interfaz puede observar combinaciones que jamás debieron coexistir, como cargando y error a la vez, y no hay ningún sitio donde esa invariante se pueda escribir. Un único StateFlow de una data class completa hace que cada emisión sea una fotografía consistente por construcción. Modulariza dentro del estado, con tipos y clases anidadas, no fuera de él con flujos sueltos.
Lo que hace grande a StateFlow no es que emita, es que recuerda. Y merece la pena ver por qué esa diferencia reorganiza toda una arquitectura. Un bus de eventos, un Subject sin réplica, un callback registrado: todos ellos comunican, pero ninguno recuerda; quien no estuviera escuchando en el instante exacto de la emisión se pierde la información para siempre, y por eso las arquitecturas construidas sobre pura notificación acaban obligadas a mantener en algún rincón una copia mutable del estado para que el que llegue tarde pueda ponerse al día. Esa copia paralela es el pecado original: hay dos verdades, la que se guarda y la que se anuncia, y todo bug de sincronización de interfaz que hayas sufrido nació de que ambas se separaron. StateFlow colapsa las dos en una sola cosa. La casilla de memoria y el canal de notificación son el mismo objeto, así que es imposible por construcción que el valor guardado y el valor anunciado difieran: no hay dos sitios donde puedan divergir. De ahí sale la propiedad que sostiene el nivel entero: cualquier observador, en cualquier momento, presente o recién llegado, converge al mismo estado sin ningún protocolo de puesta al día, sin preguntar, sin repetir la petición y sin coordinarse con nadie. La rotación de pantalla deja de ser un problema, el segundo observador deja de ser un problema, arrancar tarde deja de ser un problema —y no porque los hayas resuelto, sino porque desaparecieron con la duplicidad que los causaba. Cuando el estado deja de ser algo que se anuncia y pasa a ser algo que se consulta, la mitad de la coordinación de tu programa se evapora, y lo que queda es una función del estado a la pantalla que se puede leer de un vistazo.
- Define una
data classde estado con valores por defecto para una pantalla que conozcas y justifica por qué ese estado inicial es también su mejor documentación. - Expón el estado con
MutableStateFlowprivado yasStateFlowpúblico, y explica qué invariante del ciclo unidireccional protege esa asimetría. - Reproduce una actualización perdida lanzando dos corrutinas que asignen sobre
valueconcopy, y arréglala conupdate. Describe por qué la lambda deupdatedebe ser pura. - Introduce a propósito una lista mutable dentro del estado, modifícala en el sitio y explica por qué la interfaz no se entera pese a que el dato cambió.
- Enumera los cuatro requisitos del Model en MVI y empareja cada uno con el rasgo de
StateFlowque lo satisface.