A while back, I worked on a small Flutter project while I was learning about Providers. I tried mimicking a simple MVI design pattern from this repo and attempted to create a Flutter version of it. At the time, it received a quick review that was fairly critical because I hadn't fully understood or applied the core concepts yet. Today, I am salvaging that old project in my quest to implement a proper design pattern for scalable projects.
The original Android app tracks how many times you press the thumbs-up and heart buttons and displays those numbers on the screen. When I revisited my old Flutter version, I couldn't even get it to run. I had to migrate it twice because the Android v1 embedding and the imperative apply of Flutter's Gradle plugins have since been deprecated. If you are working with an old Flutter project created prior to version 1.12, you may need to use these two migration setups as well.
What is MVI?
After updating the project, I took a step back to ask: what exactly is MVI? MVI stands for Model-View-Intent, where the Model represents the app state, the View is the UI, and the Intent represents user actions.
In MVI, the model must be immutable. You typically need several methods, such as copyWith to update the state, comparison operators, toString, and hashCode. Writing these manually is extremely error-prone. In Android development, Kotlin provides data classes that handle this automatically, but what about Dart? Fortunately, we have the freezed package to help us out.
Defining the Model
In this Flutter app, the model needs to track the number of thumbs-ups and hearts. By using Freezed annotations, I can generate all the methods I need. Keep in mind that you will need to install the latest version freezed package dependencies and generate the code using build_runner by running dart run build_runner build (or watch if you want it to update every time the code changes). As of version 2.7.0, you no longer need to pass the -d flag to build_runner as it is now automatic. For the best performance, consider using the latest version of build_runner, as it is expected to receive more performance boosts in the near future; you can follow the progress here for details
Here is the code I used for the model:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'upvote_model.freezed.dart';
//uncomment this line if parsing factory json constructor
//part 'upvote_model.g.dart';
//creating an immutable data class /hashcode/copyWith/toString etc..
//similiar to kotlin language data class
@freezed
abstract class UpvoteModel with _$UpvoteModel{
const factory UpvoteModel({
required int hearts,
required int thumbsUp,
}) = _UpvoteModel;
}
Handling User Intent
In the Android version, Kotlin uses sealed classes (or sealed unions) to help identify user intents. You can think of these as enhanced enums. With Dart 3.0, we can now use the sealed class modifier. For this app, I only needed to track two specific user properties.
// this is the same as a declaring a sealed class in kotlin
// it similar to enums but have more functionnality
// some example: https://www.baeldung.com/kotlin/sealed-class-vs-enum
sealed class MainViewEvent {}
class ThumbsUpClick extends MainViewEvent {}
class LoveItClick extends MainViewEvent {}
State Management with Riverpod
For state management, I took this opportunity to learn more about Riverpod. It is a significant improvement over the original Provider package. Providers can be automatically generated for you when using Riverpod annotations. To get started, make sure you have all the necessary dependencies and add the riverpod_generator.
A Note on Linting
I want to highlight a challenge I faced while installing the riverpod_lint package. I had some trouble enabling it properly because the official website provides very little installation information. I initially tried placing it under the analyzer tag in analysis_options.yaml, but it didn't work. I asked Gemini and Claude for help, but they suggested outdated methods, such as adding a custom lint package, which wasn't what I wanted.
After digging for hours, I looked into the analysis_server_plugin that Riverpod uses. Instead of just reading the docs, I prompted an AI to explain how it works and how to install and enable your own plugin. With that information, I was able to properly install riverpod_lint plugin.
To install it, add it as a dev dependency in your pubspec.yaml. Here is a snippet of how that file should look:
name: upvote
description: an example using riverpod state management with mvi design pattern
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.10.7
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
riverpod_annotation: ^4.0.0
flutter_riverpod: ^3.1.0
freezed_annotation: ^3.1.0
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.10.5
riverpod_generator: ^4.0.0+1
riverpod_lint: ^3.1.0 # your riverpod lint package
flutter_lints: ^6.0.0
freezed: ^3.2.3
flutter:
uses-material-design: true
To enable it, your analysis_options.yaml file should look like this:
include: package:flutter_lints/flutter.yaml
# riverpod package lint rules
plugins:
riverpod_lint: ^3.1.0
analyzer:
linter:
rules:
Implementing the Intent Factory
To handle view events, I created a class that generates a provider for my widgets.
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:upvote/Model/upvote_model.dart';
import 'package:upvote/View/main_view_event.dart';
part 'upvote_intent.g.dart';
// the annotation here create a provider for me and declare it
// all I need is to add the method I need for when the state change
// and get the "ref" in our flutter widget with "mainViewIntentFactoryProvider"
// variable
// https://riverpod.dev/docs/concepts2/refs#how-to-obtain-a-ref
@riverpod
class MainViewIntentFactory extends _$MainViewIntentFactory {
// add my model here to begin with initial values
// I need to override the build method here
// the "state" variable here is the model itself
@override
UpvoteModel build() => const UpvoteModel(hearts: 0, thumbsUp: 0);
void toIntent(MainViewEvent mainViewEvent) {
state = switch (mainViewEvent) {
ThumbsUpClick() => state.copyWith(thumbsUp: state.thumbsUp + 1),
LoveItClick() => state.copyWith(hearts: state.hearts + 1),
};
}
}
In the code above, I could have created two separate methods like addHeart() and addThumbsUp() while omitting MainViewEvent entirely. However, in MVI, using sealed classes is a great way to describe your models and makes it much easier to write tests for your state machine.
Building the UI
For the widgets, I created two rows inside a column and extended the MyHomePage class with ConsumerWidget. This allows me to call my provider methods and listen for changes to rebuild the widgets with their new state. I could have wrapped my column with a Consumer() widget instead, but I find extending ConsumerWidget and getting the ref from the build() method to be much simpler and less verbose.
there are a few example of using the Consumer from the Riverpod docs.
// instead of the StatelessWidget we use ConsumerWidget
// to get access to the provider variable
class MyHomePage extends ConsumerWidget {
const MyHomePage({super.key});
@override
// here we get the WidgetRef ref variable to access our provider
Widget build(BuildContext context, WidgetRef ref) {
// share the provider variable to it children
// the watch here is to listen for changes for the upvote model
final UpvoteModel upvoteModel = ref.watch(mainViewIntentFactoryProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Upvote Flutter Version'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'${upvoteModel.hearts} β€',
style: Theme.of(context).textTheme.displaySmall,
),
const SizedBox(width: 10),
Text(
'${upvoteModel.thumbsUp} π',
style: Theme.of(context).textTheme.displaySmall,
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: ElevatedButton(
child: const Text('+β€'),
onPressed: () {
// use the notifier inside read to call our intent
// method from upvote_intent.dart
ref
.read(mainViewIntentFactoryProvider.notifier)
.toIntent(LoveItClick());
debugPrint('heart');
},
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: ElevatedButton(
child: const Text('+π'),
onPressed: () {
ref
.read(mainViewIntentFactoryProvider.notifier)
.toIntent(ThumbsUpClick());
debugPrint('thumb');
},
),
),
],
),
],
));
}
}
Wrapping Up
And that's it! When I tap on either button, a view event is fired through my intent method. Then, my provider copies the old model state and returns a new one while also listening for any changes to update the view.
You can view the full Flutter app on GitHub here. I'm still relatively new to MVI and looking to improve, so if you spot any errors or have suggestions, please let me know!
This article has been reviewed and refined with the assistance of NotebookLM to ensure technical accuracy and clarity.
Top comments (0)