Flutter Advanced Lesson 95 of 135 3 min read
MVI in Flutter: Model–View–Intent with One State and Intents
Learn MVI (Model–View–Intent) in Flutter: one immutable state, user intents and one-way data flow, with an easy runnable example and how Bloc relates.
On this page
MVI stands for Model–View–Intent. It is a one-way (unidirectional) architecture:
- Intent: what the user wants to do (“load”, “increment”, “search for X”).
- Model: one immutable state object describing the whole screen.
- View: draws that state and sends intents.
View --intent--> reducer(state, intent) --new state--> View
Because there is exactly one state object per screen, the UI can never be “half updated”. If you know Redux or Bloc, you already know MVI: Bloc’s events are intents and its states are the model.
A counter with loading, in MVI
import 'package:flutter/material.dart';
// ---------- Model: one immutable state ----------
class CounterState {
const CounterState({this.count = 0, this.saving = false, this.message});
final int count;
final bool saving;
final String? message;
CounterState copyWith({int? count, bool? saving, String? message}) => CounterState(
count: count ?? this.count,
saving: saving ?? this.saving,
message: message,
);
}
// ---------- Intents: what the user wants ----------
sealed class CounterIntent {}
class Increment extends CounterIntent {}
class Reset extends CounterIntent {}
class Save extends CounterIntent {}
// ---------- Store: turns intents into new states ----------
class CounterStore extends ValueNotifier<CounterState> {
CounterStore() : super(const CounterState());
Future<void> dispatch(CounterIntent intent) async {
switch (intent) {
case Increment():
value = value.copyWith(count: value.count + 1);
case Reset():
value = const CounterState();
case Save():
value = value.copyWith(saving: true);
await Future.delayed(const Duration(seconds: 1)); // pretend to save
value = value.copyWith(saving: false, message: 'Saved ${value.count}');
}
}
}
// ---------- View ----------
final store = CounterStore();
void main() => runApp(const MaterialApp(home: CounterPage()));
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<CounterState>(
valueListenable: store,
builder: (context, state, _) => Scaffold(
appBar: AppBar(title: const Text('MVI')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('${state.count}', style: const TextStyle(fontSize: 56)),
if (state.saving) const CircularProgressIndicator(),
if (state.message != null) Text(state.message!),
const SizedBox(height: 16),
Wrap(
spacing: 8,
children: [
FilledButton(onPressed: () => store.dispatch(Increment()), child: const Text('+1')),
OutlinedButton(onPressed: () => store.dispatch(Reset()), child: const Text('Reset')),
OutlinedButton(
onPressed: state.saving ? null : () => store.dispatch(Save()),
child: const Text('Save'),
),
],
),
],
),
),
),
);
}
}
- The view never changes data; it only sends intents.
sealed class+switchmeans the compiler checks you handled every intent (see sealed classes).- Every possible screen is described by one
CounterState, so it is easy to test and to debug.
MVI vs MVVM
| MVI | MVVM | |
|---|---|---|
| State | One immutable object per screen | Several properties on a ViewModel |
| User actions | Intent objects | Method calls |
| Data flow | Strictly one-way | Usually one-way |
| Flutter tool that fits | Bloc, Redux | Riverpod, Provider, ChangeNotifier |
Try it yourself
Add a Decrement intent that is not allowed below zero, and show an error message in the state when the user tries.