El ciclo intent → reduce → render
El ciclo intent a reduce a render es el corazon operativo de MVI: el punto donde el Intent de entrada y el Model de salida se enlazan por una funcion pura sobre un unico StateFlow inmutable. Esta leccion recorre la vuelta completa: un intent entra por onIntent, reduce lo combina con el estado actual para producir uno nuevo, StateFlow.update lo publica de forma atomica y Compose, al recolectar ese flujo con collectAsStateWithLifecycle, recompone la interfaz. Defiende que la aplicacion entera es un scan sobre el flujo de intents, que render no es un paso que ordenas sino una consecuencia que ocurre, y que la pureza de reduce es lo que vuelve el bucle determinista, reproducible y comprobable sin encender un emulador.
Ya tienes las dos mitades del patrón: en las lecciones anteriores modelaste el Intent como la entrada del sistema y el Model como su salida. Falta el tejido que las une, y ese tejido es un ciclo de tres tiempos que se repite mientras la pantalla siga viva. Un intent entra, una función lo reduce a un estado nuevo, y la vista se vuelve a dibujar a partir de ese estado: intent, reduce, render. La palabra decisiva es ciclo, no secuencia. Cuando el usuario toca la pantalla recién pintada no reanuda un hilo que venía de antes, sino que arranca otra vuelta idéntica emitiendo un intent nuevo. Sobre un único StateFlow inmutable, esa vuelta se cierra sobre sí misma una y otra vez, y toda la vida de la interfaz cabe en ese giro que no se detiene.
- Trazar la vuelta completa
intentareducearendercomo un bucle cerrado sobre unStateFlow. - Entender
reducecomo una función pura(estado, intent) => estadoNuevo, el centro del ciclo. - Ver cómo
StateFlow.updatepublica el estado nuevo de forma atómica y libre de carreras. - Comprender por qué en Compose el render no se ordena: ocurre como consecuencia del estado.
El ciclo como una sola vuelta
Una vuelta de MVI tiene tres tiempos y ni uno más. Primero, la vista emite un Intent: el usuario tocó algo y esa interacción se convirtió en un dato que entra por onIntent. Segundo, reduce toma ese intent junto al estado vigente y calcula el estado siguiente, sin tocar nada del mundo exterior. Tercero, ese estado nuevo se publica en el StateFlow y la vista, que lo está observando, se redibuja. Ahí termina la vuelta. Si el usuario vuelve a actuar sobre la pantalla ya redibujada, no continúa donde lo dejó: empieza una vuelta nueva, con la misma forma exacta.
flowchart LR U[usuario toca la pantalla] --> I[emite un Intent] I --> OI[onIntent] OI --> RD[reduce estado mas intent] RD --> NS[StateFlow publica estado nuevo] NS --> RE[Compose recompone] RE --> U style I fill:#89b4fa,color:#11111b style RD fill:#f9e2af,color:#11111b style NS fill:#a6e3a1,color:#11111b style RE fill:#cba6f7,color:#11111b
Fíjate en que ninguna flecha va hacia atrás dentro de una misma vuelta. La vista no escribe en el estado; el estado no llama a la vista para ordenarle que se pinte. Cada tramo entrega su resultado al siguiente y se desentiende. Esa geometría —la misma del flujo unidireccional del nivel 17, ahora hablada en Kotlin— es la que permite razonar sobre la pantalla como sobre un bucle de tres pasos y no como sobre una maraña de callbacks que se llaman entre sí.
intent
La interacción del usuario cosificada como un dato que entra por onIntent. No hace nada por sí misma: solo declara qué se quiso.
reduce
La función pura que dobla el intent sobre el estado actual y devuelve el siguiente. Aquí vive toda la lógica de cómo cambia la pantalla.
render
Compose proyecta el estado nuevo en píxeles. No lo ordenas: ocurre porque el StateFlow emitió un valor distinto.
reduce: la función pura en el centro
El corazón del ciclo es reduce, y conviene escribirlo como lo que es: una función pura, aislada del ViewModel, que recibe el estado actual y un intent y devuelve el estado siguiente. Nada más entra —ni la hora, ni la red, ni una variable de fuera— y nada más sale que su return. Sacarlo del ViewModel a una función suelta no es capricho: es lo que te deja probarlo pasándole valores, sin montar medio Android alrededor.
data class ContadorState(val cuenta: Int = 0, val paso: Int = 1)
sealed interface ContadorIntent {
data object Incrementar : ContadorIntent
data object Decrementar : ContadorIntent
data class PasoCambiado(val nuevoPaso: Int) : ContadorIntent
}
// reduce: funcion pura, aislada del ViewModel. (estado, intent) -> estado nuevo.
fun reduce(estado: ContadorState, intent: ContadorIntent): ContadorState =
when (intent) {
ContadorIntent.Incrementar -> estado.copy(cuenta = estado.cuenta + estado.paso)
ContadorIntent.Decrementar -> estado.copy(cuenta = estado.cuenta - estado.paso)
is ContadorIntent.PasoCambiado -> estado.copy(paso = intent.nuevoPaso)
}
El ViewModel se limita a orquestar: guarda el estado en un MutableStateFlow, lo expone en solo lectura y, en onIntent, aplica reduce sobre el valor vigente. La pieza clave es update, que lee el estado actual, le aplica tu función y publica el resultado en una sola operación atómica. Si dos corrutinas emiten intents a la vez, update garantiza que ninguna pisa a la otra.
class ContadorViewModel : ViewModel() {
private val _state = MutableStateFlow(ContadorState())
val state: StateFlow<ContadorState> = _state.asStateFlow()
// Punto de entrada unico: publica el estado nuevo de forma atomica.
fun onIntent(intent: ContadorIntent) {
_state.update { actual -> reduce(actual, intent) }
}
}
Es tentador escribir _state.value = reduce(_state.value, intent) y darlo por equivalente. No lo es. Esa línea hace tres cosas —leer, calcular, escribir— que otra corrutina puede interrumpir entre medias: si dos intents llegan casi a la vez, ambos leen el mismo estado viejo y el segundo sobrescribe al primero, perdiendo un incremento. update ejecuta ese leer-calcular-escribir como una unidad indivisible y reintenta si el valor cambió mientras calculaba. Como en MVI todo cambio de estado pasa por un solo sitio, basta usar update ahí para que la actualización sea consistente bajo concurrencia. La pureza de reduce es lo que hace seguro ese reintento: volver a llamarla con el estado más fresco no arrastra ningún efecto colateral.
Si reduce no puede tocar la red ni el reloj, ¿dónde va una llamada a una API? Fuera del reduce, en el ViewModel. Un onIntent puede, además de reducir, lanzar una corrutina en viewModelScope que hace el trabajo sucio y, cuando termina, entra de nuevo por el ciclo emitiendo otro intent —un ResultadosRecibidos, por ejemplo— que reduce sí sabe plegar en el estado. Así el centro sigue siendo una función pura y comprobable, y los efectos quedan empujados al borde: la misma partición entre un núcleo puro y un borde impuro que gobernaba los reducers de Redux.
render: Compose recompone y cierra el bucle
Falta el tercer tiempo, y es el que menos se parece a lo que dictaría la intuición imperativa: tú nunca ordenas que se dibuje. La vista se suscribe al StateFlow con collectAsStateWithLifecycle, que convierte cada valor emitido en estado observable de Compose y, de paso, deja de recolectar cuando la pantalla no está visible. Cuando reduce publica un estado nuevo, Compose lo detecta y reprograma la recomposición de los componibles que leyeron ese estado. Render no es una instrucción que das: es la consecuencia automática de que el estado cambió.
// Componible con estado: recolecta el flujo y delega el dibujo.
@Composable
fun ContadorScreen(vm: ContadorViewModel = viewModel()) {
val estado by vm.state.collectAsStateWithLifecycle()
ContadorContenido(estado = estado, onIntent = vm::onIntent)
}
// Componible sin estado: funcion pura del estado, previsualizable y testeable.
@Composable
fun ContadorContenido(estado: ContadorState, onIntent: (ContadorIntent) -> Unit) {
Column {
Text("Cuenta: ${estado.cuenta} (paso ${estado.paso})")
Button(onClick = { onIntent(ContadorIntent.Incrementar) }) { Text("Mas") }
Button(onClick = { onIntent(ContadorIntent.Decrementar) }) { Text("Menos") }
}
}
El corte en dos componibles no es adorno. El de arriba tiene estado —recolecta el flujo y conoce el ViewModel—; el de abajo es una función pura del estado que recibe, así que se previsualiza y se prueba pasándole un ContadorState cualquiera y una lambda vacía. Esa separación, el state hoisting de Compose, es la que vuelve literal la ecuación vista = f(estado): el contenido no sabe de dónde vino el estado ni a dónde van los intents, solo pinta lo que le dan y avisa de lo que el usuario quiso.
Hay un dividendo de rendimiento en esta última parte que conviene ver. Compose no repinta la pantalla entera cuando el estado cambia: gracias a su sistema de snapshots, solo vuelven a ejecutarse los componibles que leyeron el trozo de estado que cambió, y las anotaciones de estabilidad del nivel anterior le permiten saltarse subárboles que reciben el mismo valor. Por eso un estado único inmutable no implica recomponer de más: implica recomponer exactamente lo que dejó de ser cierto, ni un píxel más.
Una vuelta que sale al mundo y regresa
El contador no toca el exterior, pero casi ninguna pantalla real es tan pura. En cuanto hay una carga de datos, un solo intent no basta para cerrar el trabajo: dispara un efecto que tarda, y el resultado tiene que volver a entrar por el ciclo como un intent nuevo. El molde es siempre el mismo. onIntent recibe la intención de cargar, reduce el estado al modo de carga y, en la misma llamada, lanza una corrutina que hará el trabajo lento; cuando esa corrutina termina, no escribe el estado a mano, sino que emite otro intent —el que transporta el resultado— y deja que reduce lo pliegue.
fun onIntent(intent: TareasIntent) {
when (intent) {
TareasIntent.Cargar -> {
_state.update { reduce(it, intent) } // pasa al modo de carga
viewModelScope.launch {
val items = repo.cargar() // efecto: tarda y puede fallar
onIntent(TareasIntent.Recibidas(items)) // el resultado reentra por el ciclo
}
}
else -> _state.update { reduce(it, intent) }
}
}
Observa la simetría: tanto la intención del usuario como la respuesta de la red entran por la misma puerta y se pliegan con la misma función pura. El efecto —la parte impura, la que espera y puede fallar— queda confinado a la corrutina, mientras que el estado solo cambia doblando intents. Una carga, vista así, no son dos mecanismos distintos: son dos vueltas del mismo bucle, una que sale al mundo y otra que trae lo que el mundo respondió.
El salto que separa a quien ejecuta MVI de quien lo comprende está en ver que el ciclo intent-reduce-render no son tres pasos que programas, sino una sola ecuación que se repite: estadoSiguiente = reduce(estadoActual, intent). Escrita así, la aplicación completa deja de ser una lista de instrucciones y se revela como un plegado que nunca termina —exactamente el Array.reduce del nivel 18, pero sobre un flujo de eventos en vez de un array cerrado—. Si tuvieras todos los intents de una sesión en una lista, el estado final sería intents.fold(inicial, ::reduce); como los intents llegan repartidos en el tiempo, el operador que corresponde es scan —o runningFold— sobre el Flow, que emite un estado nuevo por cada intent y es, punto por punto, lo que hace tu StateFlow. El render es la otra mitad de la ecuación: vista = estado.map(::render), una proyección pura del estado a píxeles. Toda tu pantalla, vista desde esta altura, son dos funciones puras —una que dobla intents en estados, otra que proyecta estados en vistas— y un flujo que las conecta. De ahí sale, gratis, todo lo que asociamos a MVI. Como reduce es pura, la misma secuencia de intents sobre el mismo estado inicial produce siempre el mismo estado final, así que puedes reproducir sesiones, viajar en el tiempo entre estados y probar la lógica sin encender un emulador. Y como el render es una consecuencia y no una orden, dejas de preguntarte cuándo repintar: repintas siempre que el estado cambia, ni antes ni después, porque la vista no es algo que diriges sino algo que se deduce. Quien internaliza esto deja de pensar en pasos y empieza a pensar en la ecuación, y esa ecuación es la misma en Elm, en Redux y en MVI: cambia el lenguaje, no el álgebra.
- Escribe
reducecomo una función suelta, fuera delViewModel, y pruébala pasándole un estado y un intent: afirma sobre el estado que devuelve, sin ningún mock ni emulador. - Cablea esa función con
_state.update { reduce(it, intent) }y explica, con el ejemplo de dos incrementos casi simultáneos, por qué es más seguro que_state.value = reduce(_state.value, intent). - Toma una interacción real de tu app y nombra el valor que viaja en cada uno de los tres tiempos: qué
Intentse emite, qué estado devuelvereduce, qué pinta la vista. - Parte tu pantalla en un componible con estado y otro sin estado; previsualiza el segundo pasándole un estado a mano y una lambda vacía, y comprueba que no necesita el
ViewModel. - Argumenta por qué nunca llamas a una función “dibujar”: completa la frase “el render ocurre cuando…” y explica qué significa que la vista sea una consecuencia del estado y no una orden tuya.