El modelo mental completo: intent, reduce, estado y efecto
Todo el track cabe en un bucle de cuatro piezas y dos garantías, y esta lección lo reconstruye entero desde los cimientos: por qué el intent es un dato y no una llamada, por qué el reduce es puro y secuencial, por qué el estado es un valor inmutable comparable por igualdad y por qué el efecto necesita un canal aparte. Después encaja ese bucle con las dos máquinas que lo ejecutan —las corrutinas, que aportan concurrencia estructurada y cancelación cooperativa, y Compose, que aporta recomposición dirigida por igualdad— y termina señalando las cuatro costuras exactas donde el modelo se rompe si se le pide más de lo que promete.
Un modelo mental no es un resumen de lo aprendido: es la estructura mínima que te permite deducir el resto cuando no recuerdas los detalles. Después de veinte niveles de piezas sueltas —tipos sellados, reductores puros, flujos calientes, canales de efectos, recomposición y estabilidad— llega el momento de comprobar si todo eso colapsa en algo que quepa en la cabeza a la vez. La respuesta es que sí, y que cabe en menos de lo que parece: cuatro piezas, un bucle, dos garantías y dos motores. Lo demás son consecuencias. Si has entendido bien este nivel, no necesitarás recordar cómo se escribía un Channel ni qué hacía flatMapLatest, porque sabrás deducir qué pieza corresponde a cada problema y qué garantía se rompe si eliges mal.
- Reconstruir el bucle completo desde sus cuatro piezas y enunciar las dos garantías que lo sostienen.
- Explicar el encaje con las corrutinas: dónde vive la concurrencia y por qué el reduce queda fuera de ella.
- Explicar el encaje con Compose: por qué la igualdad estructural del estado gobierna la recomposición.
- Identificar las cuatro costuras donde el modelo se rompe y qué se hace en cada una.
Cuatro piezas y un bucle
El bucle entero se enuncia en una frase que conviene saber decir sin titubear: una intención entra como dato, un reductor puro la convierte en un estado nuevo, la interfaz se redibuja desde ese estado, y todo lo que no es estado sale por un canal aparte de una sola vez. Cada palabra de esa frase está pagada con una lección del track.
Que la intención sea un dato y no una llamada a un método es lo que permite encolarla, registrarla, reproducirla y comprobar exhaustivamente en un when que ninguna quedó sin tratar. Una llamada se ejecuta y desaparece; un valor persiste y se puede examinar. Ese cambio de registro —de verbo a sustantivo— es el que convierte una sesión de usuario en una lista de valores, y una lista de valores es algo que se puede doblar, guardar y volver a aplicar.
Que el reductor sea puro es lo que permite afirmar el comportamiento sin ejecutarlo. Sin reloj, sin azar, sin red, sin registro, sin this. Su única salida es el valor devuelto, y por eso un test suyo es una aserción sobre valores y no una coreografía de dobles y esperas.
Que el estado sea inmutable y comparable por igualdad es lo que permite a Compose decidir qué saltarse. Un data class con copy produce un valor nuevo por transición, y la comparación entre el valor viejo y el nuevo es la señal que gobierna toda la recomposición aguas abajo.
Que el efecto viaje por un canal aparte es lo que reconoce que hay dos semánticas incompatibles: el estado converge y se puede releer, el efecto se entrega una vez y no se relee. Meter una navegación en el estado es pedirle a una estructura que converge que se comporte como una que no.
flowchart LR U[Gesto del usuario] --> I[Intent como dato] I --> C[Container secuencial] C --> R[Reduce puro] R --> S[Estado inmutable] S --> V[Compose redibuja] V --> U C --> E[Canal de efectos] E --> V style R fill:#cba6f7,color:#11111b style S fill:#a6e3a1,color:#11111b style E fill:#f9e2af,color:#11111b
Sobre ese bucle se apoyan dos garantías que hay que saber nombrar porque son las que se pierden primero cuando alguien improvisa. La primera es la unidireccionalidad: la interfaz nunca escribe en el modelo, solo envía intenciones; el modelo nunca lee de la interfaz, solo publica estado. La segunda es la serialización de las transiciones: por muchas corrutinas que haya en vuelo, las reducciones se aplican de una en una sobre el estado más reciente, de modo que nunca dos transiciones parten de la misma fotografía.
Conviene ver las cuatro piezas escritas juntas una vez, porque la declaración completa de una pantalla cabe en una pantalla de editor y esa brevedad es parte del argumento.
// 1. El estado: un valor inmutable, comparable por igualdad
data class PerfilState(
val cargando: Boolean = false,
val perfil: Perfil? = null,
val error: String? = null,
)
// 2. Las intenciones: un tipo cerrado con todo lo que puede ocurrir
sealed interface PerfilIntent {
data object PantallaMostrada : PerfilIntent
data class SeguirPulsado(val id: String) : PerfilIntent
}
// 3. Los efectos: lo que sale una sola vez y no se relee
sealed interface PerfilEfecto {
data class Avisar(val mensaje: String) : PerfilEfecto
data object VolverAtras : PerfilEfecto
}
// 4. El reductor: funcion libre, pura, total
fun reducir(estado: PerfilState, intent: PerfilIntent): PerfilState = when (intent) {
PerfilIntent.PantallaMostrada -> estado.copy(cargando = true, error = null)
is PerfilIntent.SeguirPulsado -> estado
}
Léelo buscando lo que no hay. No hay una referencia a Context. No hay una llamada suspendible. No hay una excepción que escape. No hay un else que oculte un caso sin decidir. Las cuatro ausencias son el contrato entero, y cada una de ellas es la que hace posible una garantía distinta: la primera permite probar en la JVM, la segunda permite serializar las transiciones, la tercera permite que todo fallo sea visible en el estado y la cuarta permite que añadir una intención rompa la compilación hasta que alguien decida qué hace.
Dónde entra la corrutina
El error más común al integrar corrutinas con MVI es creer que la concurrencia entra en el reductor. No entra, y la razón es estructural: si el reductor pudiera suspenderse, la garantía de serialización se rompería, porque entre la lectura del estado y la escritura del siguiente cabría otra transición entera. Por eso el reparto es rígido y merece memorizarse: el trabajo suspendible ocurre en el intent, la transición ocurre en el reduce, y el reduce no suspende nunca.
fun cargarPerfil(id: String) = intent {
reduce { state.copy(cargando = true, error = null) }
val resultado = runCatching { repositorio.perfil(id) } // suspende aqui
reduce {
resultado.fold(
onSuccess = { state.copy(cargando = false, perfil = it) },
onFailure = { state.copy(cargando = false, error = it.mensajeUsuario()) },
)
}
resultado.onFailure { postSideEffect(PerfilEfecto.Avisar("No se pudo cargar")) }
}
Léelo como una alternancia: reduce, suspende, reduce. Cada bloque reduce es un instante indivisible; entre ellos el mundo puede cambiar, y por eso el segundo reduce lee state de nuevo en vez de confiar en el que vio el primero. Esa relectura no es un detalle de estilo: es la única forma de que una respuesta lenta no pise el trabajo de una intención posterior.
El resto del encaje se sigue de la concurrencia estructurada. El ámbito de las corrutinas es el del ViewModel, de modo que cuando la pantalla muere de verdad, todo el trabajo en vuelo se cancela solo. La cancelación es cooperativa, lo que significa que tu código bloqueante no la respeta a menos que compruebe el estado del trabajo o llame a funciones suspendibles que lo hagan por él. Y los errores no se propagan hacia arriba por su cuenta: se capturan en el borde del intent y se traducen a estado, porque un fallo que solo existe en una excepción es un fallo que la interfaz no puede mostrar.
Nunca captures state en una variable antes de una suspensión para usarla después. El valor que leíste puede estar obsoleto cuando la corrutina despierte, y escribirlo de vuelta pisará silenciosamente cualquier transición ocurrida mientras tanto. Lee siempre state dentro del bloque reduce, que es el único punto donde tienes la garantía de estar viendo la fotografía más reciente.
Dónde entra Compose
Compose no participa del bucle: lo consume. Su contrato con MVI es de una simplicidad engañosa —recibe un estado y produce una descripción de interfaz— y toda la complejidad real está en decidir qué partes de esa descripción hay que volver a producir cuando el estado cambia. Esa decisión la toma comparando: si un parámetro es igual al de la composición anterior y su tipo es estable, la función se salta entera.
De ahí se sigue por qué MVI y Compose encajan tan bien y por qué el encaje se rompe con tanta facilidad. Encajan porque un data class inmutable de tipos estables da exactamente la señal que el runtime necesita. Se rompe en cuanto el estado contiene una List mutable, una lambda recreada en cada composición o un tipo de un módulo que el compilador no puede inferir como estable, porque entonces la comparación devuelve siempre desigual y el ahorro desaparece sin que nada lo avise.
@Composable
fun PerfilRoute(vm: PerfilViewModel = hiltViewModel()) {
val state by vm.container.stateFlow.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
vm.container.sideEffectFlow.collect { efecto ->
when (efecto) {
is PerfilEfecto.Avisar -> mostrarAviso(efecto.mensaje)
PerfilEfecto.VolverAtras -> navegar.popBackStack()
}
}
}
PerfilScreen(state = state, onIntent = vm::enviar)
}
Fíjate en el reparto: el componente de ruta habla con el ViewModel, colecciona el estado con conciencia del ciclo de vida y consume los efectos en un efecto lanzado; el componente de pantalla es una función del estado que no conoce el ViewModel y por eso se puede previsualizar, probar y reutilizar. Esa frontera es la versión Compose del state hoisting, y coincide exactamente con la frontera que MVI ya había trazado por otros motivos.
Coleccionar el flujo de efectos sin atender al ciclo de vida significa que la pantalla sigue recibiendo órdenes mientras está en segundo plano: navegaciones que ocurren donde nadie las ve, avisos que se descartan sin mostrarse. Coleccionarlo con conciencia del ciclo de vida detiene la colección al pasar a segundo plano, y el búfer del canal retiene lo emitido hasta la vuelta. Es la diferencia entre un efecto perdido y un efecto aplazado.
Las cuatro costuras
Un modelo mental maduro incluye sus propios límites. Estas son las cuatro costuras por donde el bucle se abre, y en cada una la respuesta correcta es reconocer que el modelo no cubre el caso, no forzarlo.
La muerte del proceso
El estado en memoria no sobrevive a que el sistema mate el proceso. Lo que debe volver se guarda explícitamente, y lo que viajaba por el canal de efectos se pierde sin rastro. Decide por adelantado qué campos merecen SavedStateHandle y acepta que el resto se recalcula.
El estado efímero de la vista
La posición de un scroll, el foco, una animación a medias y el texto de un campo mientras se escribe no pertenecen al modelo. Subirlos al estado global es pagar una transición y una recomposición por pulsación de tecla. Viven en remember y solo suben cuando la lógica necesita leerlos.
La granularidad del estado único
Una pantalla con secciones independientes castigadas por un solo data class recompone de más y obliga a razonar sobre combinaciones imposibles. La respuesta es componer subestados y subreductores, no partir el bucle en varios.
Los efectos que no pueden perderse
Una confirmación de pago o la apertura de un documento tras una subida no son efímeros: si se pierden, el usuario lo nota. Esos pocos casos viven en el estado con identidad y consumo explícito, aunque el resto siga usando el canal.
Hay un patrón común detrás de las cuatro: todas aparecen cuando se aplica una regla que era correcta en su dominio fuera de él. El estado único es correcto para la lógica de la pantalla y equivocado para el cursor de un campo de texto. El canal de efectos es correcto para un aviso y equivocado para una confirmación legal. Reconocer el dominio de validez de cada regla es exactamente lo que separa a quien aplica una arquitectura de quien la entiende.
De las cuatro, la que más discusiones inútiles genera es la tercera, y merece un ejemplo porque la solución es menos obvia de lo que parece. La tentación ante una pantalla con secciones independientes es multiplicar los containers, uno por sección, y esa salida rompe la garantía de serialización precisamente en el único sitio donde importaba: en las reglas que cruzan secciones. La salida correcta es un solo container con el estado compuesto de subestados.
data class PedidoState(
val carrito: CarritoState = CarritoState(),
val pago: PagoState = PagoState(),
val fase: Fase = Fase.Editando,
) {
// Regla que cruza secciones: vive en el padre porque solo el las ve juntas
val puedeConfirmar: Boolean get() = carrito.lineas.isNotEmpty() && pago.valido
}
Ese puedeConfirmar como propiedad calculada, y no como campo guardado, es un detalle con consecuencias: un valor derivado que se almacena es una segunda verdad que hay que mantener sincronizada a mano, y toda verdad duplicada acaba divergiendo. La regla general que se sigue de ahí vale para cualquier estado: guarda lo que no se puede deducir y calcula lo que sí.
Queda una pregunta que casi todo el mundo se hace al llegar aquí y que conviene responder sin adornos: ¿merece la pena todo esto para una pantalla de dos campos? No, y decirlo forma parte del modelo mental. El bucle completo compra la eliminación de estados imposibles, la comprobabilidad sin instrumentación y la reproducción de fallos; si una pantalla no tiene estados imposibles que eliminar ni fallos difíciles que reproducir, no había nada que comprar y la ceremonia es coste puro. El criterio no es aplicar el patrón en todas partes, sino saber qué señales indican que ya hace falta: más de tres campos que se condicionan entre sí, trabajo asíncrono que puede solaparse, o un bug que nadie consigue reproducir.
Si se quita todo lo específico de Android, de Kotlin y de Orbit, lo que queda de este track es una única idea, y es más vieja y más general que cualquier librería: un programa interactivo es una función que dobla una secuencia de eventos sobre un valor inicial. Nada más. El estado en el instante n no es una variable que ha ido cambiando, es el resultado de aplicar el reductor n veces sobre el estado inicial, y esa reformulación —de historia de mutaciones a sucesión de valores— es la que hace posible todo lo demás. De ella se sigue el viaje en el tiempo, porque los estados intermedios son valores que se pueden guardar. De ella se sigue la reproducción de fallos, porque la traza de intenciones es un valor que se puede reenviar. De ella se sigue que la concurrencia deje de dar miedo, porque una función pura no tiene sección crítica que proteger y las corrutinas solo compiten por el turno de entrar al reductor, no por la memoria. De ella se sigue incluso la recomposición barata, porque comparar dos valores es trivial mientras que detectar qué ha mutado dentro de un objeto es imposible sin ayuda. Todo el andamiaje que hemos construido —los tipos sellados, la inmutabilidad, la pureza, el canal separado, la serialización— existe para preservar esa propiedad frente a un mundo que empuja en dirección contraria: el mundo real es asíncrono, tiene fallos, tiene reloj y tiene un sistema operativo que mata procesos. La arquitectura es el conjunto de concesiones mínimas que se le hacen a ese mundo sin perder la propiedad. Por eso las decisiones difíciles del track no eran sobre sintaxis sino sobre dónde poner la frontera: qué se admite dentro del reductor, qué sale por el canal, qué se guarda y qué se recalcula. Y por eso el criterio último ante cualquier duda nueva se puede formular como una sola pregunta: ¿esta decisión conserva la posibilidad de reconstruir el estado a partir de los valores que tengo, o introduce una dependencia del orden, del reloj o de la memoria que ya no puedo reproducir? Quien sepa responder a eso no necesita recordar ninguna receta.
- Escribe de memoria, sin consultar el track, las cuatro piezas del bucle y la garantía que aporta cada una; contrasta después con esta lección y anota lo que se te olvidó.
- Toma una pantalla tuya y marca en el código dónde ocurre exactamente cada pieza: dónde nace el intent, dónde reduce, dónde se publica el estado y dónde sale el efecto.
- Busca en esa pantalla una lectura de
stateque ocurra fuera de un bloquereducey explica qué carrera concreta podría provocar. - Identifica cuál de las cuatro costuras te está afectando hoy y escribe en tres líneas la decisión que tomarías para gestionarla, no para eliminarla.
- Explica a alguien que no conoce MVI por qué el estado es un valor y no una variable, y por qué esa distinción hace posible reproducir un bug de producción.