wandres.dev
MVI · Model-View-Intent

Model-View-Intent: el flujo unidireccional de Android

MVI es el nombre que el flujo unidireccional de Flux, Redux y Elm adopta al cruzar a Android y reescribirse en Kotlin sobre Jetpack Compose. Esta leccion presenta sus tres piezas —Model, View e Intent— y defiende que su aportacion no esta en las piezas sino en una inversion: la intencion del usuario deja de ser un metodo que se ejecuta y pasa a ser un dato inerte que entra por un solo punto y recorre un circuito cerrado antes de que la pantalla cambie. Muestra el esqueleto minimo con StateFlow, un onIntent unico y una UI de Compose que es funcion pura del estado, y situa el patron en su linaje para que veas que no aprendes algo nuevo, sino la misma geometria de sentido unico bajo tres iniciales.

⏱ 16 min

MVI es lo que ocurre cuando el flujo unidireccional que ya conoces —Flux en el navegador, Redux en React, la Elm Architecture en el mundo funcional— cruza la frontera de Android y se reescribe en Kotlin sobre Jetpack Compose. No trae ninguna idea nueva: trae una traduccion. Las tres iniciales significan Model, View e Intent, pero lo que de verdad las une no es la lista de piezas, sino una inversion que cuesta ver la primera vez. La intencion del usuario —eso que en la programacion imperativa de toda la vida era una llamada a un metodo que hacia algo— aqui se convierte en un dato inerte que entra por un unico sitio y recorre un circuito cerrado antes de que la pantalla cambie. Compose vuelve esta idea casi inevitable, porque ya describe la interfaz como una funcion del estado; MVI se limita a cerrar el bucle por el otro extremo.

🎯 Al terminar esta lección sabrás
  • Nombrar las tres piezas de MVI —Model, View e Intent— y situar cada una en el circuito.
  • Comprender la inversion central: la intencion como entrada de datos, no como llamada a un metodo.
  • Reconocer por que Compose, al ser la interfaz una funcion del estado, hace natural el patron.
  • Trazar el linaje que une MVI con Elm, Redux y el flujo unidireccional de niveles anteriores.

La inversión que define MVI

Detente en cómo se maneja un clic en el Android imperativo clásico. Dentro del OnClickListener llamas a un repositorio, recibes un resultado y, acto seguido, tú mismo tocas la pantalla: textView.text = ..., progressBar.visibility = .... El manejador es a la vez quien decide qué pasa y quien pinta el resultado. Esa fusión —decisión y efecto en el mismo sitio, repartida por decenas de callbacks— es la raíz del desorden que ya diagnosticaste en el MVC del nivel 17: demasiados caminos por los que el estado puede cambiar sin dejar rastro.

MVI corta ese nudo con un solo gesto: el clic ya no hace nada, emite. Lo único que produce la interacción del usuario es un valor —un Intent— que describe qué quiso hacer, nunca cómo llevarlo a cabo. Ese valor entra al sistema por un único punto, se combina con el estado actual para producir un estado nuevo, y solo entonces la pantalla se redibuja. La intención se ha cosificado: ha pasado de ser un verbo que se ejecuta a ser un sustantivo que viaja.

// El Model: una unica foto inmutable de la pantalla
data class ContadorState(val cuenta: Int = 0)

// El Intent: la intencion del usuario, modelada como dato
sealed interface ContadorIntent {
    data object Incrementar : ContadorIntent
    data object Decrementar : ContadorIntent
}

class ContadorViewModel : ViewModel() {
    private val _state = MutableStateFlow(ContadorState())
    val state: StateFlow<ContadorState> = _state.asStateFlow()

    // Punto de entrada unico: la intencion entra, el estado nuevo sale
    fun onIntent(intent: ContadorIntent) = _state.update { s ->
        when (intent) {
            ContadorIntent.Incrementar -> s.copy(cuenta = s.cuenta + 1)
            ContadorIntent.Decrementar -> s.copy(cuenta = s.cuenta - 1)
        }
    }
}

Las tres piezas sobre Compose y StateFlow

Cada inicial corresponde a una pieza concreta del Android de 2026. El Model es una data class inmutable que contiene todo lo que la pantalla necesita para dibujarse: una única fuente de la verdad, no un puñado de campos sueltos. La View es un @Composable que recibe ese estado y lo pinta, sin lógica ni decisiones propias. El Intent es un tipo sellado —casi siempre un sealed interface— que enumera todas las acciones que el usuario puede realizar. Entre las tres media el ViewModel, que guarda el estado en un StateFlow y expone un único método de entrada, onIntent.

