Why We Never Manage State with Global Variable in Flutter
I believe nobody manages the states of Flutter apps with global variables. There is no doubt that we need state management packages or Flutter’s fundamental widgets like InheritedWidget
or StatefulWidget
for our Flutter apps.
However, do we really understand how those packages are valuable for state management? What kind of topics do we have to consider when thinking about state management?
In this article, we will build a simple counter app intentionally using global variables for state management to explore what kind of problems would happen. This challenge would help us understand what state management packages are trying to solve.
Counter App with Integer Value of Global Variable
We are going to start with the simple counter app below.
import 'package:flutter/material.dart';
void main() => runApp(const MainApp());
var counter = 0;
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: Text(counter.toString()),
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () {
counter++;
},
),
),
);
}
}
It must be a simple counter app except the counter
being declared as a global variable.
Of course, Text
always shows 0
even when we tap the FloatingActionButton
because it only increments count
and never causes a rebuild.
Understanding the Mechanism of Rebuild
Before we go ahead to make the app work, discussing how rebuild happens is important to understand the following discussion.
We first need to understand that all widgets create their corresponding Element
in build phases. And Element
manages if corresponding widgets need to be rebuilt in the next frame with the flag named _dirty
.
As _dirty
is modified with the method markNeedsBuild
, what we need now is calling the method to rebuild our counter app when FloatingActionButton
is tapped.
setState of StatefulWidget
The handiest way to call markNeedsBuild
is calling setState
of StatefulWidget
.
State
of StatefulWidget
is also created by StatefulWidget
, like Element
is, and constructs one-to-one relations with Element
and Widget
. setState
causes a rebuild of the corresponding StatefulWidget
by calling markNeedsBuild
of the corresponding Element
like the code below.
@protected
void setState(VoidCallback fn) {
final Object? result = fn() as dynamic;
_element!.markNeedsBuild();
}
Thus, what we need is to make MainApp
a StatefulWidget
and call setState
in onPressed
of FloatingActionButton
.
onPressed: () {
setState(() => counter++);
},
Great! We made the counter app work as we expected.
You can check the diff and the entire code on the GitHub repo below.
Managing App State
Did we solve all the problems? Of course NOT.
Another problem happens when we share the state with multiple widgets.
Let’s make another screen widget named FirstPage
and extract the existing widget as SecondPage
. Both pages show the count at the center of their screen, but the FloatingActionButton
of FirstPage
is for navigating to SecondPage
while that of SecondPage
is for incrementing the counter.
You can check the diff here and a demo below.
Based on the discussion so far, the reason why FirstPage
always shows 0
is clear; markNeedsBuild
of FirstPage
is never called.
FirstPage
definitely refers to the state counter
and shows its value at first but never knows its updates. In other words, being able to refer to states is NOT enough for state management but receiving those updates is also mandatory.
ValueNotifier
and ValueListenableBuilder
Our next challenge is fixing the problem that FirstPage
can’t notice the update of counter
. To solve this, we have ValueNotifier
and ValueListenableBuilder
.
ValueNotifier
notifies the updates of preserving value
, and ValueListenableBuilder
is a widget for receiving ValueNotifier
‘s notification and calling setState
inside.
First, we update the counter
variable as a class named Counter
extending ValueNotifier
.
final counter = Counter();
class Counter extends ValueNotifier<int> {
Counter() : super(0);
void increment() => value++;
}
It also exposes the method increment
for incrementing the value
, as value
can’t be updated directly from widgets.
Next, we wrap the Text
of FirstPage
with ValueListenableBuilder
like below.
child: ValueListenableBuilder(
valueListenable: counter,
builder: (context, value, child) {
return Text(value.toString());
},
),
Finally, we could perfectly share counter
between FirstPage
and SecondPage
. Check the entire diff here and the demo below.
Combining Multiple States
So, we now solve all the problems? Still NOT, of course.
We are heading to the next problem of combining multiple states. We would like MultipliedCounter
exposing the value of twice the counter
‘s value. We now must invent a way to catch the notification of Counter
outside of widgets, which means without ValueListenableBuilder
.
Here is the trick for listening to other ValueNotifier
‘s updating.
final multipleCounter = MultipliedCounter(counter);
class MultipliedCounter extends ValueNotifier<int> {
MultipliedCounter(Counter counter) : super(0) {
counter.addListener(() {
multiply(counter.value);
});
}
void multiply(int base) => value = base * 2;
}
Because what MultipliedCounter
has to do is adding a listener to Counter
, it receives Counter
object via its constructor and calls its addListener
, which ValueNotifier
provides, passing the function calling multiply
method. This trick enables MultipleCounter
to receive Counter
‘s update and update its value with base * 2
.
Here is an example of using multipliedCounter
at FirstPage
.
Check the entire code here.
Disposing State
We still have a huge number of problems not been solved, and one is how to dispose of states.
Generally speaking, we have to dispose of states when we no longer need them, yet our counter
or multipliedCounter
are never automatically disposed of as they are defined as global variables that keep alive as long as our app is alive.
If we wish them to be disposed of when they are no longer used, we must dispose of them manually by calling some method to do so.
We have to be aware that assigning a brand-new object to counter
is totally irrelevant in this case.
// don't do this for disposing
counter = Counter();
Because multipliedCounter
or ValueListenableBuilder
already refer to the existing Counter
object, assigning a new Counter
object to counter
variable doesn’t result in automatically switching the references of them.
Then, how about providing the method initialize
( dispose
is already defined in ValueNotifier
) and calling this at the relevant timing, like in dispose
of _SecondPageState
?
class Counter extends ValueNotifier<int> {
Counter() : super(0);
void increment() => value++;
void initialize() => value = 0; // provide a method to initialize
}
@override
void dispose() {
counter.initialize();
super.dispose();
}
But wait, is it safe to call initialize
here? We have to remember that multipliedCounter
is also listening to counter
and _SecondPageState
never knows that fact and vise-versa. In other words, nobody knows “ counter
is no longer used by anyone”.
So, what we have to do first is to build the mechanism of managing who are listening to counter
, but how?
It’s a quite difficult problem and we don’t answer it in this article.
Testing
The last, but not the only, problem left is testing.
When we think of testing SecondPage
with a widget test, we soon find it doesn’t work.
void main() {
testWidgets('SecondPage shows 0 first and 1 after tapping button', (widgetTester) async {
await widgetTester.pumpWidget(const MaterialApp(home: SecondPage()));
expect(find.text('0'), findsOneWidget);
final incrementButton = find.byIcon(Icons.add);
await widgetTester.tap(incrementButton);
await widgetTester.pumpAndSettle();
expect(find.text('1'), findsOneWidget);
});
}
Looking at this simple widget test, it seems to pass all the tests, and it does actually.
$ flutter test test/second_page_test.dart
00:00 +0: loading /path/to/global_variable_counter/test/second_page_test.dart
00:01 +0: SecondPage
00:01 +1: SecondPage
00:01 +1: All tests passed!
However, once we add another testWidgets
here like below,
void main() {
testWidgets('SecondPage shows 0 first and 1 after tapping button',
(widgetTester) async {
await widgetTester.pumpWidget(const MaterialApp(home: SecondPage()));
expect(find.text('0'), findsOneWidget);
final incrementButton = find.byIcon(Icons.add);
await widgetTester.tap(incrementButton);
await widgetTester.pumpAndSettle();
expect(find.text('1'), findsOneWidget);
});
testWidgets('SecondPage shows 0 first and 1 after tapping button',
(widgetTester) async {
await widgetTester.pumpWidget(const MaterialApp(home: SecondPage()));
expect(find.text('0'), findsOneWidget);
final incrementButton = find.byIcon(Icons.add);
await widgetTester.tap(incrementButton);
await widgetTester.pumpAndSettle();
expect(find.text('1'), findsOneWidget);
});
}
The test shows the failure below.
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following TestFailure was thrown running a test:
Expected: exactly one matching node in the widget tree
Actual: _TextFinder:<zero widgets with text "0" (ignoring offstage widgets)>
Which: means none were found but one was expected
When the exception was thrown, this was the stack:
...
This is because global variables keep alive in main()
and the same object is shared in each test. This means we need to initialize all the states before each testWidget
.
Conclusion
So far, we have tried to manage to build a counter app using global variables as its state management strategy, we found a lot of problems about initializing, updating, watching, and disposing of the states. In addition, testing widgets comes challenging because of the global variables.
I don’t believe people want to tackle solving these problems one by one. It’s the business of state management packages such as Riverpod, BLoC, GetX, etc.
As those packages provide solutions for the issues discussed in this article, we don’t need to waste our time struggling with them. What we have to do is understand what kind of problems with state management exist out there and how we can solve them properly using the packages.
Thank you for reading this article, and I’d like to write another article about how each state management package deals with the problems so that we can find the best state management package for each project and its usage.