MVI — Comprendiendo el Patrón Model-View-Intent en Apps Móviles

Autor: IT Sectr Publicado: 2026-02-16 Tiempo de lectura: 10 min

MVI (Model-View-Intent) es un patrón arquitectónico reactivo basado en flujo de datos unidireccional y estado inmutable. A diferencia de MVVM, donde una ViewModel puede tener múltiples StateFlows, MVI define un estado único (State), intenciones inmutables (Intent) y una función reductora pura (Reducer). MVI garantiza la predecibilidad del estado de la pantalla en cualquier momento. El patrón fue popularizado en la comunidad Android por las bibliotecas Mosby y Orbit. Más información en MVIKotlin de Arkadii Ivanov.

Puntos Clave

  • MVI — Model (estado), View (visualización), Intent (intención del usuario) — un ciclo reactivo
  • Unidirectional data flow — los datos se mueven en una dirección: Intent → Reducer → State → View
  • Immutable State — el estado de la pantalla es un objeto inmutable, recreado en cada cambio
  • Reducer — una función pura que recibe el estado actual y un Intent, devolviendo un nuevo estado
  • Side effects — los efectos secundarios (red, BD) se manejan por separado del Reducer, mediante Middleware

Qué es MVI: la esencia del patrón Model-View-Intent

MVI (Model-View-Intent) es un patrón arquitectónico reactivo construido sobre los principios de Redux y Cycle.js. Model es el estado inmutable de la pantalla, Intent es una intención del usuario o sistema, View se suscribe al estado y envía Intents. Los datos fluyen en un ciclo: el usuario interactúa con la View → la View crea un Intent → el Intent es procesado por el Reducer → el Reducer crea un nuevo estado → la View recibe el nuevo estado y se vuelve a renderizar.

La principal diferencia entre MVI y MVVM es la Fuente Única de Verdad. En MVVM, una ViewModel puede tener varios LiveData/StateFlow (userState, loadingState, errorState), lo que genera inconsistencia: loading=true y user=null simultáneamente. En MVI existe exactamente una clase/interface sealed State que describe todo el estado de la pantalla. En cualquier momento, el estado de la pantalla está determinado de forma única — es imposible tener loading=true cuando los datos ya están cargados. En IT Sectr, aplicamos MVI para pantallas con lógica compleja — formularios de pedido, registros multipaso, pantallas financieras — donde la predecibilidad del estado es crítica.

ComponenteRol en MVIEjemplo
IntentIntención del usuario o sistemaLoadUser, Refresh, SubmitForm
StateEstado inmutable de la pantallasealed class UserState
ReducerFunción pura: State + Intent → Statefun reduce(state, intent) -> state
MiddlewareManejo de efectos secundariosPetición de red, escritura en BD

El ciclo MVI consta de cinco pasos: 1) La View envía un Intent (por ejemplo, LoadUser(42)); 2) El Middleware (EffectHandler) ejecuta un efecto secundario — una petición de red; 3) El resultado se devuelve como un nuevo Intent al sistema; 4) El Reducer toma el estado actual y el Intent, crea un nuevo estado; 5) La View recibe el nuevo estado y se vuelve a renderizar. Cada paso es predecible y se prueba de forma aislada.

MVI en Android: Intent, Reducer, State en Kotlin

MVI en Android se implementa mediante clases sealed para Intent y State, una ViewModel con lógica MVI y Jetpack Compose para renderizado reactivo. La ViewModel recibe Intent de la View, delega efectos secundarios al Middleware, ejecuta el Reducer y publica el nuevo estado a través de StateFlow. Jetpack Compose vuelve a renderizar la UI cuando el estado cambia — ideal para el ciclo MVI.

kotlin
// Intent — intenciones del usuario
sealed interface UserIntent {
    data class LoadUser(val userId: Int) : UserIntent
    data object Refresh : UserIntent
}

// State — estado único de la pantalla
sealed interface UserState {
    data object Idle : UserState
    data object Loading : UserState
    data class Success(val user: User) : UserState
    data class Error(val message: String) : UserState
}

