Flutter State Management: Beginner Basics — Counter Example

Jackie Moraa
14 min readDec 17, 2023

It seems like every other waking day there is a debate about Flutter and the different state management approaches. People have very strong opinions on these approaches and even the simplest conversation turns into an argument very quickly.

In my opinion, it’s okay to have a preference. But that doesn’t mean that every other approach outside of this preference is necessarily wrong — different strokes, for different folks. There are approaches that are definitely more complex than others, right? But if this is the hill someone chooses to die on, they should be allowed to do so freely.

So in this article, we will look at the different state management approaches in Flutter. But before we actually dive into the state management approaches, let’s first understand what state management is and why it’s a necessary evil.

What is state?

State refers to the mutable data that determines how a Flutter widget behaves and how it is displayed on the screen.

Flutter is declarative. This means that Flutter builds its user interface to reflect the current state of your app — docs.flutter.dev

So state can be described as the data needed in order to rebuild the UI at whichever given point in time. It controls the behavior and appearance of widgets. This data can change at any moment, and how it changes will also determine how the UI is rebuilt. Handling these changes efficiently is vital for creating responsive and interactive user interfaces in Flutter.

Types of state

There are two types of state: ephemeral state and application state.

Ephemeral state: This is “local” state. This is the state that affects only one widget and is therefore confined to it. There is no need to use the various complex state management approaches for this local state. A stateful widget and setState can handle this perfectly. An example of ephemeral state is the current page in a PageView.

return PageView.builder(
itemCount: widget.imagesUrl.length,
onPageChanged: (value) {
setState(() => currentIndex = value);
},
...
},
);

Application state: This is “shared” state. It represents the overall state of the entire application as it involves data that needs to be shared and accessed across various widgets within the application. Unlike ephemeral state, app state is global and typically stored at a higher level so that different parts of the application can read and modify this shared state. An example of ephemeral state is authentication status, notifications, inbox items etc.

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

Why state management?

The choice between ephemeral and app state depends on the kind of application being developed. Simple apps can get away with ephemeral state. But as you start building complex applications in Flutter, maintaining app state becomes inevitable and thus, managing this state becomes a crucial aspect of the development process.

What are the various state management techniques?

A number of techniques exist for managing state in Flutter and I may not even touch on all of them below. But, if you’re a beginner looking for where start, hopefully this article will provide a bit of direction. You can also read more on each from the resources I have referenced and linked at the very end.

1) setState()
The setState method is Flutter’s built-in, off-the-shelf technique to update the state of a widget. When it is called, it triggers the rebuild of the widget tree and updates the UI. It is a low-level state management approach for managing ephemeral state (as we mentioned above).

Therefore, for simple and straightforward apps, setState would be very effective for managing state. However, as the complexity of the app increases, it’ll be difficult to track and update the state shared across multiple screens and/or widgets.

The Flutter starter app is a great example the setState method in action. When the _incrementCounter method is called, it calls the setState method which notifies the Flutter framework that there has been a change in the state. This then causes the re-run of the build method so that the display can reflect the updated values.

class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;

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

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}

The Flutter framework has been optimised to make re-running build methods fast but overusing setState in the app can cause performance issues as we discussed in this article.

Pros:

  • It is a simple and straightforward way to manage state. The learning curve is not steep
  • It is ideal for small and simple applications. There is no need for complicated state management techniques for simple apps with few screens
  • It is a built-in mechanism thus no need to add any additional packages.

Cons:

  • Frequent use of setState can cause performance issues.
  • For larger and/or more complex applications, setState will not be sufficient as data might need to be passed & maintained across various screens.

2) InheritedWidget
The InheritedWidget is a low-level but powerful built-in Flutter class that can be used to manage the state across the widget tree. It allows you to define a piece of data at the top of the widget tree and access it from any descendant widget that needs it. The InheritedWidget is actually what some other state management packages e.g., Provider (see technique #3) use.

Back to our counter app. Let us now see how it will look with the InheritedWidget instead.

class CounterInheritedWidget extends InheritedWidget {
final int counter;
final _UpdateCounterState updateCounterState;

const CounterInheritedWidget({
Key? key,
required Widget child,
required this.counter,
required this.updateCounterState,
}) : super(key: key, child: child);

static _UpdateCounterState? of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<CounterInheritedWidget>()?.updateCounterState;
}

@override
bool updateShouldNotify(CounterInheritedWidget oldWidget) => oldWidget.counter != counter;
}

