MVI — Comprendre le modèle Model-View-Intent dans les apps mobiles

Auteur : IT Sectr Publié le : 2026-02-16 Temps de lecture : 10 min

MVI (Model-View-Intent) est un modèle architectural réactif basé sur un flux de données unidirectionnel et un état immuable. Contrairement à MVVM, où une ViewModel peut avoir plusieurs StateFlows, MVI définit un état unique (State), des intentions immuables (Intent) et une fonction réductrice pure (Reducer). MVI garantit la prévisibilité de l'état de l'écran à tout moment. Le modèle a été popularisé dans la communauté Android par les bibliothèques Mosby et Orbit. En savoir plus dans MVIKotlin d'Arkadii Ivanov.

Points Clés

  • MVI — Model (état), View (affichage), Intent (intention utilisateur) — un cycle réactif
  • Unidirectional data flow — les données circulent dans une direction : Intent → Reducer → State → View
  • Immutable State — l'état de l'écran est un objet immuable, recréé à chaque changement
  • Reducer — une fonction pure qui prend l'état actuel et un Intent, retournant un nouvel état
  • Side effects — les effets de bord (réseau, BD) sont traités séparément du Reducer, via Middleware

Qu'est-ce que MVI : l'essence du modèle Model-View-Intent

MVI (Model-View-Intent) est un modèle architectural réactif construit sur les principes de Redux et Cycle.js. Model est l'état immuable de l'écran, Intent est une intention utilisateur ou système, View s'abonne à l'état et envoie des Intents. Les données circulent en cycle : l'utilisateur interagit avec la View → la View crée un Intent → l'Intent est traité par le Reducer → le Reducer crée un nouvel état → la View reçoit le nouvel état et réaffiche.

La principale différence entre MVI et MVVM est la Source Unique de Vérité. Dans MVVM, une ViewModel peut avoir plusieurs LiveData/StateFlow (userState, loadingState, errorState), ce qui entraîne une incohérence : loading=true et user=null simultanément. Dans MVI, il existe exactement une classe/interface sealed State qui décrit tout l'état de l'écran. À tout moment, l'état de l'écran est déterminé de manière unique — il est impossible d'avoir loading=true alors que les données sont déjà chargées. Chez IT Sectr, nous appliquons MVI pour les écrans à logique complexe — formulaires de commande, inscriptions multi-étapes, écrans financiers — où la prévisibilité de l'état est critique.

ComposantRôle dans MVIExemple
IntentIntention utilisateur ou systèmeLoadUser, Refresh, SubmitForm
StateÉtat d'écran immuablesealed class UserState
ReducerFonction pure : State + Intent → Statefun reduce(state, intent) -> state
MiddlewareTraitement des effets de bordRequête réseau, écriture BD

Le cycle MVI se compose de cinq étapes : 1) La View envoie un Intent (ex. LoadUser(42)); 2) Le Middleware (EffectHandler) exécute un effet de bord — une requête réseau ; 3) Le résultat est renvoyé comme un nouvel Intent dans le système ; 4) Le Reducer prend l'état actuel et l'Intent, crée un nouvel état ; 5) La View reçoit le nouvel état et réaffiche. Chaque étape est prévisible et testable isolément.

MVI sous Android : Intent, Reducer, State en Kotlin

MVI sous Android est implémenté avec des classes sealed pour Intent et State, une ViewModel avec logique MVI et Jetpack Compose pour le rendu réactif. La ViewModel reçoit l'Intent de la View, délègue les effets de bord au Middleware, exécute le Reducer et publie le nouvel état via StateFlow. Jetpack Compose réaffiche l'UI quand l'état change — idéal pour le cycle MVI.

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

// State — état unique de l'écran
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 — fonction pure
object UserReducer {
    fun reduce(state: UserState, intent: UserIntent): UserState = when (intent) {
        is UserIntent.LoadUser -> UserState.Loading
        is UserIntent.Refresh -> UserState.Loading
    }
}

// ViewModel avec 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 précédent */)
        }
    }

    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 envoie 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 et effets de bord — dans MVI, un Reducer pur ne peut pas effectuer de requêtes réseau. Le Middleware (également EffectHandler ou Bootstrapper) traite l'Intent, exécute l'effet de bord et émet un nouvel Intent dans le cycle. Les bibliothèques Orbit MVI et MVIKotlin fournissent un support Middleware intégré avec des effets testables. Sans Middleware, MVI dégénère en MVVM avec une structure Intent et State supplémentaire.