// Reducer — función pura
object UserReducer {
    fun reduce(state: UserState, intent: UserIntent): UserState = when (intent) {
        is UserIntent.LoadUser -> UserState.Loading
        is UserIntent.Refresh -> UserState.Loading
    }
}

// ViewModel con MVI
class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {

    private val _state = MutableStateFlow<UserState>(UserState.Idle)
    val state: StateFlow<UserState> = _state.asStateFlow()

    fun process(intent: UserIntent) {
        val newState = UserReducer.reduce(_state.value, intent)
        _state.value = newState
        when (intent) {
            is UserIntent.LoadUser -> loadUser(intent.userId)
            is UserIntent.Refresh -> loadUser(/* ID anterior */)
        }
    }

    private fun loadUser(userId: Int) {
        viewModelScope.launch {
            repository.getUser(userId)
                .onSuccess { user ->
                    _state.value = UserState.Success(user)
                }
                .onFailure { e ->
                    _state.value = UserState.Error(e.message ?: "Error")
                }
        }
    }
}

// View envía Intent
fun UserScreen(viewModel: UserViewModel) {
    val state by viewModel.state.collectAsState()
    LaunchedEffect(Unit) { viewModel.process(UserIntent.LoadUser(42)) }
    when (state) {
        is UserState.Loading -> CircularProgressIndicator()
        is UserState.Success -> UserCard((state as UserState.Success).user)
        is UserState.Error -> ErrorView((state as UserState.Error).message)
        is UserState.Idle -> Text("Press to load")
    }
}

Middleware y efectos secundarios — en MVI, un Reducer puro no puede realizar peticiones de red. El Middleware (también EffectHandler o Bootstrapper) procesa el Intent, ejecuta el efecto secundario y emite un nuevo Intent de vuelta al ciclo. Las bibliotecas Orbit MVI y MVIKotlin proporcionan soporte integrado de Middleware con efectos testeables. Sin Middleware, MVI degenera en MVVM con estructura adicional de Intent y State.

MVIKotlin de Arkadii Ivanov es la biblioteca MVI más popular para Kotlin Multiplatform. Soporta Android, iOS, web y JVM. Proporciona componentes: Store (ViewModel), Bootstrapper (efectos iniciales), Reducer, Middleware. A octubre de 2025, la biblioteca tiene 2.5K estrellas en GitHub y se usa en proyectos comerciales, incluyendo aplicaciones de grandes bancos rusos. En IT Sectr, usamos MVIKotlin para proyectos multiplataforma KMP con lógica de negocio compartida.

MVI en iOS: flujo unidireccional en Swift

MVI en iOS se implementa sin Combine-ViewModel, mediante el ciclo Intent → State. La View envía un Intent a través de un closure, el Reducer es una función pura, y State es una struct con campos inmutables. SwiftUI vuelve a renderizar la View cuando State cambia, lo que encaja perfectamente en el ciclo MVI sin propiedades @Published adicionales. MVI en iOS es especialmente popular entre desarrolladores SwiftUI que migraron desde Redux (JavaScript).

swift
// State — estructura inmutable
struct UserState: Equatable {
    var user: User?
    var isLoading = false
    var errorMessage: String?
}

// Intent — enum con intenciones
enum UserIntent {
    case loadUser(id: Int)
    case refresh
    case userLoaded(User)
    case loadFailed(Error)
}

// Reducer — función pura
func userReducer(state: UserState, intent: UserIntent) -> UserState {
    var newState = state
    switch intent {
    case .loadUser, .refresh:
        newState.isLoading = true
        newState.errorMessage = nil
    case .userLoaded(let user):
        newState.isLoading = false
        newState.user = user
    case .loadFailed(let error):
        newState.isLoading = false
        newState.errorMessage = error.localizedDescription
    }
    return newState
}

// Store — posee el estado y gestiona los efectos
final class UserStore: ObservableObject {
    @Published private(set) var state = UserState()
    private let service: UserService