We first start by creating our InheritedWidget, CounterInheritedWidget which is responsible for holding the value of the counter which all other widgets can have access to. Since inherited widgets are immutable, the state of the counter will be updated from another stateful widget which is being referenced to as _UpdateCounterState while the InheritedWidget itself stays unchanged for the whole lifecycle of the app.

The of method allows widgets to access the updateCounterState object from the context, the dependOnInheritedWidgetOfExactType<>() method allows descendant widgets to access the state of a specific inherited widget, and the updateShouldNotify method ensures that the widget tree rebuilds only when the counter value changes (return value is true). (Ps. you have probably interacted with inherited widgets before with Flutter and Dart e.g., Theme.of(context).textTheme.headlineMedium in text styles. Note the use of the of method).

class UpdateCounterState extends StatefulWidget {
final Widget child;
const UpdateCounterState({required this.child, super.key});

@override
State<UpdateCounterState> createState() => _UpdateCounterState();
}

class _UpdateCounterState extends State<UpdateCounterState> {
int counter = 0;

void incrementCounter() {
setState(() {
counter++;
});
}

@override
Widget build(BuildContext context) {
return CounterInheritedWidget(
counter: counter,
updateCounterState: this,
child: widget.child,
);
}
}

The UpdateCounterState is the stateful widget that works in conjunction with the InheritedWidget to manage and update the mutable state of the counter. It contains the method that increments the counter value and rebuilds the widget tree. The child is wrapped with the CounterInheritedWidget which will store the current value of the counter.

class MyApp extends StatelessWidget {
const MyApp({super.key});
final String title = 'Inherited Widget';
@override
Widget build(BuildContext context) {
return UpdateCounterState(
child: MaterialApp(
debugShowCheckedModeBanner: false,
title: title,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: MyHomePage(title: title),
),
);
}
}

MyApp is the main application widget. It wraps the MaterialApp widget with the UpdateCounterState widget to allow all widgets below the UpdateCounterState widget to access the counter state.

class MyHomePage extends StatefulWidget {
final String title;
const MyHomePage({required this.title, super.key});

@override
State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
final counter = CounterInheritedWidget.of(context)?.counter;
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}

void incrementCounter() {
final increment = CounterInheritedWidget.of(context);
increment?.incrementCounter();
}
}

This is the home page for the application. It is a stateful widget. It displays the current counter value and provides a button to increment the counter. It uses the CounterInheritedWidget.of method to access the counter value and the incrementCounter method from the other classes. The full code is linked here.

Pros:

  • As mentioned above, the InheritedWidget is used by other state management packages e.g., Provider. So understanding it would make it easier to understand these other techniques.
  • It avoids the need to manually pass down data through the descendants, thus efficient and clean.
  • Simple to use compared to other state management solutions.

Cons:

  • It can be challenging to manage state, especially when dealing with complex or deeply nested widget trees.
  • It causes a rebuild of the entire widget tree. It is therefore limited when it comes to updating or modifying state without this which also causes performance overheads.

3) Provider
Provider is one of the popular Flutter package for managing state. It is built on top of the InheritedWidget and it leverages on it together with the ChangeNotifier to simplify state management. It uses the concept of “providers”.

Key concepts with Provider:

  1. Provider: This is a widget that encapsulates the state data and exposes it to its descendants when they need it.
  2. Consumer: This is a widget that listens to changes in the provider and rebuilds itself when the state changes.
  3. ChangeNotifier: This is a class that allows you to notify listeners when the state changes.

Let’s modify our counter app to use Provider.

Start by adding the package to your pubspec.yaml file flutter pub add provider and once done, import it to your dart file.

class CounterProvider with ChangeNotifier {
int _counter = 0; // holds the actual value of the counter

int get counter => _counter;

void increment() {
_counter++;
notifyListeners();
}
}

Then, create a provider class that inherits from the ChangeNotifier class as shown above. The ChangeNotifier provides the function for notifying listeners whenever the state of the counter changes. Within the class, we have a getter that allows other parts of the code to access the current value of the counter. The increment method is responsible for incrementing the _counter variable by 1 and notifying all listeners that the state has changed. This will thus trigger a rebuild of any widget that is listening for changes in the CounterProvider.

void main() {
runApp(ChangeNotifierProvider(
create: (context) => CounterProvider(),
child: const MyApp(),
));
}

