Flutter Tutorial

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 + switch means 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

MVIMVVM
StateOne immutable object per screenSeveral properties on a ViewModel
User actionsIntent objectsMethod calls
Data flowStrictly one-wayUsually one-way
Flutter tool that fitsBloc, ReduxRiverpod, 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.

Practise in the playground Updated