MVIKotlin d'Arkadii Ivanov est la bibliothèque MVI la plus populaire pour Kotlin Multiplatform. Elle prend en charge Android, iOS, le web et JVM. Elle fournit les composants : Store (ViewModel), Bootstrapper (effets initiaux), Reducer, Middleware. En octobre 2025, la bibliothèque a rassemblé 2,5K étoiles sur GitHub et est utilisée dans des projets commerciaux, y compris des applications de grandes banques russes. Chez IT Sectr, nous utilisons MVIKotlin pour les projets multiplateformes KMP avec logique métier partagée.

MVI sous iOS : flux unidirectionnel en Swift

MVI sous iOS est implémenté sans Combine-ViewModel, via le cycle Intent → State. La View envoie un Intent via une fermeture, le Reducer est une fonction pure, et State est une struct aux champs immuables. SwiftUI réaffiche la View quand State change, ce qui s'intègre parfaitement dans le cycle MVI sans propriétés @Published supplémentaires. MVI sur iOS est particulièrement populaire parmi les développeurs SwiftUI ayant migré de Redux (JavaScript).

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

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

// Reducer — fonction pure
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 — possède l'état et gère les effets
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 met à jour l'état
        state = userReducer(state: state, intent: intent)
        // 2. Side effects (si nécessaire)
        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) est l'implémentation MVI la plus populaire pour iOS par Point-Free, construite sur SwiftUI et Combine. TCA fournit Store, Reducer, Effect et Environment. En octobre 2025, ses étoiles GitHub dépassent 13K — c'est le standard de facto pour MVI sur iOS. TCA est utilisé dans les apps Starbucks, Airbnb (partiellement) et de nombreux projets indépendants. Contrairement au MVI personnalisé, TCA résout les tests, la navigation et les effets de bord dès le départ.

MVI vs MVVM sur iOS — TCA/MVI offre la prévisibilité de l'état mais nécessite plus de code boilerplate (Reducer, State, Action). MVVM avec @Published est plus simple pour les écrans basiques. Chez IT Sectr, nous utilisons MVVM pour 80% des écrans et MVI (TCA) pour 20% complexes — transactions financières, formulaires multi-étapes, interfaces drag-and-drop, où une erreur d'état pourrait coûter de l'argent à l'utilisateur.

Comparaison MVI vs MVVM : quand choisir MVI

MVI et MVVM résolvent le même problème — organiser la couche de Présentation — mais avec des approches différentes de la gestion d'état. MVVM autorise plusieurs sources réactives (LiveData, @Published), ce qui peut entraîner des incohérences. MVI garantit exactement un état à tout moment, le rendant plus strict et prévisible, mais augmente la quantité de code.

CritèreMVVMMVI
ÉtatPlusieurs LiveData/StateFlowClasse sealed State unique
Flux de donnéesBidirectionnel (View → ViewModel, LiveData → View)Unidirectionnel (Intent → Reducer → State → View)
Effets de bordDirectement dans ViewModelVia Middleware/EffectHandler
TestsTests unitaires de ViewModelTests unitaires de Reducer + Middleware
Code boilerplateMinimalReducer + State + Intent + Middleware

