wandres.dev
MVI · Model-View-Intent

MVI vs MVVM

MVVM y MVI se confunden porque ambos giran en torno a un ViewModel, pero difieren en el eje que mas importa: como fluye el estado. El MVVM clasico de Android expone varios observables mutables sueltos, LiveData o MutableStateFlow, que la vista lee y a veces escribe, y que pueden actualizarse en momentos distintos dejando la pantalla en combinaciones intermedias sin sentido. MVI aprieta esa libertad hasta un solo estado inmutable y un unico canal de entrada. Esta leccion contrasta ambos, muestra que MVI no es un rival de MVVM sino una disciplina de flujo unidireccional estricto montada sobre el mismo ViewModel, pesa sus trade-offs de ceremonia frente a predecibilidad y estados imposibles descartados, y ofrece un criterio para decidir cual conviene segun la complejidad y la criticidad de la pantalla.

⏱ 18 min

MVVM y MVI comparten tres letras, un ViewModel y una confusión persistente. Quien viene del Android moderno ya usa un ViewModel con MVVM y sospecha que MVI es su competidor, algo que hay que elegir o descartar. No lo es. MVI no reemplaza a MVVM: le aprieta las tuercas. El MVVM clásico deja que la vista lea —y a veces escriba— un puñado de observables mutables sueltos: un LiveData para el nombre, otro para el estado de carga, otro para el error. Esa libertad, cómoda cuando la pantalla es simple, es la misma que permite que la interfaz caiga en combinaciones que nadie diseñó. MVI toma ese mismo ViewModel y le impone dos reglas sin excepción: una sola salida, un estado inmutable, y una sola entrada, un canal único. Entender qué compra esa estrechez, y qué cobra a cambio, es entender cuándo vale la pena y cuándo es ceremonia de más.

🎯 Al terminar esta lección sabrás
  • Contrastar el MVVM clásico de Android —varios observables mutables— con el estado único de MVI.
  • Ver que MVI no es rival de MVVM sino un flujo unidireccional estricto sobre el mismo ViewModel.
  • Pesar los trade-offs: ceremonia y repetición frente a predecibilidad y estados imposibles descartados.
  • Decidir cuándo conviene cada uno según la complejidad y la criticidad de la pantalla.

El ViewModel que ambos comparten

La raíz de la confusión es que el contenedor es idéntico. Tanto MVVM como MVI usan el ViewModel de Jetpack: la misma clase que sobrevive a los cambios de configuración y guarda el estado fuera de la actividad. La diferencia no está en la caja, sino en la forma del contrato entre la vista y esa caja. En el MVVM clásico, el ViewModel expone varias propiedades observables —un LiveData o un MutableStateFlow por cada trozo de pantalla— y ofrece varios métodos que la vista llama según lo que el usuario haga: onGuardar, onNombreCambiado, onReintentar. La vista se enlaza a cada observable por separado y, con data binding de dos vías, hasta puede escribir de vuelta en él.

MVI conserva ese ViewModel pero le cambia la fachada. En lugar de muchas propiedades y muchos métodos, expone exactamente una propiedad de estado —val state: StateFlow<UiState>— y exactamente un método de entrada —fun onIntent(intent: Intent)—. Misma clase base, misma supervivencia, pero una superficie de contacto radicalmente más estrecha. Todo lo que sigue se deriva de esa diferencia de forma, no de ninguna magia añadida.

Conviene además desactivar un espejismo frecuente. El MVVM moderno de 2026 muchas veces ya expone un solo StateFlow<UiState> en lugar de varios observables, porque la comunidad aprendió por las malas los peligros del estado disperso. Cuando eso pasa, la frontera con MVI casi se borra y la única diferencia que queda es la entrada: si la vista sigue llamando a un abanico de métodos, tienes un MVVM con estado unificado, no MVI todavía; solo cuando esa entrada se colapsa en un único onIntent cruzas del todo al otro lado. Exponer un StateFlow no te vuelve MVI por sí solo; te vuelve MVI cerrar también el canal de entrada.

Estado múltiple mutable frente a estado único inmutable

Aquí está el eje que de verdad separa a los dos. En MVVM, el estado de la pantalla vive repartido en varios observables mutables e independientes, y la vista es quien los combina al vuelo.

// MVVM clasico: varios observables mutables, la vista los combina.
class PerfilViewModel : ViewModel() {
    val nombre = MutableStateFlow("")
    val cargando = MutableStateFlow(false)
    val error = MutableStateFlow<String?>(null)

    fun onNombreCambiado(n: String) { nombre.value = n }
    fun onGuardar() {
        cargando.value = true
        // ...mas tarde, en otra corrutina, se tocan cargando y error por separado
    }
}

