MVI (Model-View-Intent) è un pattern architetturale reattivo basato su flusso di dati unidirezionale e stato immutabile. A differenza di MVVM, dove una ViewModel può avere più StateFlows, MVI definisce un unico stato (State), intenzioni immutabili (Intent) e una funzione riduttrice pura (Reducer). MVI garantisce la prevedibilità dello stato dello schermo in qualsiasi momento. Il pattern è stato reso popolare nella comunità Android dalle librerie Mosby e Orbit. Maggiori informazioni in MVIKotlin di Arkadii Ivanov.
Punti Chiave
MVI (Model-View-Intent) è un pattern architetturale reattivo costruito sui principi di Redux e Cycle.js. Model è lo stato immutabile dello schermo, Intent è un'intenzione dell'utente o del sistema, View si abbona allo stato e invia Intents. I dati fluiscono in un ciclo: l'utente interagisce con la View → la View crea un Intent → l'Intent viene elaborato dal Reducer → il Reducer crea un nuovo stato → la View riceve il nuovo stato e ri-renderizza.
La differenza principale tra MVI e MVVM è la Singola Fonte di Verità. In MVVM, una ViewModel può avere più LiveData/StateFlow (userState, loadingState, errorState), portando a incoerenza: loading=true e user=null simultaneamente. In MVI, esiste esattamente una classe/interfaccia sealed State che descrive l'intero stato dello schermo. In qualsiasi momento, lo stato dello schermo è determinato in modo univoco — è impossibile avere loading=true quando i dati sono già caricati. In IT Sectr, applichiamo MVI per schermi con logica complessa — moduli d'ordine, registrazioni multi-step, schermi finanziari — dove la prevedibilità dello stato è critica.
| Componente | Ruolo in MVI | Esempio |
|---|---|---|
| Intent | Intenzione utente o sistema | LoadUser, Refresh, SubmitForm |
| State | Stato schermo immutabile | sealed class UserState |
| Reducer | Funzione pura: State + Intent → State | fun reduce(state, intent) -> state |
| Middleware | Gestione effetti collaterali | Richiesta di rete, scrittura DB |
Il ciclo MVI si compone di cinque passaggi: 1) La View invia un Intent (es. LoadUser(42)); 2) Il Middleware (EffectHandler) esegue un effetto collaterale — una richiesta di rete; 3) Il risultato viene restituito come nuovo Intent nel sistema; 4) Il Reducer prende lo stato corrente e l'Intent, crea un nuovo stato; 5) La View riceve il nuovo stato e ri-renderizza. Ogni passaggio è prevedibile e testabile isolatamente.
MVI su Android viene implementato usando classi sealed per Intent e State, una ViewModel con logica MVI e Jetpack Compose per il rendering reattivo. La ViewModel riceve Intent dalla View, delega gli effetti collaterali al Middleware, esegue il Reducer e pubblica il nuovo stato tramite StateFlow. Jetpack Compose ri-renderizza la UI quando lo stato cambia — ideale per il ciclo MVI.
// Intent — intenzioni utente
sealed interface UserIntent {
data class LoadUser(val userId: Int) : UserIntent
data object Refresh : UserIntent
}
// State — stato unico dello schermo
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 — funzione 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 precedente */)
}
}
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 invia 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 ed effetti collaterali — in MVI, un Reducer puro non può eseguire richieste di rete. Il Middleware (chiamato anche EffectHandler o Bootstrapper) elabora l'Intent, esegue l'effetto collaterale ed emette un nuovo Intent nel ciclo. Le librerie Orbit MVI e MVIKotlin forniscono supporto integrato per Middleware con effetti testabili. Senza Middleware, MVI degenera in MVVM con struttura aggiuntiva di Intent e State.
MVIKotlin di Arkadii Ivanov è la libreria MVI più popolare per Kotlin Multiplatform. Supporta Android, iOS, web e JVM. Fornisce componenti: Store (ViewModel), Bootstrapper (effetti iniziali), Reducer, Middleware. A ottobre 2025, la libreria ha 2,5K stelle su GitHub ed è utilizzata in progetti commerciali, incluse applicazioni di grandi banche russe. In IT Sectr, usiamo MVIKotlin per progetti multipiattaforma KMP con logica di business condivisa.
MVI su iOS viene implementato senza Combine-ViewModel, attraverso il ciclo Intent → State. La View invia un Intent tramite una closure, il Reducer è una funzione pura, e State è una struct con campi immutabili. SwiftUI ri-renderizza la View quando State cambia, adattandosi perfettamente al ciclo MVI senza proprietà @Published aggiuntive. MVI su iOS è particolarmente popolare tra gli sviluppatori SwiftUI che sono passati da Redux (JavaScript).
// State — struttura immutabile
struct UserState: Equatable {
var user: User?
var isLoading = false
var errorMessage: String?
}
// Intent — enum con intenzioni
enum UserIntent {
case loadUser(id: Int)
case refresh
case userLoaded(User)
case loadFailed(Error)
}
// Reducer — funzione 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 — possiede lo stato e gestisce gli effetti
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 aggiorna lo stato
state = userReducer(state: state, intent: intent)
// 2. Side effects (se necessario)
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) è l'implementazione MVI più popolare per iOS di Point-Free, costruita su SwiftUI e Combine. TCA fornisce Store, Reducer, Effect e Environment. A ottobre 2025, le sue stelle GitHub superano 13K — è lo standard de facto per MVI su iOS. TCA è utilizzato nelle app Starbucks, Airbnb (parzialmente) e molti progetti indipendenti. A differenza di MVI personalizzato, TCA risolve test, navigazione ed effetti collaterali immediatamente.
MVI vs MVVM su iOS — TCA/MVI fornisce prevedibilità dello stato ma richiede più codice boilerplate (Reducer, State, Action). MVVM con @Published è più semplice per schermi base. In IT Sectr, usiamo MVVM per l'80% degli schermi e MVI (TCA) per il 20% complesso — transazioni finanziarie, moduli multi-step, interfacce drag-and-drop, dove un errore di stato potrebbe costare denaro all'utente.
MVI e MVVM risolvono lo stesso problema — organizzare il livello di Presentazione — ma con approcci diversi alla gestione dello stato. MVVM permette molteplici fonti reattive (LiveData, @Published), che possono portare a incoerenza. MVI garantisce esattamente uno stato in qualsiasi momento, rendendolo più rigoroso e prevedibile, ma aumenta la quantità di codice.
| Criterio | MVVM | MVI |
|---|---|---|
| Stato | Molteplici LiveData/StateFlow | Singola classe sealed State |
| Flusso dati | Bidirezionale (View → ViewModel, LiveData → View) | Unidirezionale (Intent → Reducer → State → View) |
| Effetti collaterali | Direttamente in ViewModel | Tramite Middleware/EffectHandler |
| Test | Test unitari della ViewModel | Test unitari di Reducer + Middleware |
| Codice boilerplate | Minimo | Reducer + State + Intent + Middleware |
Quando scegliere MVI — schermi dove lo stato deve essere strettamente deterministico: operazioni finanziarie, carrello della spesa, moduli multi-step con validazione a ogni passaggio. In questi scenari, il costo di un errore di stato (ad esempio, mostrare il totale del carrello mancante di un articolo a causa di una race condition tra due LiveData) supera il costo del codice aggiuntivo. In MVVM, ci si affida alla disciplina del team; in MVI, ci si affida all'architettura.
Quando MVVM è sufficiente — 80% degli schermi standard: elenco utenti, profilo, impostazioni, feed notizie. Qui, uno stato singolo è eccessivo e la struttura MVI aggiuntiva rallenterà lo sviluppo. In IT Sectr, la regola è: se uno schermo ha 3+ stati possibili con transizioni (caricamento → dati → errore → riprova → caricamento → dati) — usa MVI. Se uno schermo ha 1-2 operazioni asincrone — usa MVVM.
Sealed State — best practice in MVI. Lo stato è definito come classe/interfaccia sealed con varianti: Loading, Success(data), Error(message). Questo garantisce che la View non finisca in uno stato incoerente — non è possibile visualizzare dati quando loading=true perché Loading e Success sono classi diverse. Tutti i dati relativi allo stato risiedono all'interno della variante sealed: Success contiene l'utente, Error contiene il messaggio di errore.
Il Reducer deve rimanere una funzione pura — senza chiamate API, DB o SharedPreferences. Una funzione pura prende State e Intent e restituisce State. Gli effetti collaterali (rete, DB, navigazione, toast) vengono gestiti in Middleware o in Store.dispatch dopo aver chiamato il Reducer. Se il Reducer è inquinato da effetti collaterali, MVI perde testabilità e prevedibilità — si ottiene MVVM con struttura aggiuntiva senza benefici.
Errori comuni — dichiarare State come data class con campi nullable invece di sealed class: data class UserState(val user: User?, val isLoading: Boolean, val error: String?). Questo è equivalente a MVVM, non a MVI — la View deve controllare le combinazioni di campi per validità. Nell'approccio sealed, le combinazioni non valide (isLoading=true e user!=null) sono impossibili a livello di tipi. Il secondo errore è posizionare la logica di business in Intent (Intent.LoadUserBeforeXHours) invece di creare Intent di comando semplici (Intent.LoadUser) e posizionare la logica di business in Middleware.
Domande Frequenti
MVI usa una singola classe sealed State immutabile e flusso di dati unidirezionale attraverso un Reducer. MVVM permette molteplici LiveData/StateFlow con binding bidirezionale. MVI garantisce coerenza dello stato a livello di tipi — è impossibile avere loading=true e user=null simultaneamente. MVVM si basa sulla disciplina dello sviluppatore.
Principali: MVIKotlin (Arkadii Ivanov, 2,5K stelle, Kotlin Multiplatform), Orbit MVI (BabyJ, 1,3K stelle), Mobius (Spotify, Kotlin/Java). MVIKotlin è la più popolare per Kotlin, Orbit è la più facile da imparare. Tutte e tre supportano Reducer e Middleware testabili. Per Jetpack Compose, puoi scrivere MVI semplice senza libreria usando sealed State + Reducer.
No — Intent sealed + State sealed + ViewModel + StateFlow danno MVI funzionante senza dipendenze. Le librerie (MVIKotlin, Orbit, TCA) aggiungono Middleware, test degli effetti collaterali e integrazione DI. Per progetti semplici, il peso della libreria è ingiustificato. Per progetti complessi con 20+ schermi, la libreria si ripaga con una gestione strutturata degli effetti.
MVI funziona benissimo per iOS tramite TCA (The Composable Architecture) — l'architettura più popolare nella comunità SwiftUI. TCA è essenzialmente MVI + Redux + Combine. Su iOS, puoi implementare MVI senza TCA usando ObservableObject e una funzione riduttrice pura. SwiftUI con State immutabile si adatta perfettamente al ciclo MVI.
Il Reducer viene testato con test unitari come funzione pura: imposta uno State iniziale, invia un Intent, verifica lo State risultante. Il Middleware viene testato con un repository mock: verifica che getUser sia stato chiamato dopo LoadUser. Test della ViewModel: invia un Intent, verifica lo StateFlow. MVI è più facile da testare di MVVM perché il Reducer è una funzione pura senza dipendenze nascoste.
Riepilogo
Svilupperemo un'applicazione mobile chiavi in mano
IT Sectr crea applicazioni iOS e Android per startup e aziende dal 2017. Ti consulteremo e ti proporremo la soluzione migliore.
Leggi anche