    init(service: UserService) {
        self.service = service
    }

    func dispatch(_ intent: UserIntent) {
        // 1. Reducer actualiza el estado
        state = userReducer(state: state, intent: intent)
        // 2. Side effects (si es necesario)
        switch intent {
        case .loadUser(let id), .refresh:
            service.fetchUser(id: id) { [weak self] result in
                switch result {
                case .success(let user):
                    self?.dispatch(.userLoaded(user))
                case .failure(let error):
                    self?.dispatch(.loadFailed(error))
                }
            }
        default: break
        }
    }
}

TCA (The Composable Architecture) es la implementación MVI más popular para iOS de Point-Free, construida sobre SwiftUI y Combine. TCA proporciona Store, Reducer, Effect y Environment. A octubre de 2025, sus estrellas en GitHub superan las 13K — es el estándar de facto para MVI en iOS. TCA se usa en las apps de Starbucks, Airbnb (parcialmente) y muchos proyectos independientes. A diferencia del MVI personalizado, TCA resuelve pruebas, navegación y efectos secundarios de fábrica.

MVI vs MVVM en iOS — TCA/MVI proporciona predecibilidad del estado pero requiere más código repetitivo (Reducer, State, Action). MVVM con @Published es más simple para pantallas básicas. En IT Sectr, usamos MVVM para el 80% de las pantallas y MVI (TCA) para el 20% complejas — transacciones financieras, formularios multipaso, interfaces drag-and-drop, donde un error de estado podría costarle dinero al usuario.

Comparación MVI vs MVVM: cuándo elegir MVI

MVI y MVVM resuelven el mismo problema — organizar la capa de Presentación — pero con diferentes enfoques a la gestión del estado. MVVM permite múltiples fuentes reactivas (LiveData, @Published), lo que puede generar inconsistencia. MVI garantiza exactamente un estado en cada momento, haciéndolo más estricto y predecible, pero aumenta la cantidad de código.

CriterioMVVMMVI
EstadoMúltiples LiveData/StateFlowClase sealed State única
Flujo de datosBidireccional (View → ViewModel, LiveData → View)Unidireccional (Intent → Reducer → State → View)
Efectos secundariosDirectamente en ViewModelMediante Middleware/EffectHandler
PruebasPruebas unitarias de ViewModelPruebas unitarias de Reducer + Middleware
Código repetitivoMínimoReducer + State + Intent + Middleware

Cuándo elegir MVI — pantallas donde el estado debe ser estrictamente determinista: operaciones financieras, carrito de compras, formularios multipaso con validación en cada paso. En estos escenarios, el coste de un error de estado (por ejemplo, mostrar el total del carrito sin un artículo debido a una condición de carrera entre dos LiveData) supera el coste del código adicional. En MVVM confías en la disciplina del equipo; en MVI confías en la arquitectura.

Cuándo MVVM es suficiente — el 80% de las pantallas estándar: lista de usuarios, perfil, ajustes, feed de noticias. Aquí, un estado único es excesivo y la estructura adicional de MVI ralentizará el desarrollo. En IT Sectr, la regla es: si una pantalla tiene 3 o más estados posibles con transiciones (carga → datos → error → reintentar → carga → datos) — usa MVI. Si una pantalla tiene 1-2 operaciones asíncronas — usa MVVM.

Mejores prácticas de MVI y errores típicos

Sealed State — mejor práctica en MVI. El estado se define como una clase/interface sealed con variantes: Loading, Success(data), Error(message). Esto garantiza que la View no termine en un estado inconsistente — no se pueden mostrar datos cuando loading=true porque Loading y Success son clases diferentes. Todos los datos relacionados con el estado residen dentro de la variante sealed: Success contiene el usuario, Error contiene el mensaje de error.