The main function is the entry point of the application and it uses the ChangeNotifierProvider to provide the CounterProvider and make it accessible to all descendant widgets in the widget tree. MyApp is a simple stateless widget which leads to the HomePage class.

  Widget build(BuildContext context) {
final counterProvider = context.watch<CounterProvider>();

return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'${counterProvider.counter}',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: counterProvider.increment,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}

In HomePage class uses context.watch<CounterProvider>() to access the CounterProvider. Display the current counter value from the CounterProvider counterProvider.counter. The FAB also calls the increment method from the CounterProvider and updates the counter.

Pros:

  • Provider is a straightforward and scalable state management technique when compared to other packages.
  • Compared to the InheritedWidget, it simpler and more flexible (Note the fewer LOC than what we have for the InheritedWidget).
  • It offers better performance compared to InheritedWidget and setState.
  • Using Provider, the state of your application will be visible in the Flutter devtool — for easy monitoring & debugging

Cons:

  • For more complex applications, the provider state management technique is still a bit limited.

4) RiverPod
Riverpod is another popular state management library in Flutter. It is built on top of the Provider package discussed above and works in a similar way to Provider but with a few improvements. (Fun fact: Riverpod is an anagram of Provider — I too just realised this as I was doing research for this article. Am I the only one?)

Key features with Riverpod:

  1. Declarative & composable: Riverpod provides a declarative way to define the logic making code more readable and maintainable.
  2. Common UI patterns: With Riverpod, complex UI patterns e.g., “pull to refresh” can be achieved using very few lines of code.
  3. Compile safety: Detects most programming errors at compile time, hence reducing chances of these errors at run time.
  4. Lint rules: Riverpod also provides custom lint rules from the optional riverpod_lint package to help with writing better code. It also provides custom refactoring options. This is the list of riverpod lint rules.

Now we proceed to modify our counter app to use Riverpod.

Start by adding the package to your pubspec.yaml file with this command flutter pub add flutter_riverpod and once done, import it to your dart file. Create a global counterProvider which is a provider that manages the counter state and initialise it to 0.

final counterProvider = StateProvider<int>((ref) => 0);

Instead of using Provider we use StateProvider from Riverpod. Whatever the StateProvider returns will be reactive e.g., the integer value in our counter app.

void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}

In the main function, wrap the MyApp widget with ProviderScope. This ensures that all widgets down the tree have access to the provider.

class MyHomePage extends ConsumerWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);

final String title;

@override
Widget build(BuildContext context, WidgetRef ref) {
final counter = ref.watch(counterProvider);

return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => ref.read(counterProvider.notifier).state++,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}

Instead of extending a stateless widget, we’ll use a ConsumerWidget which allows us to access the StateProvider values inside the class. Get access to our counter value using ref.watch(counterProvider) which we display in the text widget. ref.read(counterProvider.notifier).state++ increments the counter value using the provider’s state property.

Pros:

We’ve covered most of the pros of Riverpod while discussing the key features above.

Cons:

  • There’s a bit of a learning curve when getting started with Riverpod (especially if you haven’t worked with its predecessor — provider).

5) BLoC
BLoC — Business Logic Component, is a pattern which aims to separate the UI layer or components from the business logic. By creating a BLoC class which handles the state and provides the stream of data to the other widgets, this pattern aims to promote code readability, reusability and testability. This is especially useful when dealing with large and complex applications which handle multiple processes.

Key concepts with BLoC:

  1. BLoC: This is a class that holds the business logic and the state. It emits the states to update the UI based on events from the user.
  2. Event: An action triggered from the UI e.g., button press
  3. State: The current state of the application data

Onto modifying our counter app with BLoC.

Just like the previous packages, start by adding the package to your pubspec.yaml file with this command flutter pub add flutter_bloc and import it to your dart file.

class CounterBloc extends Bloc<CounterEvent, CounterState> {
CounterBloc() : super(InitialState()) {
on<IncrementCounterEvent>((event, emit) {
emit(CounterState(count: state.count + 1));
});
}
}

class CounterEvent {}

class IncrementCounterEvent extends CounterEvent {}

class CounterState {
final int count;
const CounterState({required this.count});
}

class InitialState extends CounterState {
InitialState() : super(count: 0);
}

