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-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.
| Composant | Rôle dans MVI | Exemple |
|---|---|---|
| Intent | Intention utilisateur ou système | LoadUser, Refresh, SubmitForm |
| State | État d'écran immuable | sealed class UserState |
| Reducer | Fonction pure : State + Intent → State | fun reduce(state, intent) -> state |
| Middleware | Traitement des effets de bord | Requê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 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.
// 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 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).
// 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.
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ère | MVVM | MVI |
|---|---|---|
| État | Plusieurs LiveData/StateFlow | Classe sealed State unique |
| Flux de données | Bidirectionnel (View → ViewModel, LiveData → View) | Unidirectionnel (Intent → Reducer → State → View) |
| Effets de bord | Directement dans ViewModel | Via Middleware/EffectHandler |
| Tests | Tests unitaires de ViewModel | Tests unitaires de Reducer + Middleware |
| Code boilerplate | Minimal | Reducer + 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.
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
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.
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.
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 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.
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é
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.
Lisez aussi