Del lado de la vista, la consecuencia es que hay que recolectar cada fuente por separado y combinarlas a mano, y es justo ahí donde se cuelan los estados intermedios.

@Composable
fun PerfilScreen(vm: PerfilViewModel) {
    val nombre by vm.nombre.collectAsStateWithLifecycle()
    val cargando by vm.cargando.collectAsStateWithLifecycle()
    val error by vm.error.collectAsStateWithLifecycle()
    // tres fuentes que cambian en momentos distintos: aqui nacen las combinaciones sin sentido
}

El problema no es estético: es que esos observables se actualizan en momentos distintos. Entre que onGuardar pone cargando en cierto y que la respuesta apaga ese flag y rellena error, hay instantes en los que la vista puede leer una combinación que nadie quiso —cargando verdadero con un error viejo todavía presente—. La pantalla es función de tres fuentes que no cambian a la vez, así que existen fotogramas intermedios inconsistentes. MVI cierra esa puerta reuniendo todo en un solo estado inmutable que se reemplaza de una pieza.

// MVI: un solo estado inmutable, reemplazado de una pieza.
data class PerfilState(
    val nombre: String = "",
    val contenido: Contenido = Contenido.Cargando,
)

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

    fun onIntent(intent: PerfilIntent) = _state.update { reduce(it, intent) }
}
flowchart TB
subgraph MVVM_clasico [MVVM clasico]
  W[View] <--> A[LiveData nombre]
  W <--> B[LiveData cargando]
  W <--> C[LiveData error]
end
subgraph MVI_estricto [MVI estricto]
  X[View] --> OI[onIntent]
  OI --> RD[reduce]
  RD --> S[un UiState inmutable]
  S --> X
end
style A fill:#f38ba8,color:#11111b
style B fill:#f38ba8,color:#11111b
style C fill:#f38ba8,color:#11111b
style OI fill:#f9e2af,color:#11111b
style S fill:#a6e3a1,color:#11111b

Como el estado nuevo se publica entero y de golpe, la vista nunca ve medio cambio: o el estado viejo completo, o el nuevo completo, jamás una mezcla. Y como es un solo valor, modelar su contenido con un tipo suma —lo que hiciste en la lección anterior— vuelve inexpresables las combinaciones imposibles. En MVVM esa garantía no existe: cada observable es libre de contradecir a los demás, y nada en el tipo lo impide.

El sentido único estricto: qué añade y qué cuesta

Lo que MVI añade sobre MVVM se resume en una palabra: restricción. Un solo canal de entrada en vez de muchos métodos, así que toda interacción es un dato registrable y reproducible. Un solo estado inmutable en vez de varios observables mutables, así que no hay fotogramas inconsistentes. Un sentido único sin data binding de dos vías, así que la vista nunca escribe el estado: solo lo lee y emite intents. Ninguna de esas es una capacidad nueva; todas son caminos prohibidos, y en la prohibición está el valor, igual que en el nivel 17.

Cada prohibición, además, compra una propiedad concreta que se puede nombrar. El canal único compra trazabilidad, porque una interacción que es dato se puede registrar y reproducir. El estado inmutable compra consistencia, porque nadie ve medio cambio. Y el sentido único compra que la vista jamás pueda mentirle al estado, porque no tiene por dónde escribirlo. Son las mismas monedas que ya pagaste en Flux y en Redux, ahora en la caja registradora de Android.

Pero la estrechez se paga. MVI pide más ceremonia —un sealed interface de intents, una data class de estado y un reduce por pantalla— que en una pantalla trivial es puro andamiaje sin retorno. Y arrastra una arruga conocida que conviene nombrar antes de decidir: los eventos de una sola vez.

ℹ️
Los eventos de una sola vez: la arruga de MVI

Navegar a otra pantalla, mostrar un Snackbar, disparar una vibración: son cosas que deben ocurrir exactamente una vez, no describir un estado que persiste. Meterlas dentro del UiState da problemas —tras una rotación, Compose vuelve a leer el estado y el Snackbar reaparece, o la navegación se dispara dos veces—. El estado contesta a cómo se ve la pantalla ahora, no a qué hay que hacer una única vez. La salida habitual es un canal aparte para efectos, un SharedFlow o un Channel que la vista consume una sola vez, separado del StateFlow de estado. MVVM padece lo mismo y lo parchea con trucos como SingleLiveEvent; MVI al menos lo hace explícito. Distinguir estado de evento es parte de dominar el patrón, no un detalle menor.

La diferencia se cobra en la depuración

El contraste no se nota tanto al escribir la pantalla como al mantenerla. En MVVM, reproducir un fallo suele exigir recrear a mano la secuencia de llamadas a métodos y vigilar varios LiveData a la vez para ver cuál quedó mal: el estado que disparó el bug no está guardado en ninguna parte, hay que reconstruirlo. En MVI, como cada intent es un dato y reduce es puro, el fallo es reproducible por construcción: guardas la lista de intents, la vuelves a plegar sobre el estado inicial y recuperas exactamente la misma pantalla rota.

