Note: this article is out of date. To learn about a more modern approach to state management, read my articles about Riverpod.
Flutter state management is a very hot subject and it seems like every day a new state management package is born.
Once you get past the basics, you may ask yourself a few questions:
- How should I structure my app?
- Where should my business logic go?
- What is state, where do I store it, and how do widgets get access to it?
- What are the common problems with state management, and how can I solve them?
- Which state management solution should I use?
- Once I have chosen one, will it scale and support my codebase as it grows?
These are all valid questions and getting them right can help you save hours, days, even weeks of work as your apps become more complex.
In this tutorial, I'll try to answer some of them and help you understand the most important state management principles.
I'll introduce a simple Flutter app that mixes UI and business logic inside a StatefulWidget class. We'll talk about some of the problems with this approach, and refactor the code using Freezed & StateNotifier.
We'll use Provider in this tutorial, but the same principles are valid if you prefer flutter_bloc, Riverpod, or other state management packages.
Along the way, we will cover important principles and Flutter best practices that you can follow to write high-quality code and design complex apps.
At the end, I will share a new reference movie app that I've built to compare and contrast different state management techniques:
You'll find the full source code for this app on GitHub, so that you can see a practical application of all the principles we're about to explore.
Example: Create Profile Page
Suppose that we need to create a simple page where the user can enter a profile name, and press a Save button to persist this to a data store:
We'll explore three different ways of implementing it, discuss their trade-offs, and highlight some useful concepts along the way:
setState()
ChangeNotifier
+Provider
Freezed
+StateNotifier
+Provider
So let's start with version 1.
This page will have some state, so we can start implementing it as a StatefulWidget
:
class CreateProfileBasic extends StatefulWidget {
const CreateProfileBasic({Key key, this.dataStore}) : super(key: key);
// [DataStore] is a custom API wrapper class to get access to a persistent store.
final DataStore dataStore;
@override
_CreateProfilePageState createState() => _CreateProfilePageState();
}
class _CreateProfilePageState extends State<CreateProfilePage> {
final _controller = TextEditingController();
bool _isLoading = false;
String _errorText;
Future<void> _submit(String name) async {
// TODO: Implement me
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Create Profile'),
actions: [
FlatButton(
onPressed: _isLoading
? null
:() => _submit(_controller.value.text),
child: const Text('Save'),
)
],
),
body: Center(
child: TextField(
controller: _controller,
decoration: InputDecoration(errorText: _errorText),
onSubmitted: _isLoading
? null
:(name) => _submit(name),
),
),
);
}
}
This simple UI is composed by a TextField
and a Save action button.
We also have a _isLoading
variable that disables the button while a profile is being saved, along with an _errorText
that will show in the TextField
decoration if there are any errors.
Our goal is to do some validation and create a new profile when the _submit()
method is called by using this API:
// save a profile with the given name and a unique ID
await widget.dataStore.createProfile(Profile(name: name, id: Uuid().v1()));
We can implement the _submit()
method like this:
Future<void> _submit(String name) async {
// 1
if (name.isEmpty) {
setState(() => _errorText = 'Name can\'t be empty');
return;
}
// 2
final nameExists = await widget.dataStore.profileExistsWithName(name);
if (nameExists) {
setState(() => _errorText = 'Name already taken');
return;
}
// 3
final id = Uuid().v1();
setState(() => _isLoading = true);
try {
// 4
await widget.dataStore.createProfile(Profile(name: name, id: id));
setState(() {
_isLoading = false;
_errorText = null;
});
} catch (e) {
// 5
setState(() => _errorText = e.toString());
return;
}
// 6
Navigator.of(context).pop();
}
The code above checks that the name is not empty (1) and not already taken (2). If validation passes it creates a new unique ID (3), saves the profile (4), and pops the navigation stack (6) if there are no errors (5).
setState()
is called whenever the _isLoading
or _errorText
variables change, so that the widget rebuilds.
The code works, but it has some drawbacks.
So let's see what's wrong and how to improve it.
Bad: Mixing business logic and UI
All the validation and saving logic lives inside a _submit()
method.
This is better than putting all the logic inside callbacks in the build()
method, as the business logic and UI are visually separate and belong to separate methods.
But it's still not great, as all the logic is still inside the _CreateProfilePageState
class. If we add more UI and logic to this class, our code will quickly become hard to read and reason about.
To save a profile we need an external dependency (the data store) that is passed in as a constructor argument to the CreateProfilePage
class.
One of the best things you can do to write more maintainable code is to move any non-trivial business logic (along with its dependencies) outside your widget classes.
Solution: move business logic into a separate model class for better separation of concerns
Packages such as flutter_bloc
and state_notifier
can be used to hold the state and logic we need. Before we can fully understand what problems they solve, we can take a small step and use ChangeNotifier
instead:
class CreateProfileModel with ChangeNotifier {
CreateProfileModel(this.dataStore);
final DataStore dataStore;
bool isLoading = false;
String errorText;
Future<bool> submit(String name) async {
if (name.isEmpty) {
errorText = 'Name can\'t be empty';
notifyListeners();
return false;
}
final nameExists = await dataStore.profileExistsWithName(name);
if (nameExists) {
errorText = 'Name already taken';
notifyListeners();
return false;
}
final id = Uuid().v1();
isLoading = true;
notifyListeners();
try {
await dataStore.createProfile(Profile(name: name, id: id));
isLoading = false;
errorText = null;
notifyListeners();
} catch (e) {
errorText = e.toString();
notifyListeners();
}
return true;
}
}
A few things to notice:
- The
DataStore
is now a dependency of theCreateProfileModel
class. - The
submit()
method doesn't contain any UI code. The previous implementation was callingNavigator.of(context).pop()
on success. Instead, the new code returnstrue
orfalse
and lets the calling code handle the result. - We have to call
notifyListeners()
every time there is a state change.
Let's see how we can modify the widget class to use this new setup:
class CreateProfilePage extends StatefulWidget {
/// This can be called as:
/// CreateProfileWidget.create(context);
static Widget create(BuildContext context) {
final dataStore = context.watch<DataStore>();
return ChangeNotifierProvider<CreateProfileModel>(
create: (_) => CreateProfileModel(dataStore),
child: CreateProfilePage(),
);
}
@override
_CreateProfilePageState createState() => _CreateProfilePageState();
}
The code above has a new static create()
method that is used to add a parent ChangeNotifierProvider<CreateProfileModel>
to the CreateProfilePage
widget, using the Provider package.
We can then update the _CreateProfilePageState
class as follows:
// Note: we still use a [StatefulWidget] with a [State] subclass
// as the [TextEditingController] holds the internal state of the [TextField]
class _CreateProfilePageState extends State<CreateProfilePage> {
final _controller = TextEditingController();
Future<void> submit(CreateProfileModel model, String name) async {
// 1. All the logic now lives in the model class
final success = await model.submit(name);
if (success) {
// 2. pop navigator on success
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
// 3. `context.watch` causes this widget to rebuild when notifyListeners() is called
final model = context.watch<CreateProfileModel>();
return Scaffold(
appBar: AppBar(
title: const Text('Create Profile'),
actions: [
FlatButton(
onPressed: model.isLoading
? null
: () => submit(model, _controller.value.text),
child: const Text('Save'),
)
],
),
body: Center(
child: TextField(
controller: _controller,
decoration: InputDecoration(errorText: model.errorText),
onSubmitted: (name) =>
model.isLoading ? null : submit(model, name),
),
),
);
}
}
The UI code is nearly identical to how it was before, but we now use context.watch<CreateProfileModel>()
to rebuild the UI when notifyListeners()
is called in the model class.
context.watch<T>
was introduced in Provider 4.1.0. It works just likeConsumer<T>
orProvider.of<T>
, but uses Dart extension methods to provide a more lightweight syntax.
What's most important is that all the state variables and business logic are no longer in the widget class.
This is a big win because we have better separation of concerns and our code is more readable and easily testable (though we added a bit of boilerplate to wire things up).
But we're not done yet, as our ChangeNotifier
implementation has some drawbacks.
Bad: Mutable state
As it stands, the CreateProfileModel
declares the isLoading
and errorText
variables as public. This means that once our widget class gets hold of the model, it could modify their values directly:
Widget build(BuildContext context) {
final model = context.watch<CreateProfileModel>();
// BAD: this should not be allowed!
model.isLoading = true;
return Scaffold(...);
}
To prevent this, we could redeclare the state variables as private, and add a public getter:
class CreateProfileModel with ChangeNotifier {
...
bool _isLoading = false;
bool get isLoading => _isLoading;
String _errorText;
String get errorText => _errorText;
}
This makes our model class safer to use, but requires two declarations for each state variable we need. Not a good sight.
The underlying problem here is that the state variables in our model class are mutable.
Instead, by only using immutable model classes we can enforce a unidirectional data flow. This means that state changes cause our widgets to rebuild, but widgets cannot mutate state directly and they need to do so by other means (for example by dispatching events or calling methods in our model classes).
ChangeNotifier
withProvider
is the recommended state management approach on Flutter.dev, because of its simplicity. It can be used to implement an unidirectional data-flow, as long as it's used correctly.
Our ChangeNotifier
implementation has other problems too.
Bad: null state and invalid state configurations
The errorText
state variable uses null
to indicate that there is no error:
bool isLoading = false;
String errorText; // use null to indicate no error
This works, but we can't figure out what are the valid error states just by looking at the variable declaration. It would be better to have an actual type to tell us if there is an error or not.
In some cases,
null
is not enough. For example, if we're loading data from an API we need to distinguish between "no data" and "data is loading", and a singlenull
value won't do it.
Our example only has two state variables (isLoading
and errorText
) but it's not clear from the context how many different permutations of these variables are valid. Is it ok to have isLoading = true
and a non-null errorText
? We just don't know for sure.
Solution: Immutable state and sealed unions
Can we make our state immutable and use the type system to only allow valid state configurations?
In Dart, we can make a variable immutable by declaring it as final
. And we can use enums to choose between a distinct set of options. Example:
enum CreateProfileState {
noError,
error, // where does the error text go?
loading
}
But Dart enums are not powerful enough because we can't associate additional values to certain cases (e.g. errorText
for the error
state). What we actually want are sealed unions.
In Dart we can "simulate" a sealed union by creating a base abstract class for our state:
abstract class CreateProfileState {}
And then we can create subclasses to represent each valid state, along with any values they need:
class CreateProfileStateNoError extends CreateProfileState {}
class CreateProfileStateError extends CreateProfileState {
CreateProfileStateError(this.errorText);
final String errorText;
}
class CreateProfileStateLoading extends CreateProfileState {}
With this setup we can declare a state variable of type CreateProfileState
and assign it with an instance of any of the subclasses. And we can check all possible states with the is
keyword and an if/else
chain:
void printState(CreateProfileState state) {
if (state is CreateProfileStateNoError) {
print('no error');
} else if (state is CreateProfileStateError) {
print('error: ${state.errorText}');
} else if (state is CreateProfileStateLoading) {
print('loading');
}
}
You may be familiar with this syntax if you've used the flutter_bloc
library.
This setup makes it impossible to represent invalid states but results in a lot of boilerplate code. And it still doesn't give us a concise way to check the current state.
While Dart doesn't support sealed unions as a language feature, we can use code generation to get the result we want.
Enter Freezed! ❄️
The Freezed package
Freezed is a code generation package that offers many useful features. From sealed unions, to pattern matching, to json serialization, it can make our life a lot easier.
We can install it by adding the following to our pubspec.yaml
file:
# pubspec.yaml
dependencies:
freezed_annotation:
dev_dependencies:
build_runner:
freezed:
And now we can forget about all the abstract classes and subclasses we created above. With Freezed, all we need is this (make sure to follow all the steps exactly!):
// create_profile_state.dart
// 1. Import this:
import 'package:freezed_annotation/freezed_annotation.dart';
// 2. Declare this:
part 'create_profile_state.freezed.dart';
// 3. Annotate the class with @freezed
@freezed
// 4. Declare the class as abstract and add `with _$ClassName`
abstract class CreateProfileState with _$CreateProfileState {
// 5. Create a `const factory` constructor for each valid state
const factory CreateProfileState.noError() = _NoError;
const factory CreateProfileState.error(String errorText) = _Error;
const factory CreateProfileState.loading() = _Loading;
}
In this case, we have created three separate constructors to represent the noError, error, and loading states that we need. This is a design decision and you should think about which states you need on a case-by-case basis.
Because Freezed uses code generation, we need to run this command every time we change our state classes:
dart run build_runner build -d
And now, it's time for some magic! ✨
Want to check the various states? Do this:
final state = CreateProfileState.error('Something went wrong');
print(
state.when(
// Note: the callback names and signatures match the constructors we created above
noError: () => 'no error',
error: (errorText) => 'error: $errorText',
loading: () => 'loading',
)
);
The .when()
method above gives us a callback-based API that we can use to evaluate all possible states, using pattern matching and destructuring under the hood. .when()
makes it super-easy to map state to UI, which is exactly the goal of state management:
state => UI
Freezed is a feature-rich package, and I won't cover all the details here. You can read the documentation to learn about its other features. Also, be aware that Dart code generation is quite slow. If you have a lot of model classes, consider moving them to a separate package or adding a
build.yaml
file to specify a subset of files to be processed as explained here.
Updated ChangeNotifier implementation
Now that we have defined a CreateProfileState
class, let's see how to use it in our ChangeNotifier
implementation:
class CreateProfileModel with ChangeNotifier {
CreateProfileModel(this.dataStore);
final DataStore dataStore;
CreateProfileState state = CreateProfileState.noError();
Future<bool> submit(String name) async {
if (name.isEmpty) {
state = CreateProfileState.error('Name can\'t be empty');
notifyListeners();
return false;
}
final nameExists = await dataStore.profileExistsWithName(name);
if (nameExists) {
state = CreateProfileState.error('Name already taken');
notifyListeners();
return false;
}
final id = Uuid().v1();
state = CreateProfileState.loading();
notifyListeners();
try {
await dataStore.createProfile(Profile(name: name, id: id));
state = CreateProfileState.noError();
notifyListeners();
} catch (e) {
state = CreateProfileState.error(e.toString());
notifyListeners();
}
return true;
}
}
The isLoading
and errorText
variables have now been replaced by state
. And this makes it impossible to represent invalid states.
But this class is still error-prone. If we forget to call notifyListeners()
following a state change, our widget won't rebuild.
And because the state
variable is mutable, it can still be modified in the widget class.
Bottom line: we need something better than ChangeNotifier
.
Because we now need only a single CreateProfileState
object to hold all the state we need, we could modify our CreateProfileModel
class to extend ValueNotifier<CreateProfileState>
.
Alternatively, we could choose a 3rd party alternative such as StateNotifier
or Cubit
from the flutter_bloc
package.
In this tutorial, I'll focus on StateNotifier
, but the same principles are nearly identical for other solutions.
StateNotifier
StateNotifier is a replacement for ValueNotifier
. You can read about the advantages of StateNotifier
over ValueNotifier
in the package documentation.
If you want to use
StateNotifier
withProvider
, make sure to add both thestate_notifier
andflutter_state_notifier
packages to yourpubspec.yaml
.
Its syntax is nearly identical to that of ValueNotifier
. Here is how we can use it:
class CreateProfileModel extends StateNotifier<CreateProfileState> {
CreateProfileModel({@required this.dataStore})
: super(const CreateProfileState.noError());
final DataStore dataStore;
Future<bool> createProfile(String name) async {
if (name.isEmpty) {
state = CreateProfileState.error('Name can\'t be empty');
return false;
}
final nameExists = await dataStore.profileExistsWithName(name);
if (nameExists) {
state = CreateProfileState.error('Name already taken');
return false;
}
final id = Uuid().v1();
state = CreateProfileState.loading();
try {
await dataStore.createProfile(Profile(name: name, id: id));
state = CreateProfileState.noError();
} catch (e) {
state = CreateProfileState.error(e.toString());
}
return true;
}
}
Much better. We can now use the super constructor to define the initial state and we can set the state directly with an assignment inside createProfile()
. Since all the notifyListeners()
calls are gone, our code is now much easier to read.
Let's update the CreateProfilePage
to use the new model class:
class CreateProfilePage extends StatefulWidget {
static Widget create(BuildContext context) {
final dataStore = context.read<DataStore>();
return StateNotifierProvider<CreateProfileModel, CreateProfileState>(
create: (_) => CreateProfileModel(dataStore),
child: CreateProfilePage(),
);
}
@override
_CreateProfilePageState createState() => _CreateProfilePageState();
}
This time, we use a parent StateNotifierProvider
with two type annotations: CreateProfileModel
and CreateProfileState
.
The build()
method of the state class looks like this:
@override
Widget build(BuildContext context) {
// watch for changes to [CreateProfileState].
final state = context.watch<CreateProfileState>();
// extract loading variable
final isLoading = state.maybeWhen(loading: () => true, orElse: () => false);
// extract errorText
final errorText =
state.maybeWhen(error: (errorText) => errorText, orElse: () => null);
return Scaffold(
appBar: AppBar(
title: const Text('Create Profile'),
actions: [
FlatButton(
onPressed:
isLoading ? null : () => submit(context, controller.value.text),
child: const Text('Save'),
)
],
),
body: Container(
padding: const EdgeInsets.all(32.0),
alignment: Alignment.center,
child: TextField(
controller: controller,
decoration: InputDecoration(errorText: errorText),
onSubmitted: (name) => isLoading ? null : submit(context, name),
),
),
);
}
By calling context.watch<CreateProfileState>()
, we ensure that the widget is rebuilt when the state changes.
Then, we use the .maybeWhen()
method generated by Freezed to extract the isLoading
and errorText
variables we need. In this example we need to do this because we can't map the noError, error and loading states directly to specific widgets. Instead, if your state maps 1-to-1 to your UI, you can use state.when(...)
to return different widgets for different states.
Note: in the
build()
method above we get the (immutable) state variable rather than the model itself. This makes it impossible to change the state by mistake, as the only thing we can do is to read it.
Finally, let's review the _submit()
method:
Future<void> _submit(String name) async {
final model = context.read<CreateProfileModel>();
final success = await model.submit(name);
if (success) {
Navigator.of(context).pop();
}
}
In this case, we get the model object using context.read
(rather than context.watch
), and we use it to submit the name. This in turn will update the state and cause the UI to rebuild again. So our unidirectional data flow is preserved.
Some state management purists would argue that we are introducing unwanted business logic by checking the success value and popping the
Navigator
as a result. Packages likeflutter_bloc
offer aBlocListener
widget that can be used to respond to state changes that don't require a UI rebuild. I like to take a more pragmatic approach and I'm happy with very-short callback method handlers in my widget classes.
Wrap Up
If you've managed to follow all the way here, congratulations!
We now have managed to address all these concerns:
- mixing business logic and UI
- mutable state
- null state and invalid state configurations
All the changes we made resulted in:
- clear separation of logic an UI
- immutable state with unidirectional data flow
- only valid states are allowed
We have applied good state management principles and refactored a widget class that had some local state.
Take-aways
- Use StateNotifier to create separate model classes for your business logic. StateNotifier works very well with Provider and Riverpod.
- Use sealed unions to represent mutually exclusive, immutable states in your app.
- The Freezed package supports sealed unions via code generation, along with
.when()
and.maybeWhen()
methods that make it easy to map state to UI in your widget classes. - Code generation is quite slow. Add a build.yaml file to your project if you decide to use it.
Some more considerations should be made when dealing with shared/global state (more tutorials incoming 😉).
But the same principles still apply and following them can help immensely as your code (and team size) grows.
While the example I presented uses Provider and StateNotifier, with small changes you can make the same code work with flutter_bloc or Riverpod.
Full Movie App
In fact, using these principles I have built a more complex app inspired by Netflix that includes the following features:
- "Now Playing" movies (with pagination)
- Save favourites to watch list
- Multiple profiles
- Local data persistence (movies, favourites, profiles) with Sembast
I created this app to compare and contrast different state management techniques:
The full source code includes separate implementations using Riverpod, flutter_bloc, and Provider (with more to come in the future).
What next?
As we have seen, there are many different ways to solve our original problem:
state => UI
While this was a long tutorial, state management is a very broad subject and there are additional topics I haven't covered:
- working with shared/global state
- working with streams & asynchronous data
- runtime dependencies between data/state classes
- testing
But I hope that you'll be able to take the principles I outlined here, and apply them to your own apps.
I strongly believe that getting code to "just work" is only the first step when writing software.
If you try to ship a lot of features by making things "just work" and never focus on code quality, I guarantee that you will pay a high price for it.
I have seen this happen in many projects over the years. Companies can fail because of this.
If you care about your work or have a business that relies on the code you write, don't make the same mistake. Over time, your code should become easier to work with, not harder. This will make your life easier, not harder.
And as usual, enjoy the journey!
Happy coding!