All I Know about State Management

All I Know about State Management

Flutter constructs UI declaratively. Each widget can't be changed individually after rendered, so we have to rebuild the entire widget tree if we want to change even a single Text, though we don’t worry about the performance as the Flutter framework points out the updates and optimizes the rebuild process.

Flutter describes the idea, in its document, with the statement of UI = f(state).

https://docs.flutter.dev/data-and-backend/state-mgmt/declarative

As UI is always decided by the given state, at least in theory, state management is the most important but bothersome topic in the Flutter development.

In this article, I'll describe how I understand this complicated topic so that you can meet another idea to reveal how state management works and how we manage our states in Flutter.

What is state?

Once we take a closer look at our practical source code, we can find that the values for build() method come in various ways. So let’s start our discussion by clarifying them.

Typically, they are listed as follows:

  • values managed within the widget

  • values managed outside of the widget

  • values given as an argument of the widget

Let's take a look at each case one by one with the example source code.

values managed within the widget

This is the value that is managed within the widget, typically within State object of StatefulWidget.

class MyWidget extends StatefulWidget {
  const MyWidget({super.key});

  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  /// value managed within the widget
  int _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Text('$_counter');
  }
}

This is the simplest case, as most tutorials start by introducing this pattern as state management.

The value, _counter in the example above, is managed within _MyWidgetState class and other classes can't read or change it. This is completely managed and used locally, called ephemeral state according to the document below.

https://docs.flutter.dev/data-and-backend/state-mgmt/ephemeral-vs-app

value managed outside of the widget

The second case is the value that is managed outside of the widget, called app state in the previous document.

Though Flutter officially provides InheritedWidget to manage app state, we have a lot more options to manage it, such as Provider, Riverpod, Bloc, Signals and more.

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    // retrieve app state from InheritedWidget
    final theme = MediaQuery.orientationOf(context);

    // retrieve another state from Riverpod's provider.
    final account = ref.watch(accountProvider);

    return SomeWidget( ... );
  }
}

Instead of preserved inside the widget itself, MyApp in the case above, they are managed outside of the widget with the manner of each approach, regardless of whether they are on a widget tree or not.

value given as an argument of the widget

Let me introduce another case which tends to be ignored, an argument of the widget.

build() method doesn't use only the values managed as ephemeral state or app state but also use the value given as an argument of the widget.

class MyWidget extends StatelessWidget {
  const MyWidget({
    super.key, 
    required this.value,
  });

  final int value;

  @override
  Widget build(BuildContext context) {
    return Text('$value');
  }
}

I'm not sure if this is called state in general, but as long as this also decides the appearance of the widget, it should be considered, I would say.

How rebuild is triggered

Program is not magical. Once the value is changed, we must trigger another operation to cause rebuild because the Flutter framework don’t detect the change automatically.

Though the operations vary between the state management approach, the most common way to trigger the rebuild is to call setState() method of StatefulWidget.

onPressed: () {
  setState(() {
    _counter += 1;
  });
}

So, let's start by diving into the internal implementation of setState() to reveal how the rebuild is triggered under the hood.

Here is the outline of setState() method's implementation. Note that I just omit lines which we are not interested in, such as assertions, for simplicity.

@protected
void setState(VoidCallback fn) {
  final Object? result = fn() as dynamic;
  _element!.markNeedsBuild();
}

That's so simple, isn't it? This only calls the given function and markNeedsBuild() method of Element.

Element is an object that manages the relationship between ancestor/descendant widgets as well as concrete behavior on rebuilds, whereas widgets only preserve configuration as an immutable object.

See my other article below for more details about Element.

https://chooyan.hashnode.dev/all-i-know-about-buildcontext

So, we will dive deeper into what markNeedsBuild() method does.

void markNeedsBuild() {
  _dirty = true;
  owner!.scheduleBuildFor(this);
}

This is also simple. It sets true to its _dirty field and calls scheduleBuildFor() method of BuildOwner passing itself to its argument.

This says markNeedsBuild() doesn't cause a rebuild immediately but just marks the Element as "dirty" and asks the BuildOwner to rebuild the Element later.

What is BuildOwner?

BuildOwner is an object that manages what Elements should be rebuilt in the next frame.

This object has a continuously-called _flushDirtyElements() method, which rebuilds Elements registered in its _dirtyElements field.

void _flushDirtyElements({ required Element debugBuildRoot }) {
  // sort dirty Elements for efficient rebuild
  _dirtyElements.sort(Element._sort);

  try {
    // rebuild dirty Elements one by one
    for (int index = 0; index < _dirtyElements.length; index = _dirtyElementIndexAfter(index)) {
      final Element element = _dirtyElements[index];
      if (identical(element.buildScope, this)) {
        _tryRebuild(element);
      }
    }
  } finally {
    // mark the Elements as not dirty
    for (final Element element in _dirtyElements) {
      if (identical(element.buildScope, this)) {
        element._inDirtyList = false;
      }
    }
    _dirtyElements.clear();
  }
}

