MVU-Inspired State Management for Flutter
There was a moment where I fell in love with working with front-end, and that was when I met The Elm Architecture, more specifically the Elmish F# implementation. I wanted to have the same happiness I have working with Elmish in Flutter and almost found something similar, but it was still too much work for my lazy self. The basic building blocks of TEA (The Elm Architecture) or MVU (Model-View-Update) are:
- Model — the state of your application
- View — a way to turn your state into widgets (or HTML)
- Update — a way to update your state based on messages
Originally the Update part is like a reducer. You have a function that takes the current model, a message that encodes what you want to change in the model and then you return a new model. That makes it a cool and nice state-machine. You can see in that function how the state will change as the messages arrive by external events or user interaction.
While having a function to keep the state modification centralised is a good thing, Dart support for discriminated unions is kinda bad and having different types implementing a base message creates lots of noise. In the end the message makes the update function select a model transition, so why not encode it in a simple function?
Let’s talk a little about the remaining pieces, the Model and the View. One of the things that I love about MVU is that the model is immutable and the view is a pure function, meaning that it depends only on the current model to decide what will be shown to the user. If you want to test any feature, you can start the model at any intermediate step and be in the screen right before you want to click the button you just implemented, no need to pass through a login screen and click on things just to prepare the environment.
In Elmish the view function takes two arguments: The current model and a dispatch function to send messages. I like that, but now that I'm in OOP world, maybe I can have something that helps automatic code completion, so maybe I could have some sort of object with all possible transitions.
And that’s what I did. Instead of dispatching messages, why not dispatch transitions between states directly and cut the middle update? Instead of scattered message definitions, why not have a single class that holds the methods that do the transitions?
How was it achieved?
Well, it was not an easy journey as I was misguided for a long time, but in the end I believe that the end product creates simple and well behaved apps with little boilerplate.
Let's examine an example, nothing fancy for now, just the transformed sample app using the proposed state machine:
Model:
Starting simple, we have a title for the shown counter and a tally of the current number:
class HomeModel {
final String title;
final int counter;
HomeModel({this.title, this.counter});
HomeModel copyWith({title, counter}) =>
HomeModel(title: title ?? this.title, counter: counter ?? this.counter);
}
Messenger:
Here we have the main player! The messenger is where we will create methods that are like alias to our state machine transitions:
We initialise it with an initial state, an Update
instance that is what we use to pass along the new state. It has other features that will be explained later.
class HomeMessenger extends Messenger<HomeModel> {
HomeMessenger(title) : super(Update(HomeModel(title: title, counter: 0)));
void increment() =>
modelDispatcher((model) => model.copyWith(counter: model.counter + 1));
}
The modelDispatcher
is a special simple case where you just want to create a new state using the previous one.
View:
While in Elmish the view takes the model and the dispatcher, our view takes a BuildContext
together with our messenger and the current model:
Widget homeBuilder(BuildContext context, HomeMessenger messenger, HomeModel model) {
return Scaffold(
appBar: AppBar(
title: Text(model.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button as MVU this many times:',
),
Text(
'${model.counter}',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: messenger.increment,
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
Here we use the title and the counter from the model, and the increment
function from the messenger.
The remaning bits:
How do we use the previous code? That is the answer:
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: MsgBuilder<HomeMessenger, HomeModel>(
messenger: HomeMessenger('Flutter MVU Demo Home Page'),
builder: homeBuilder),
);
}
}
We have a MsgBuilder
that takes a Messenger
and a view function to create a stateful widget that will listen the changes of the model in the messenger and recreate the widget as new state changes arrive.
And that is it for the simplest case. For more complex cases, we can use more capabilities of the Update
object. Its definition looks like this:
class Update<Model> {
final Cmd<Model> commands;
final bool doRebuild;
final Model model;
const Update(this.model,
{this.commands = const Cmd.none(), this.doRebuild = true});
}
Model:
The next state. No secrets here.
Commands:
It's a list of actions that will generate or not transitions in a later time. As every transition is acted on the latest model, there is no risk of race conditions and using a stale model, even in out-of-order execution.
So you can do something like this:
Result getSomethingFromSomewhere() => // long running function
// inside the messenger
void askForSomething() =>
dispatcher((model) => Update(model.copyWith(loading:true),
commands: Cmd.ofFunc(getSomethingFromSomewhere,
onSuccessModel: (model, result) => model.copyWith(loading: false, result: result),
onErrorModel: (model, ex) => model.copyWith(loading: false, error: true))));
All commands are async, so you can choose to keep handling the user interaction while the function completes and then when it succeeds or fails, you do something with it using the state that the user changed with their actions.
DoRebuild:
If false, the new model won't be sent to the MsgBuilder
widget, so it won't trigger a new render.
Where can I check about it?
If the list is updated, this one should be the 32nd state management library for Flutter, so I want to have more feedback before publishing it. In the little research I did, I couldn’t find and exact replica of what I did, but it's probably achievable in other libraries with some extra steps.
EDIT: I published it anyway! It should be on mvu_layer if everything goes well!
I created a sample repository with the simple app and a bigger one with a Todo. You can test it by cloning the GitHub repository I created. You can create issues with questions or corrections of things I might have overlooked. Honestly I'm new in Flutter and most of my previous relevant work is all in F#. Maybe there isn't something similar because it won’t solve most problems? I'll be glad to know, so I can fix it or keep using it!
Thank you for reading!