// Un test de MVI no necesita Android: construye valores y afirma sobre el resultado.
@Test
fun guardar_pasa_al_modo_cargando() {
    val siguiente = reduce(PerfilState(nombre = "Ada"), PerfilIntent.Guardar)
    assertEquals(Contenido.Cargando, siguiente.contenido)
    assertEquals("Ada", siguiente.nombre) // el resto del estado se conserva intacto
}

@Test
fun nombre_cambiado_solo_toca_el_nombre() {
    val siguiente = reduce(PerfilState(), PerfilIntent.NombreCambiado("Grace"))
    assertEquals("Grace", siguiente.nombre)
}

Esa facilidad no es un extra de MVI: es la misma pureza de reduce cobrando dividendos, igual que en los reducers del nivel 18. En MVVM, comprobar la misma transición obliga a menudo a instrumentar observables, esperar corrutinas y montar dobles de prueba, porque la lógica va entretejida con el mecanismo de notificación. La estrechez que MVI impone al escribir se devuelve, multiplicada, cuando toca comprobar y depurar: es una deuda que se cobra con intereses del lado de quien mantiene la pantalla.

🪶

MVVM encaja cuando...

La pantalla es simple, con poco estado y pocos caminos: un formulario directo, una lista sin apenas modos. La ceremonia de MVI no se amortiza y el data binding ahorra código real.

🏗️

MVI encaja cuando...

La pantalla es compleja: carga asíncrona, errores, modos que se excluyen, varias fuentes empujando a la vez. Ahí los estados imposibles y las carreras cuestan caras, y la disciplina se paga sola.

🎚️

No es binario

Entre ambos hay un continuo. Puedes agrupar dos o tres observables en una data class sin llegar a un reduce formal, y subir el rigor solo cuando la complejidad lo exija.

MVI es MVVM con las salidas de emergencia soldadas

La forma madura de ver esto no es como una rivalidad entre dos patrones, sino como dos puntos de un mismo eje: cuánta disciplina de sentido único le impones a tu ViewModel. MVVM y MVI no son especies distintas; son el mismo animal con distinto grado de restricción. Cada observable mutable que un MVVM expone es una salida de emergencia: un sitio por donde la vista puede escribir, por donde dos fuentes pueden contradecirse, por donde la pantalla puede quedar en un estado que nadie diseñó. MVI no inventa nada nuevo; se limita a soldar esas salidas —un solo estado que solo reduce escribe, un solo canal por el que solo entran datos— hasta que el único comportamiento posible es el que quisiste. Y de ahí sale el criterio que de verdad importa, que no es de bando sino de umbral. Por debajo de cierta complejidad, la libertad de MVVM no cuesta nada: si la pantalla tiene dos campos y ningún modo, las salidas de emergencia jamás se usan y soldarlas es trabajo tirado. Por encima de ese umbral —carga, error, vacío y éxito a la vez, con corrutinas empujando desde varios lados— esa misma libertad se convierte en el pasivo que te tiene depurando estados imposibles un viernes por la noche, y entonces cada restricción de MVI se paga sola. La pregunta correcta nunca es MVVM o MVI como si fueran tribus, sino cuánta libertad puede permitirse esta pantalla sin volverse impredecible. La predecibilidad, otra vez, no es algo que añades escribiendo más código; es algo que compras prohibiendo caminos, y MVI es sencillamente el MVVM que ya ha decidido cuáles prohibir. Quien entiende esto deja de discutir patrones y empieza a graduar disciplina: poca donde sobra, mucha donde la pantalla, si la dejas suelta, te va a morder.

⚔️ Gradúa la disciplina de una pantalla real
  1. Toma una pantalla tuya con tres o más observables mutables —LiveData o MutableStateFlow—, enumera las combinaciones que permiten sus tipos y marca cuáles son estados imposibles que hoy nada impide.
  2. Reúne esos observables en un solo UiState con el contenido modelado como tipo suma, y comprueba que las combinaciones imposibles dejan de ser representables.
  3. Sustituye los varios métodos del ViewModel por un único onIntent con un sealed interface de intents; anota qué ganaste y qué ceremonia pagaste a cambio.
  4. Encuentra un evento de una sola vez —navegar, mostrar un Snackbar— y argumenta por qué no debe vivir en el UiState; esboza el canal aparte que lo llevaría.
  5. Elige dos pantallas reales, una simple y otra compleja, y justifica MVVM para una y MVI para la otra usando el argumento del umbral de complejidad, no el gusto personal.