El Reducer debe seguir siendo una función pura — sin llamadas a API, BD o SharedPreferences. Una función pura recibe State e Intent y devuelve State. Los efectos secundarios (red, BD, navegación, toasts) se manejan en Middleware o en Store.dispatch después de llamar al Reducer. Si el Reducer se contamina con efectos secundarios, MVI pierde testeabilidad y predecibilidad — obtienes MVVM con estructura adicional sin beneficios.

Errores típicos — declarar State como data class con campos nullable en lugar de sealed class: data class UserState(val user: User?, val isLoading: Boolean, val error: String?). Esto es equivalente a MVVM, no a MVI — la View debe comprobar combinaciones de campos para validez. En el enfoque sealed, las combinaciones inválidas (isLoading=true y user!=null) son imposibles a nivel de tipos. El segundo error es poner lógica de negocio en Intent (Intent.LoadUserBeforeXHours) en lugar de crear Intents de comando simples (Intent.LoadUser) y poner la lógica de negocio en Middleware.

Preguntas Frecuentes

¿Cuál es la principal diferencia entre MVI y MVVM?

MVI usa una única clase sealed State inmutable y flujo de datos unidireccional a través de un Reducer. MVVM permite múltiples LiveData/StateFlow con enlace bidireccional. MVI garantiza consistencia del estado a nivel de tipos — es imposible tener loading=true y user=null simultáneamente. MVVM depende de la disciplina del desarrollador.

¿Qué bibliotecas MVI existen para Android?

Principales: MVIKotlin (Arkadii Ivanov, 2.5K estrellas, Kotlin Multiplatform), Orbit MVI (BabyJ, 1.3K estrellas), Mobius (Spotify, Kotlin/Java). MVIKotlin es la más popular para Kotlin, Orbit es la más fácil de aprender. Las tres soportan Reducer y Middleware testeables. Para Jetpack Compose, puedes escribir MVI simple sin biblioteca usando sealed State + Reducer.

¿Se necesita una biblioteca separada para MVI?

No — Intent sealed + State sealed + ViewModel + StateFlow te dan MVI funcional sin dependencias. Las bibliotecas (MVIKotlin, Orbit, TCA) añaden Middleware, pruebas de efectos secundarios e integración con DI. Para proyectos simples, el peso de la biblioteca no está justificado. Para proyectos complejos con 20+ pantallas, la biblioteca se amortiza con manejo estructurado de efectos.

¿MVI es adecuado para iOS o es solo un patrón de Android?

MVI funciona muy bien para iOS a través de TCA (The Composable Architecture) — la arquitectura más popular de la comunidad SwiftUI. TCA es esencialmente MVI + Redux + Combine. En iOS, puedes implementar MVI sin TCA usando ObservableObject y una función reductora pura. SwiftUI con State inmutable encaja perfectamente en el ciclo MVI.

¿Cómo probar MVI?

El Reducer se prueba con tests unitarios como función pura: se establece un State inicial, se envía un Intent, se verifica el State resultante. El Middleware se prueba con un repositorio mock: se verifica que getUser se llamó después de LoadUser. Test de ViewModel: enviar un Intent, verificar el StateFlow. MVI es más fácil de probar que MVVM porque el Reducer es una función pura sin dependencias ocultas.

Resumen

  • MVI (Model-View-Intent) — un patrón reactivo con flujo unidireccional y un estado único
  • Sealed State — garantiza consistencia a nivel de tipos, eliminando combinaciones inválidas
  • Reducer — una función pura State + Intent → State, testeable sin objetos mock
  • Middleware — una capa separada para efectos secundarios (red, BD, navegación)
  • MVI vs MVVM — MVI es más estricto y predecible, MVVM es más simple y rápido
  • Android — MVIKotlin u Orbit para pantallas complejas; MVVM para las simples
  • iOS — TCA (The Composable Architecture) es el estándar MVI en SwiftUI

Desarrollaremos una aplicación móvil llave en mano

IT Sectr crea aplicaciones para iOS y Android para startups y empresas desde 2017. Le asesoraremos y le propondremos la mejor solución.

Discutir el proyecto

Lea también