We create a CounterBloc class that extends Bloc<CounterEvent, CounterState>. This will manage the CounterEvent and produce states CounterState. This class has on<IncrementCounterEvent> method which handles the IncrementCounterEvent and update the state accordingly whenever the event occurs. CounterEvent is an abstract class that serves as the base class for the events in this BLoC, one of them being the IncrementCounterEvent. You can define other events as needed in an application. The CounterState represents the current state of the counter. It contains a single property, count, which holds the current count value. InitialState is a subclass of CounterState which represents the initial state of the counter with a count of 0.

  Widget build(BuildContext context) {
return BlocProvider(
create: (_) => CounterBloc(),
child: MaterialApp(
...
),
home: MyHomePage(title: title),
),
);
}

Wrap the child of MyApp (the top level widget) using BlocProvider and set up the CounterBloc we created above. This will allow all the child widgets in the tree to have access to CounterBloc

...
BlocBuilder<CounterBloc, CounterState>(
builder: (context, state) => Text(
'${state.count}',
style: Theme.of(context).textTheme.headlineMedium,
),
),

...
...

floatingActionButton: FloatingActionButton(
onPressed: () => context.read<CounterBloc>().add(IncrementCounterEvent()),
tooltip: 'Increment',
child: const Icon(Icons.add),
),

Finally, in the HomePage, we'll use BlocBuilder to listen for any changes in the state and rebuild parts of the UI with the updated count in response to changes in the CounterBloc state. The floating action button triggers the IncrementCounterEvent when pressed.

Pros:

  • There is improved code organisation and readability due to the separation of the business logic and UI elements.
  • Better testability and code maintainability.
  • For complex applications, this state management technique works quite well.

Cons:

  • Steep learning curve especially for a beginner.
  • More lines of code. There’s an increase in the boilerplate code needed which adds a level of complexity thus making it not suitable for simple/small apps.

6) GetX
GetX is not only light weight state management technique but it also combines route management and dependency injection in its offering.

The three basic GetX principles:

  1. Productivity: Has an easy syntax which saves a lot of development time.
  2. Performance: It focuses on minimal resource consumption as it doesn’t use Streams or ChangeNotifiers like other state management techniques.
  3. Organisation: It decouples the view, presentation logic, business logic, dependency injection, and navigation. It is also independent of the widget tree as it eliminates the need to access context in order to navigate between routes.

Back to our counter app. Let us modify it with GetX

As per the norm, start by adding the package to your pubspec.yaml file with this command flutter pub add flutter_bloc and import it to your dart file.

class Controller extends GetxController {
var count = 0.obs;
increment() => count++;
}

Then, create the business logic class which extends the GetxController and place any variables and methods needed for the functionality. Use .obs to make variables observable — what we’ll be keeping track of as it changes.

final Controller controller = Get.put(Controller());

In the HomePage, get an instance of the Controller class we created above using Get.put.

Obx(
() => Text(
'${controller.count}',
style: Theme.of(context).textTheme.headlineMedium,
),
),

Then using, Obx(()=>, we can be able to update the counter text whenever that count is changed. And to change the count from the FAB, we simply access the increment method from the controller we initialised. onPressed: () => controller.increment()

Pros:

  • Aside from state management, GetX also provides other functionalities such as dependency injection, route management, internationalisation (Check out this article on i18n), etc.
  • It is lightweight.

Cons:

  • GetX is dynamic in nature, thus tracking down state changes and dependencies in complex applications to debug or test can be challenging due.
  • GetX has quite a number of ‘non supporters’ in the community — it is therefore hard to get people who actively use it, resources and good documentation.
  • [Update] Randal L. Schwartz has shared with me this video which talks more about the cons of GetX. If you have time on your hands, kindly have a look at it.

State management is a part in developing functional and scalable applications. In this article, we have covered a number of these approaches and demonstrated how to implement them using the simple counter app. However, there are other state management packages that I haven’t covered in this article e.g., MobX and Redux but I have linked them for reference.

By understanding state management, the different techniques, implementation details, pros and cons, you can make informed decisions about which approach to adopt for your projects. State management is not a one size fits all kind of thing. Each developer or organisation can choose the approach to go with based on the complexity or requirement.

The link to the GitHub repository for this article can be found here (I’ll be cleaning this up a bit though). Let me know what you think about this article and any points of correction.

“State management is like the plumbing in your app. If it’s done right, you won’t even notice it. But if it’s leaky, it will ruin your user experience.”
— Bard

As always, thank you for reading! ❤

--

--