As previous scheduleBuildFor(), or internally called _scheduleBuildFor(), adds the given Element to its _dirtyElements field and schedule the rebuild, they will be rebuilt in the next chance of _flushDirtyElements() called.

void _scheduleBuildFor(Element element) {
  // add itself to the dirty list
  if (!element._inDirtyList) {
    _dirtyElements.add(element);
    element._inDirtyList = true;
  }
  // schedule rebuild 
  if (!_buildScheduled && !_building) {
    _buildScheduled = true;
    scheduleRebuild?.call();
  }
}

That is the brief process of how the rebuild is triggered.

In short, calling markNeedsBuild() method is the key to triggering the rebuild. It marks the Element as "dirty" and the dirty Elements will be rebuilt in the next frame by BuildOwner.

In other words, one of the goals of any state management approach is to call markNeedsBuild() method at proper timing, meaning immediately after the state is changed. That is why “global variables” is not a good strategy to manage app state because the simple global variables never call markNeedsBuild() of relevant Elements when they are updated.

var count = 0;

...

onPressed: () {
  // this doesn't cause rebuild
  count += 1;

  // this cause rebuild but only rebuild this widget.
  // this does not rebuild other widgets that depend on [count].
  setState(() {
    count += 1;
  });
}

Now, we can say that we will obtain deeper understanding by taking a look at various state management approaches focusing on how and when they call markNeedsBuild, so let's move on to the next disucussion of the case studies.

Case studies

Though the title of this article is about "state management", I'm NOT going to discuss how each approach preserves or updates the state or what features it provides to us. I'd rather focus on how they call markNeedsBuild() method when the state is updated in the rest of this article.

StatefulWidget

As we have seen in the previous section, setState() method just marks the associated Element as "dirty" to be rebuilt in the next frame.

One interesting fact is that calling setState() method multiple times in a single operation doesn't mean the Element will be rebuilt multiple times because the actual rebuild will happen in the next frame regardless of how many times setState() is called.

// this will cause only one rebuild
setState(() {
  _counter++;
});

setState(() {
  _counter++;
});

You don't need to refactor your source code to avoid calling setState() multiple times in a single operation. It is way more important not to forget to call setState() method when you update values.

InheritedWidget

First, InheritedWidget, or its associated InheritedElement, remembers the Elements which have referred to it with context.dependOnInheritedWidgetOfExactType() method.

@override
T? dependOnInheritedWidgetOfExactType<T extends InheritedWidget>({Object? aspect}) {
  final InheritedElement? ancestor = _inheritedElements == null ? null : _inheritedElements![T];
  if (ancestor != null) {
    return dependOnInheritedElement(ancestor, aspect: aspect) as T;
  }
  return null;
}

It internally calls dependOnInheritedElement() method to remember the dependents like below.

@override
InheritedWidget dependOnInheritedElement(InheritedElement ancestor, { Object? aspect }) {
  _dependencies ??= HashSet<InheritedElement>();
  _dependencies!.add(ancestor);
  ancestor.updateDependencies(this, aspect);
  return ancestor.widget as InheritedWidget;
}

Then, once the InheritedWidget is updated, which results in InheritedElement decides to rebuild its dependents, it calls notifyClients() method and internal notifyDependent() passing each dependent to its argument.

  @override
  void notifyClients(InheritedWidget oldWidget) {
    // recursively find the Elements which have referred to it 
    _recurseChildren(this, (Element element) {
      if (element.doesDependOnInheritedElement(this)) {
        notifyDependent(oldWidget, element);
      }
    });
  }

notifyDependent() is so simple. It just calls didChangeDependencies() method of the given Element and didChangeDependencies() method calls well-known markNeedsBuild() method internally.

@protected
void notifyDependent(covariant InheritedWidget oldWidget, Element dependent) {
  dependent.didChangeDependencies();
}
@mustCallSuper
void didChangeDependencies() {
  markNeedsBuild();
}

We now see that regardless of the approaches, rebuilds are triggered when markNeedsBuild() method is called.

ValueListenableBuilder

How about ValueListenableBuilder?

This is a widget that "watches" a given ValueListenable, which is usually ChangeNotifier or ValueNotifier, and cause rebuild when the preserved value is changed.

While we have seen the unfamiliar source code of Element so far, ValueListenableBuilder is simple, which just works in Widget's manner.

As we see below, ValueListenableBuilder is a simple StatefulWidget and what it does is just to call setState() method when the value is changed.

class _ValueListenableBuilderState<T> extends State<ValueListenableBuilder<T>> {
  @override
  void initState() {
    super.initState();
    value = widget.valueListenable.value;

    // add callback to the given ValueListenable
    widget.valueListenable.addListener(_valueChanged);
  }

  void _valueChanged() {
    // call setState() when it is called
    setState(() { value = widget.valueListenable.value; });
  }
}

We can say that some approaches are not as difficult as we expected. They only wrap the basic operations, such as setState() method or InheritedWidget.

flutter_hooks

So how about state management packages? Let's start with flutter_hooks.

https://pub.dev/packages/flutter_hooks

flutter_hooks provides useState() method, which we can use in build() method instead of StatefulWidget.

