Components of Flutter State Management
State management is a critical aspect of building responsive Flutter apps. We often talk about “State Management Solutions” in Flutter, but we rarely break down the components of state management into their constituent parts. Understanding the different components of state management in Flutter can help you understand why you might choose one state management solution over another and identify the simplest ways to do state management. Breaking these components down shows how all state management solutions are built on the same foundation. They are all variants of one underlying principle. This article discusses the components and gives examples of how they work together.
The Components
There are four primary components of state management in Flutter: Builder, Controller, State, and Dependency Manager. Each of these components plays a unique role in managing the state of your application.
Controller
The controller manages the application’s state and business logic. It notifies the Builder when the state changes. There are two fundamental types of controllers in the core Flutter API: ChangeNotifier
and ValueNotifier
. They both implement the Listenable
interface, which allows them to notify the Builder when the state changes.
-
ChangeNotifier
is a class that extends theListenable
class and provides thenotifyListeners()
method to notify the builders when the state changes. You may maintain the state in the controller by adding properties to the class. TheChangeNotifier
class is a good choice when you need to maintain the mutable state in the controller. -
ValueNotifier
also extends theListenable
class and provides thevalue
property that holds the current state value. When the value changes, theValueNotifier
notifies the listeners. TheValueNotifier
class is a good choice when you need to maintain the immutable state in the controller.
Cubit
and Bloc
are both controllers that exist in the flutter_bloc library. They both require you to work with immutable state. Finally, StateNotifier is another controller that requires immutable state.
When we say “state management solution” we are often referring to the controller, but the overall solution may include the other components.
Builder
A Builder is a widget that listens to state changes and calls setState when a change occurs. This tells Flutter to rebuild the widget tree. Flutter has three popular builders in the toolkit: StreamBuilder, ValueListenableBuilder, and AnimatedBuilder.
-
We use
StreamBuilder
with streams, a sequence of asynchronous events. It listens to the stream and rebuilds the widget when new data is available. -
We use
AnimatedBuilder
with anyListenable
controller. AnimatedBuilder listens to changes and rebuilds the widget when the Listenable notifies the builder of a change. The original design was for animations only, but this widget is perfect for other kinds of state management. -
ValueListenableBuilder
is very similar toAnimatedBuilder
, but it takes a ValueListenable as a controller. A good example isValueNotifier
.
Another notable builder outside the core framework is BlocBuilder in the flutter_bloc library. This builder is similar to the earlier builders but only works with a controller that implements the StateStreamable class.
State
State is the data the app holds in memory that the UI reflects. The controller typically manages this. The State can be anything, from a simple boolean value to a complex data structure. The State can exist in the controller class, or a separate class can hold the state. The State can be mutable or immutable. Immutability is not simple in Dart, and you should carefully consider the choice between mutable and immutable. For a more in-depth discussion of immutable state in Dart, see this article.
Dependency Manager
A dependency manager is a component that mints or holds onto the controller and its dependencies. It can hold onto the controller as a singleton, an object that is only instantiated once and shared across the entire application. It can also propagate the controller and its dependencies throughout the widget tree or expose them globally. In Flutter, there are several dependency managers, including ioc_container, provider, and get_it, but none of these belong to the core Flutter API.
Importantly, dependency managers allow you to substitute dependencies with test doubles like Mockito. This is a critical feature for testing your application.
-
ioc_container
is a dependency manager that uses Inversion of Control principles to manage dependencies. It provides a container that manages the dependencies and allows for dependency injection. You can exposeioc_container
throughout the widget tree as anInheritedWidget
with flutter_ioc_container. -
provider
is a dependency manager that primarily usesInheritedWidget
to provide objects to its children. It allows for a hierarchical relationship between the parent and child widgets. -
get_it
is a dependency manager that uses the service locator pattern to manage dependencies. It provides a global singleton instance that can be used across the entire application.
Core Flutter Building Blocks
Flutter gives us a comprehensive set of tools to build our applications. The core building blocks are the widgets, controllers, and builders. Where possible, it is always best to use the building blocks that come with the toolkit. This is because the core building blocks are well-tested, performance-optimized and designed to work together. For example, the ValueListenableBuilder
is designed to work with the ValueNotifier
controller. If we depend on core classes like Listenable
or Stream<>
we maximize the chance that components will work together.
Lastly, but most importantly, adding large 3rd party packages, especially those that depend on further packages, makes your app more opaque and harder to maintain. A rule of thumb that I follow is that I should be able to look at the code in the external package and understand what it is doing. The code should be small enough that I would have a hope of fixing it if I found a bug. You can’t depend on 3rd party libraries to work the way you expect them to work. The author may make future decisions that are not in line with your expectations. That’s why packages that only perform one simple function, and limit themselves to being one of the components I mentioned here are safer to use.
So, use 3rd party packages where necessary to extend the basic building blocks, but check that they do the bare minimum and don’t add too much complexity. When a core Flutter building block does the job, use it.
Bringing It Together
To summarise, we have the following components:
- State
- Builder
- Dependency Manager
- Controller
In this example, I use ChangeNotifier
as the controller for state management. It holds the mutable state.
I use ioc_container
as the dependency manager because neither Dart nor Flutter have a built-in dependency injection framework.
Finally, I use ListenableFutureBuilder
as the builder. It has three key features:
- It holds onto the counter controller in the widget tree for you,
- It allows you to do async work before the controller initializes and
- It comes with an
InheritedWidget
to propagate theListenable
to descendant widgets.
You can use a core Flutter builder, but this means you also need to manage the controller as state yourself.
Add these packages with these commands
flutter pub add ioc_container
flutter pub add listenable_future_builder
This is a simple counter example that demonstrates how all the components interact with each other. This example has no StatefulWidget
s and all of the business logic exists in the controller. The builder takes care of the widget rebuilds and the dependency manager takes care of creating the controller. You can read about ListenableFutureBuilder
here.
import 'package:flutter/material.dart';
import 'package:ioc_container/ioc_container.dart';
import 'package:listenable_future_builder/listenable_future_builder.dart';
import 'package:listenable_future_builder/listenable_propagator.dart';
///🎮 Controller
///This class handles business logic and notifies listeners when the state changes.
///In this example, the controller holds the state, which is the counter.
///The state is mutable
class CounterController extends ChangeNotifier {
CounterController(this.apiService);
final ApiService apiService;
///📀 State - this is the mutable data that changes over time
///and displays as part of the UI
int _counter = 0;
int get counter => _counter;
void increment() {
_counter++;
notifyListeners();
}
}
///An example dependency
class ApiService {}
///🏗️ Dependency Manager
///This is the glue that holds everything together.
///It is responsible for creating the controller and allows us to replace the
///dependencies with mocks for testing.
IocContainerBuilder compose() => IocContainerBuilder()
..addSingleton((container) => ApiService())
..addAsync((container) async => Future<CounterController>.delayed(
const Duration(seconds: 2),
() => CounterController(container<ApiService>())));
void main() => runApp(MyApp(container: compose().toContainer()));
class MyApp extends StatelessWidget {
const MyApp({super.key, required this.container});
final IocContainer container;
@override
Widget build(BuildContext context) => MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.brown,
useMaterial3: true,
),
//👷🏽 Builder
//The ListenableFutureBuilder https://pub.dev/packages/listenable_future_builder
//This builder has two key features:
//1) It holds onto the counter controller in the widget tree for you
//2) It allows you to do async work before the controller initializes
home: ListenableFutureBuilder(
listenable: () async => container.getAsync<CounterController>(),
builder: (context, child, snapshot) {
if (snapshot.hasData) {
//This propagates the controller to the widget tree and is
//is part of the listenable_future_builder package
return ListenablePropagator(
listenable: snapshot.data!,
child: const MyHomePage(),
);
} else if (snapshot.hasError) {
return const Text('Error');
}
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
));
},
),
);
}
class MyHomePage extends StatelessWidget {
const MyHomePage({super.key});
@override
Widget build(BuildContext context) {
//Here we access the controller 🎮
var controller = ListenablePropagator.of<CounterController>(context);
return Scaffold(
appBar: AppBar(
title: const Text('Bringing It Together'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
controller.counter.toString(),
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => controller.increment(),
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
Live demo:
This is what the widget tree looks like while waiting for the controller
MaterialApp
├─ ListenableFutureBuilder
├─ MaterialApp
├─ Scaffold
├─ Center
├─ CircularProgressIndicator
This is what it looks like once the controller is ready and the counter has been clicked 3 times
MaterialApp
├─ ListenableFutureBuilder
├─ ListenablePropagator* (contains CounterController - State)
├─ MyHomePage
├─ Scaffold
├─ AppBar
└─ Text: 'Bringing It Together'
├─ Center
├─ Column
├─ Text: 'You have pushed the button this many times:'
└─ Text: '3'
└─ FloatingActionButton
└─ Icon: 'add'
Conclusion
The builder, controller, state, and dependency manager work together to manage the state of your application and provide a responsive user interface. If you use these components effectively, you can build reliable and efficient applications. Take the time to learn and understand these components, and you’ll be on your way to building successful Flutter applications.