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 Msgs 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 Programs 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 of View when a model is not needed – it will help with reusability
  • Do split application into multiple different Programs, 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 and update functions must be pure
  • Don’t store state anywhere outside of Models – 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.

Libraries

mvu_flutter