Why We Never Manage State with Global Variable in Flutter

Tsuyoshi Chujo
7 min readAug 18, 2023

--

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.

Video showing our counter app is working nicely.

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.

Video showing two pages sharing the counter but the first page isn’t updated

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 setStateinside.

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.

--

--

Tsuyoshi Chujo

Freelance mobile app developer in Japan. Developing apps with Flutter and also a Flutter package named crop_your_image.