Inside popular Flutter state management libraries

Xianzhe Liang
7 min readJul 26, 2022

Demystify Flutter state management solutions by understanding their internal mechanism.

We will categorize libraries by how it propagates state changes.

This is the key attribute and defines a library's capability and limitation. Libraries using the same state propagating mechanism are more or less similar.

There are four approaches to propagate state changes:

And here is a summary (link):

Using widget

StatefulWidget and InheritedWidget are Flutter’s native ways to manage states. They are easy to understand and easy to use.

StatefulWidget holds mutable state inside a widget. You can pass callbacks to adjacent widgets to allow them to change state too.

InheritedWidget holds immutable states and allows its descendants to access the state easily. You can rebuild the InheritedWidget (e.g. ChangeNotifier + InheritedNotifier, DartPad), then relevant descendants will rebuild too.

provider adds some syntax sugar to InheritedWidgetand makes it less verbose to work with (e.g. DartPad). It also supports lazy loading. (code)

Pros and cons of using widgets for state propagation:

Pros:

  • Simple and easy to understand.

Cons:

  • Need to insert a data-providing widget in the widget tree. Sometimes it is tricky to find the right place and all end up near the root.

Using stream

Some people want to develop and test their business logic in well-encapsulated modules which are not tied to Flutter widgets.

Since Stream has states and can be listened to, it can be used for state propagation. Let’s build our own solution first (DartPad):

// counter.dartint _count = 0;
final _controller = StreamController<int>..add(_count);
Stream<int> counter() => _controller.stream;
void increment() => _controller.add(_count++);
void decrement() => _controller.add(_count--);
// main.dart...
StreamBuilder<int>(
stream: counter(),
builder: (context, snapshot) => Text('${snapshot.data}'),
);
...
TextButton(onPressed: () => increment(), child: const Text('+1'));
TextButton(onPressed: () => decrement(), child: const Text('-1'));

It works, but the stream is initialized before necessary and not disposed properly.

We can put the StreamController in a class (call it CounterCubit, or CounterBloc, or CounterStore), then provide this class to a subtree through Provider or InheritedWidget, and wrap StreamBuilder inside a widget (call it BlocBuilder or StoreBuilder). Something like this (DartPad).

Great, now you got flutter_bloc and flutter_redux!

Well, these two libraries also add a few rules on how to define the state mutation function:

  • Cubit (from flutter_bloc) allows any state mutation function.
  • Bloc / Store requires you to define events/actions and can only mutate state by handling those events/actions.

Code links: flutter_bloc (state, provider, stream), flutter_redux (state, InheritedWidget, stream).

Besides that, both libraries advocate certain patterns to structure your project. It is not exactly state management but is helpful for beginners.

Pros and cons of using Stream for state propagation:

Pros:

  • Simple and easy to understand.
  • Advocate project structure patterns that are good for beginners.

Cons:

  • Similar to using widgets, need to insert a data-providing widget in the widget tree.
  • Cannot easily make a cubit/bloc/store dependent on another cubit/bloc/store. Needs a lot of Stream listening and canceling.

Using subscription

Subscription is another common approach for state management. It is also called reactive programming.

The idea is simple. Create a node (a class) that can hold a state and update its state by subscribing to other nodes’ states.

In other words, the nodes form a subscription graph, and state changes flow through the edges:

Note here the nodes can hold Widget as its state, then used by Flutter widgets tree.

Again let’s try to build our own solution first. We can name the node Rx. See this DartPad.

/// A stack tracks Rx variables being recreated.
List<Rx> _creating = [];
/// Basic reactive variable.
class Rx<T> {
Rx(this.create) {
_creating.add(this);
_state = create();
_creating.removeLast();
}
late T _state;
final T Function() create; // User provided create function
final Set<Rx> listeners = {};
T call() {
listeners.add(_creating.last);
return _state;
}
/// Update state with [update] function or [create] function.
void update([T Function(T)? update]) {
_creating.add(this);
final oldState = _state;
_state = update != null ? update(_state) : create();
_creating.removeLast();
if (oldState != _state) {
for (var l in listeners) {
l.update();
}
}
}
}
Rx num1 = Rx(() => 1);
Rx num2 = Rx(() => 2);
Rx sum = Rx(() => num1() + num2());
Rx widget = Rx(() => Text('${sum()}'));

The node is called Provider in riverpod, Observable in flutter_mobx, and Rx in getx:

