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.