flowchart LR
I[Intent del usuario] --> VM[ViewModel]
VM --> R[reduce]
R --> S[UiState inmutable]
S --> V[Compose UI]
V -->|nueva interaccion emite un Intent| I
style I fill:#89b4fa,color:#11111b
style R fill:#f9e2af,color:#11111b
style S fill:#a6e3a1,color:#11111b
style V fill:#cba6f7,color:#11111b

En la vista, la conexión con el estado se hace con collectAsStateWithLifecycle, que observa el StateFlow respetando el ciclo de vida —deja de recolectar cuando la pantalla no está visible— y provoca la recomposición cuando el estado cambia. Fíjate en que la vista solo hace dos cosas: lee state y emite intents. Nunca decide.

@Composable
fun ContadorScreen(vm: ContadorViewModel = viewModel()) {
    val state by vm.state.collectAsStateWithLifecycle()
    Column {
        Text("Cuenta: ${state.cuenta}")
        Button(onClick = { vm.onIntent(ContadorIntent.Incrementar) }) { Text("+") }
        Button(onClick = { vm.onIntent(ContadorIntent.Decrementar) }) { Text("-") }
    }
}
ℹ️
Compose ya es la mitad de MVI

Jetpack Compose recompone la interfaz cuando el estado que lee cambia: es literalmente UI = f(state), el lado derecho de la ecuación de MVI implementado por el framework. Por eso MVI encaja en Compose sin fricción, mientras que en el viejo mundo de las View de XML había que empujarlo a mano. Compose te regala la salida —del estado a los píxeles—; MVI aporta la otra mitad, el canal de entrada —de la intención al estado nuevo— y así cierra el bucle.

El mismo circuito bajo un nombre nuevo

Nada de esto es original, y reconocerlo te ahorra confusión. El término lo acuñó Hannes Dorfmann en 2016 para Android, inspirándose en el cycle.js de André Staltz, con dos ecuaciones que resumen el patrón entero: el modelo es función de la intención y la vista es función del modelo. Es, palabra por palabra, la Elm Architecture —update : Msg -> Model -> Model, view : Model -> Html— vestida de Kotlin, y es el mismo circuito de sentido único de Redux que recorriste en el nivel 18. En 2026 conviven varias encarnaciones: Orbit MVI y MVIKotlin como librerías dedicadas, y sobre todo Circuit de Slack y Molecule de Cash App, que llevan la idea a su extremo escribiendo el propio presentador como una función @Composable que devuelve estado.

No aprendes un patrón nuevo: reconoces uno viejo con acento kotlin

El error de quien llega a MVI desde Android es tratarlo como una técnica local del móvil, con su jerga de ViewModel y StateFlow, cuando en realidad es la enésima aparición de la única forma conocida de domesticar el estado de una interfaz: forzar que la información fluya en un solo sentido. Flux lo llamó flujo unidireccional; Elm lo llamó The Elm Architecture; Redux lo llamó store y reducer; The Composable Architecture de Swift lo llama Reducer y Store; MVI lo llama Model, View e Intent. Cambian los nombres y el lenguaje, pero el diagrama es idéntico: una intención entra como dato, una función pura la combina con el estado actual para producir un estado nuevo, y una vista que es función de ese estado se redibuja. Cuando internalizas que MVI no es de Android sino que Android es solo el último sitio donde el patrón se posó, dejas de memorizar recetas y empiezas a transferir una sola idea entre plataformas: la predecibilidad no se añade con más código, se compra prohibiendo caminos, y el sentido único es esa prohibición hecha arquitectura. Lo que en 2026 se debate en Kotlin ya se debatió en JavaScript en 2015 y en Elm antes; quien ve el patrón bajo los nombres deja de reaprenderlo en cada framework.

⚔️ Reconoce el bucle en tu propia pantalla
  1. Toma una pantalla real y lista, en una columna, cada cosa que el usuario puede hacer en ella; esa lista es tu conjunto de intents.
  2. En otra columna, lista cada dato que la pantalla necesita para dibujarse; ese conjunto es tu UiState.
  3. Dibuja el circuito Intent a ViewModel a reduce a UiState a vista, y verifica que ninguna flecha va hacia atrás dentro de una misma vuelta.
  4. Busca en tu código un OnClickListener que a la vez decida y toque la vista; reescríbelo para que solo emita un intent y explica qué garantía ganas.
  5. Argumenta con tus palabras por qué Compose implementa gratis la mitad vista = f(estado) y qué mitad tienes que aportar tú.