MVI — Comprendere il Pattern Model-View-Intent nelle App Mobili

Autore: IT Sectr Pubblicato: 2026-02-16 Tempo di lettura: 10 min

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 (stato), View (visualizzazione), Intent (intenzione utente) — un ciclo reattivo
  • Unidirectional data flow — i dati fluiscono in una direzione: Intent → Reducer → State → View
  • Immutable State — lo stato dello schermo è un oggetto immutabile, ricreato a ogni modifica
  • Reducer — una funzione pura che prende lo stato corrente e un Intent, restituendo un nuovo stato
  • Side effects — gli effetti collaterali (rete, DB) vengono gestiti separatamente dal Reducer, tramite Middleware

Cos'è MVI: l'essenza del pattern Model-View-Intent

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.

ComponenteRuolo in MVIEsempio
IntentIntenzione utente o sistemaLoadUser, Refresh, SubmitForm
StateStato schermo immutabilesealed class UserState
ReducerFunzione pura: State + Intent → Statefun reduce(state, intent) -> state
MiddlewareGestione effetti collateraliRichiesta 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: Intent, Reducer, State in Kotlin

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.

kotlin
// 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: flusso unidirezionale in Swift

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).

swift
// 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.

Confronto MVI vs MVVM: quando scegliere MVI

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.

CriterioMVVMMVI
StatoMolteplici LiveData/StateFlowSingola classe sealed State
Flusso datiBidirezionale (View → ViewModel, LiveData → View)Unidirezionale (Intent → Reducer → State → View)
Effetti collateraliDirettamente in ViewModelTramite Middleware/EffectHandler
TestTest unitari della ViewModelTest unitari di Reducer + Middleware
Codice boilerplateMinimoReducer + 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.

Best practice MVI ed errori comuni

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

Qual è la differenza principale tra MVI e MVVM?

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.

Quali librerie MVI esistono per Android?

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.

È necessaria una libreria separata per MVI?

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 è adatto per iOS o è un pattern solo per Android?

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.

Come testare 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

  • MVI (Model-View-Intent) — un pattern reattivo con flusso unidirezionale e stato singolo
  • Sealed State — garantisce coerenza a livello di tipi, eliminando combinazioni non valide
  • Reducer — funzione pura State + Intent → State, testabile senza oggetti mock
  • Middleware — un livello separato per effetti collaterali (rete, DB, navigazione)
  • MVI vs MVVM — MVI è più rigoroso e prevedibile, MVVM è più semplice e veloce
  • Android — MVIKotlin o Orbit per schermi complessi; MVVM per schermi semplici
  • iOS — TCA (The Composable Architecture) è lo standard MVI su SwiftUI

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.

Discuti il progetto

Leggi anche