class MyWidget extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final counter = useState(0);
    return Text('${counter.value}');
  }
}

Once we update the value, like with counter.value = 10, MyWidget is rebuilt. But how does this work?

In fact, this is as simple as ValueListenableBuilder.

useState() internally creates _StateHook and its associated _StateHookState, which does a similar operation to ValueListenableBuilder like below.

class _StateHookState<T> extends HookState<ValueNotifier<T>, _StateHook<T>> {
  late final _state = ValueNotifier<T>(hook.initialData)
    // add callback to the created ValueNotifier
    ..addListener(_listener);

  void _listener() {
    // call setState() when it is called
    setState(() {});
  }
}

It just creates ValueNotifier at first, and registers the callback of _listener which calls setState() inside.

However, be careful to one thing that this setState() is HookState's. It has the same name as State's setState() but is a different method. So let's move on to see its implementation.

@protected
void setState(VoidCallback fn) {
  fn();
  _element!
    .._isOptionalRebuild = false
    ..markNeedsBuild();
}

Finally, as seen above, we have found that flutter_hooks also calls markNeedsBuild() method to trigger rebuilds.

signals

https://pub.dev/packages/signals

Though signals provides multiple approaches to creating and watching signals, I'll pick one of them in this article, which is watch() extension method.

The code below is an example introduced in the official document.


// make signals globally independent from Flutter framework
final counter = signal(0);

class MyWidget extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return Column(
        children: [
        // use the signals with .watch(context)
          Text('Counter: ${counter.watch(context)}'),
          ElevatedButton(
          // update the value and cause rebuild
            onPressed: () => counter.value++,
            child: Text('Increment'),
          ),
        ],
    );
  }
}

Even though the widget is StatelessWidget and signal(0) is independent from the Flutter framework, counter.watch(context) enables the widget to rebuild when the value is changed. How does this work?

First, watch() calls watchSignal() method internally.

T watch(
  BuildContext context, {
  String? debugLabel,
}) {
  return watchSignal<T>(
    context,
    this,
    debugLabel: debugLabel,
  );
}

Then, watchSignal() creates ElementWatcher if the given context is associated with StatelessWidget.

T watchSignal<T>(
  BuildContext context,
  core.ReadonlySignal<T> signal, {
  String? debugLabel,
}) {
  final ctx = context;
  ...

  // if ctx.widget is StatefulWidget, this block is executed
  if (ctx is Element) {
    final key = ctx.hashCode;
    if (_elementRefs[key] == null) {
      final watcher = ElementWatcher(
        key,
        label,
        WeakReference(ctx),
      );
      _elementRefs[key] = watcher;
      ...

In the ElementWatcher, rebuild() below is called once the signal notifies the change.

void rebuild() async {
  final target = element.target;
  target.markNeedsBuild();
}

So, it’s clear now that signals also calls markNeedsBuild of watching Element. This example also shows that calling markNeedsBuild() is the goal to trigger rebuilds for state management packages as well.

Riverpod

The last example is Riverpod, especially flutter_riverpod.

https://pub.dev/packages/flutter_riverpod

flutter_riverpod provides ConsumerWidget, which provides WidgetRef as an argument of build() method. We can watch providers with calling ref.watch() method and it lets the widget be rebuilt when the watching provider's state is changed.

class MyWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // retrieve value from a provider
    final counter = ref.watch(counterProvider);
    return Text('$counter');
  }
}

We can check the implementation of .watch() method in StatefulConsumerElement class like below.

@override
Res watch<Res>(ProviderListenable<Res> target) {
  return _dependencies.putIfAbsent(target, () {
    final oldDependency = _oldDependencies?.remove(target);

    if (oldDependency != null) {
      return oldDependency;
    }

    return _container.listen<Res>(
      target,
      // markNeedsBuild() is called when the provider's state is changed
      (_, __) => markNeedsBuild(),
    );
  }).read() as Res;
}

Despite the complexity of the core implementation of riverpod, such as Provider and related classes, the mechanism to trigger rebuilds is that simple.

.watch() just listens the given Provider internally and calls markNeedsBuild() method when the state is changed. That’s it!


So far, we have seen a lot of examples that cause rebuilds, and we now know that every approach calls markNeedsBuild() method or setState() method, which internally calls markNeedsBuild() method in the end.

They show that as long as we can refer to the object of the target Element, or BuildContext, we can trigger rebuilds by calling markNeedsBuild().

Here is the interesting demo that shows even a simple StatelessWidget can trigger rebuilds.

https://x.com/tsuyoshi_chujo/status/1879542682273665234

Wrap up

So far, we have seen

  • how the Flutter framework builds UI

  • how the rebuilds are triggered

  • how each state management approach achieves markNeedsBuild()

Honestly speaking, this knowledge never enhances our source code dramatically because every approach is well designed to hide the complexity and we can just write our source codes following their Readme documents.

However, this knowledge would boost our understanding of how we properly use them and fix bugs we encounter.

Also, I hope this article shows we can "read" the internal implementation of the Flutter framework and state management packages. If you're now interested in reading the internal source code, it's the very time to obtain more advanced knowledge and insights.