mvu_flutter
No mutability. No builders. No connectors. No reducers. No StreamControllers and subscription management. A truly declarative state management approach taken from the Elm language.
Motivation
There are a lot of state management packages already, and there are even some MVU packages. Why do we need another one?
I was unable to find one that satisfies my needs. Some are nice but take stupid amounts of code to use, some are good in React world, but here they rely too heavily on code generation. Some give too much freedom and code becomes impossible to maintain, and most of them use mutability. Unfortunately, the list can be extended.
Ok, there is mvu_layer and Dartea. What is wrong with them?
Dartea was a promising project! It really was a close implementation of Elm-like MVU, but it was abandoned three years ago. Shame.
What about mvu_layer?
In my opinion, the final product wandered a little too far from The Elm Architecture. It uses Messengers, builders and it feels more like Redux more than anything else, and it’s “Flutterfied” – that is, it has a lot in common with other, more popular approaches that I don’t find appealing.
The solution
mvu_flutter allows writing entire apps without directly mutating state once, writing purely functional code, and using Programs like loosely coupled building blocks.
It’s a very close implementation of MVU architecture from Elm language and it tries to emulate some of the features. It uses simple data classes to emulate tuples, inheritance to further emulate algebraic data types and it communicates with emulated “Elm runtime” through handy dispatch methods.
It provides full immutability, ease of reasoning about code, protects from bugs that other approaches are very prone to, and is just very pleasant to use. Imagine that you don’t have to fight with state management, but instead, it will help you, lead in the right direction and work as intended in the end. I was unable to find such an approach, so mvu_flutter was created!
If the words above didn’t make a lot of sense for you — that’s okay! MVU is one of those things that “click” and becomes natural after a while, and the section below will help with understanding the core concepts. To further sort out some of the corner cases, a collection of examples can be used.
Getting started
Let’s build a counter!
Model
Firstly, the counter needs a Model
. Every user-defined model must extend the base Model
class for type safety, and it must be fully immutable; every field must be final.
Model
is used to store the current state that the UI reflects, it is a purely data-abstraction that doesn’t do anything other than store data. Our model will hold a single integer field – currentValue.
@freezed
class CounterModel extends Model with _$CounterModel {
const factory CounterModel(int currentValue) = _CounterModel;
}
@freezed
is used here to reduce boilerplate, it’s not obliged to make every model with it, but it comes with some upsides: it implements .== method for us, which needs to be implemented for the library to update the UI when the model changes, and implements the copyWith method which is extremely useful in update
function.
Messages
Then, we need to define the Messages
that will be sent through the dispatch function and will be used inside the update function. Let’s make our counter able to both increment and decrement the current value. Every message will hold a single field, amount, that will be used to determine by how much currentValue
will be modified.
class IncrementMsg extends Msg<CounterModel> {
final int amount;
IncrementMsg({required this.amount});
}
class DecrementMsg extends Msg<CounterModel> {
final int amount;
DecrementMsg({required this.amount});
}
Think about messages as real-world physical messages, like letters, parcels, and other shipments. They have their form and contents – just as MVU messages do.
Every message must extend the base Msg
class and specify as generic the Model that they are used with. So, following the real-world parallels, a shipment that consists of a ceramic mug can follow the type signature class ParselMsg extends Msg<YourName>
.
Update function
Now, we need to describe how every message will affect the model. Remember, models are immutable, so the update function will not modify, but produce a new model, copying the existing one with updated fields.
Since MVU is a declarative approach, you do not need to tell how, when, and where our model will be assigned and used – you only need to describe how it will be affected for every message, and the library will take care of the rest.
update
function type signature states that it must return not a Model
, but a ProducedState
. It is a simple tuple wrapper that consists of a new model and commands. Commands are optional and not used here for simplicity.
Update functions must be pure! It’s mandatory that for every combination of inputs the same output will be produced. For anything that involves side effects, commands must be used. Side effects include but are not limited to: API calls, HTTP requests, local database queries, terminal logging, push notifications, and more.
class UpdateCounter extends Update<CounterModel> {
@override
ProducedState<CounterModel> update(
Msg<CounterModel> message, CounterModel model) {
if (message is IncrementMsg) {
return ProducedState(
model.copyWith(currentValue: model.currentValue + message.amount));
}
if (message is DecrementMsg) {
return ProducedState(
model.copyWith(currentValue: model.currentValue - message.amount));
}
return ProducedState(model);
}
}
Note – the update
function must be implemented synchronously, anything asynchronous must be performed through commands
, example of commands usage can be found in the examples below.
View
Lastly, we need a UI to display our Model
and send Msg
s to the update
function. mvu_flutter provides an extension of StatelessWidget
called View
, and every class that extends View
must specify the Model
that it displays through generic.
Note – every View
of Model
A
must be a direct descendant of Program
(more about Program
s later) of the same type A
. Trying to use View<A>
as a descendant of Program<B>
that is a descendant of Program<A>
will lead to terrible bugs and must never be used in that way. In those cases, the composition or/and dispatchGlobal
function must be used. More about those cases can be learned from examples.
class CounterView extends View<CounterModel> {
@override
Widget view(BuildContext context, CounterModel model) => Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Counter value: ${model.currentValue}'),
ElevatedButton(
onPressed: dispatchCallback(IncrementMsg(amount: 10)),
child: Text("Increment"),
),
ElevatedButton(
onPressed: dispatchCallback(DecrementMsg(amount: 1)),
child: Text("Decrement"),
),
],
),
),
);
}
The View
widget adds two new methods – view
and dispatch
. View
’s Model
type that View
will reflect must be specified as generic. The difference in view
and build
methods is that in the View
widget first one must be overridden instead of build
and it has a new parameter – model
. Model is immutable and it is passed for read-only rendering purposes.
To change the model and update the state, the second method, dispatch
, must be used. It takes as an argument a message, and the message must extend Msg
with the same model type as the View
. Note the dispatchCallback
method – it's a convenience method that can be used as an argument where the dynamic Function()
type is needed, so you don't have to wrap the dispatch
method in anonymous closure by yourself.
If there is a need to dispatch a message to another Program
with another model type, the dispatchGlobal
function is used, but be careful with doing so. There is no way to check if such a Program
exists, and it is done this way intentionally – the dispatchGlobal
function must be used only in cases where it’s known about the target Program
existence.
Program
And to wrap everything up we need to declare a Program
. Program
takes 5 parameters: an initial model
, a view
, an update
, list of initial commands
(optional), and a subscriptions
function (also optional).
Program
can be used as any other widget, and every Program
has its data flow loop. Since every program consists of several independent modules, they can be replaced individually, which is very useful in team development and for testing new ideas.
Lets declare the Program
:
final counterProgram = () => Program(
model: CounterModel(0),
view: () => CounterView(),
update: UpdateCounter(),
);
Note the view parameter type – it must be a closure to be initialized lazily.
Commands
--Section in progress--
Subscriptions
--Section in progress--
Composition
--Section in progress--
Examples
-- Section in progress--
Performance, reusability, code style, and best practices notes
DOs
- Do use
StatelessWidgets
instead ofView
when a model is not needed – it will help with reusability - Do split application into multiple different
Program
s, program per screen+ is fine – it will help with performance - Do split
update
function into multiple functions, possibly a function for a message – it will help with maintainability
CONSIDERs
- Consider using freezed package when defying models – it will help with the boilerplate reduction
- Consider combining both composition approaches when it's appropriate – it will help with maintainability
- Consider namespacing
command
's functionality into stateless static dependencies – it will help with segregation of concerns
DON’Ts
- Don’t use views as non-direct descendants of model type – it will lead to terrible bugs
- Don’t implement any logic that will produce side effects or be referential non-transparent inside
update
functions – commands must be used instead andupdate
functions must be pure - Don’t store state anywhere outside of
Model
s – stateful dependencies must not be used
Sidenote and roadmap
This package is pretty small and there is not much room to grow bigger, pretty much all of the desired functionality was implemented in the very first version under the course of three evenings. There will be minor performance improvements in the next versions, the example library will continue to grow, but feature-wise – I don’t see what can be added. If you have any suggestions – feel free to contact me via email or by opening an issue on GitHub.
Variance feature in the stable version of Dart’s SDK will lead to breaking changes – the update will be reduced to a single function instead of a function wrapped in class and the initial model will be passed as an argument in the subscriptions function. For now, if the initial model is needed in subscriptions or initial commands, it must be declared as a top-level private variable.