// riverpodProvider num1 = Provider((ref) => 1);
Provider num2 = Provider((ref) => 2);
Provider sum = Provider((ref) => ref.watch(num1) + ref.watch(num2));
Provider widget = Provider((ref) => Text('${ref.watch(sum)}');
// flutter_mobxObservable num1 = Observable(1);
Observable num2 = Observable(2);
Computed sum = Computed(() => num1.value + num2.value);
Computed widget = Computed(() => Text('${sum.value}'));
// getxRx num1 = 1.obx;
Rx num2 = 2.obx;
int sum = num1.value + num2.value;
Obx widget = Obx(() => Text('${sum.value}'));

The main benefit of adopting this subscription-based pattern is that it is easy to combine states to achieve derived logic. Use the Todo app as an example, we can have:

  • Node A: all Todos fetched from the backend.
  • Node B: a boolean about whether to filter completed todos.
  • Node C: combine A and B to get the filtered Todo list.

This is especially helpful if your app does a lot of data processing on the client side.

These three libraries come with different syntax/features. There is a non-trivial learning curve, and here is a quick summary:

riverpod

riverpod names its nodes providers. Unlike the other two libraries, you need to explicitly establish the dependency using watch function. In my opinion, this is better than the implicit approach, because it is less magical and could avoid unnecessary dependencies.

It provides several types of nodes, which can be used in different scenarios.

Internally, each provider holds its creation function, its state, and its dependencies.

Code links: state, propagate.

flutter_mobx

flutter_mobx groups nodes into Observable (basic state) and Computed(derived state).

Internally, it works similar to riverpod, each Computed holds its creation function, its state, and its dependencies. One key difference is that flutter_mobx uses a global singleton called context to support transactions (updating several nodes’ states at once).

Unlike the other two libraries, flutter_mobx advocates some project structure pattern, where all state changes need to go through some “action” and “store”. Due to this, you will need to use a code generator when using flutter_mobx.

Code links: state, propagate.

getx

getx is not fully reactive, since it doesn’t support derived Rx variables. If you look at the example above, sum is an integer, not Rx variables, which means its state is not cached. If there are two widgets using sum, the calculation happens twice.

Internally, each Rx variable holds a Stream, which is less efficient compared to riverpod and flutter_mobx.

Note getx comes with several state management solutions and we are only discussing the reactive one in this article.

Code links: state, stream.

Pros and cons of using subscriptions for state propagation:

Pros:

  • No need to insert a data-providing widget in the widget tree.
  • Easy to combine states to achieve derived logic

Cons:

  • Steep learning curve. Many concepts to learn.

Using graph

The recently released library creator works similarly to the reactive solutions in the previous section. We still have a graph of nodes, but the graph is explicitly modeled with an adjacent list and stored inside an InheritedWidget. (Disclaimer: I’m the author of this newly released library).

Modeling the graph explicitly allows us to:

  • Manage state propagation better for async scenarios.
  • Greatly simplify the internal implementation.
  • Debug the state dependencies easily.

It has a very straightforward implementation without any magic. Read this article about how to build the library yourself with 100 lines of code. Additional code links: state, propagate.

This approach has very simple concepts and a pleasant learning curve. There are only two things:

  • Creator which creates a stream of T.
  • Emitter which creates a stream of Future<T>.

An example weather app (DartPad):

// Simple creators bind to UI.
final cityCreator = Creator.value('London');
final unitCreator = Creator.value('Fahrenheit');

// Write fluid code with methods like map, where, etc.
final fahrenheitCreator = cityCreator.asyncMap(getFahrenheit);

// Combine creators for business logic.
final temperatureCreator = Emitter<String>((ref, emit) async {
final f = await ref.watch(fahrenheitCreator);
final unit = ref.watch(unitCreator);
emit(unit == 'Fahrenheit' ? '$f F' : '${f2c(f)} C');
});

Pros and cons of using subscriptions for state propagation:

Pros:

  • No need to insert a data-providing widget in the widget tree.
  • Easy to combine states to achieve derived logic.
  • Easy to learn and easy to understand.

Cons:

  • It’s new.

Final thoughts

Hope you find this article helpful.

Even though I’m biased towards the creator library, I agree that you should just use whatever fits your project/team/background.

The whole point of this article is that state management is not complex. As long as you understand the underline mechanism, you could easily pick the elements you like and build a good solution for your projects.

Xianzhe from chooly.app

--

--