Quand choisir MVI — écrans où l'état doit être strictement déterministe : opérations financières, panier d'achat, formulaires multi-étapes avec validation à chaque étape. Dans ces scénarios, le coût d'une erreur d'état (par exemple, afficher le total du panier sans un article à cause d'une condition de course entre deux LiveData) dépasse le coût du code supplémentaire. Dans MVVM, vous comptez sur la discipline de l'équipe ; dans MVI, vous comptez sur l'architecture.

Quand MVVM suffit — 80% des écrans standard : liste d'utilisateurs, profil, paramètres, fil d'actualités. Ici, un état unique est excessif et la structure MVI supplémentaire ralentira le développement. Chez IT Sectr, la règle est : si un écran a 3+ états possibles avec transitions (chargement → données → erreur → réessayer → chargement → données) — utilisez MVI. Si un écran a 1-2 opérations asynchrones — utilisez MVVM.

Bonnes pratiques MVI et erreurs courantes

Sealed State — meilleure pratique dans MVI. L'état est défini comme une classe/interface sealed avec des variantes : Loading, Success(data), Error(message). Cela garantit que la View ne se retrouve pas dans un état incohérent — on ne peut pas afficher de données quand loading=true car Loading et Success sont des classes différentes. Toutes les données relatives à l'état résident dans la variante sealed : Success contient l'utilisateur, Error contient le message d'erreur.

Le Reducer doit rester une fonction pure — sans appels API, BD ou SharedPreferences. Une fonction pure prend State et Intent et retourne State. Les effets de bord (réseau, BD, navigation, toasts) sont traités dans Middleware ou dans Store.dispatch après appel du Reducer. Si le Reducer est pollué par des effets de bord, MVI perd sa testabilité et sa prévisibilité — vous obtenez MVVM avec une structure supplémentaire sans avantages.

Erreurs courantes — déclarer State comme data class avec des champs nullable au lieu d'une sealed class : data class UserState(val user: User?, val isLoading: Boolean, val error: String?). Cela équivaut à MVVM, pas à MVI — la View doit vérifier les combinaisons de champs pour leur validité. Dans l'approche sealed, les combinaisons invalides (isLoading=true et user!=null) sont impossibles au niveau des types. La deuxième erreur est de placer la logique métier dans Intent (Intent.LoadUserBeforeXHours) au lieu de créer des Intents de commande simples (Intent.LoadUser) et de placer la logique métier dans Middleware.

Questions Fréquentes

Quelle est la principale différence entre MVI et MVVM ?

MVI utilise une seule classe sealed State immuable et un flux de données unidirectionnel via un Reducer. MVVM autorise plusieurs LiveData/StateFlow avec liaison bidirectionnelle. MVI garantit la cohérence de l'état au niveau des types — il est impossible d'avoir loading=true et user=null simultanément. MVVM repose sur la discipline du développeur.

Quelles bibliothèques MVI existent pour Android ?

Principales : MVIKotlin (Arkadii Ivanov, 2,5K étoiles, Kotlin Multiplatform), Orbit MVI (BabyJ, 1,3K étoiles), Mobius (Spotify, Kotlin/Java). MVIKotlin est la plus populaire pour Kotlin, Orbit est la plus facile à apprendre. Les trois supportent Reducer et Middleware testables. Pour Jetpack Compose, vous pouvez écrire un MVI simple sans bibliothèque avec sealed State + Reducer.

Une bibliothèque séparée est-elle nécessaire pour MVI ?

Non — Intent sealed + State sealed + ViewModel + StateFlow donnent un MVI fonctionnel sans dépendances. Les bibliothèques (MVIKotlin, Orbit, TCA) ajoutent Middleware, test des effets de bord et intégration DI. Pour les projets simples, le poids de la bibliothèque n'est pas justifié. Pour les projets complexes avec 20+ écrans, la bibliothèque se rentabilise avec un traitement structuré des effets.

MVI est-il adapté à iOS ou est-ce un modèle propre à Android ?

MVI fonctionne très bien pour iOS via TCA (The Composable Architecture) — l'architecture la plus populaire de la communauté SwiftUI. TCA est essentiellement MVI + Redux + Combine. Sur iOS, vous pouvez implémenter MVI sans TCA en utilisant ObservableObject et une fonction réductrice pure. SwiftUI avec State immuable s'intègre parfaitement dans le cycle MVI.

Comment tester MVI ?

Le Reducer est testé avec des tests unitaires comme fonction pure : définir un State initial, envoyer un Intent, vérifier le State résultant. Le Middleware est testé avec un dépôt mock : vérifier que getUser a été appelé après LoadUser. Test de ViewModel : envoyer un Intent, vérifier le StateFlow. MVI est plus facile à tester que MVVM car le Reducer est une fonction pure sans dépendances cachées.

Résumé

  • MVI (Model-View-Intent) — un modèle réactif avec flux unidirectionnel et état unique
  • Sealed State — garantit la cohérence au niveau des types, éliminant les combinaisons invalides
  • Reducer — fonction pure State + Intent → State, testable sans objets mock
  • Middleware — une couche séparée pour les effets de bord (réseau, BD, navigation)
  • MVI vs MVVM — MVI est plus strict et prévisible, MVVM est plus simple et rapide
  • Android — MVIKotlin ou Orbit pour écrans complexes ; MVVM pour écrans simples
  • iOS — TCA (The Composable Architecture) est le standard MVI sur SwiftUI

Nous développerons une application mobile clé en main

IT Sectr crée des applications iOS et Android pour les startups et les entreprises depuis 2017. Nous vous conseillerons et vous proposerons la meilleure solution.

Discuter du projet

Lisez aussi