Flutter State Management: Going from setState to Freezed & StateNotifier with Provider

Source code on GitHub

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:

Movie app screenshots
Movie app screenshots

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:

Simple profile creation page
Simple profile creation page

We'll explore three different ways of implementing it, discuss their trade-offs, and highlight some useful concepts along the way:

  1. setState()
  2. ChangeNotifier + Provider
  3. 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 the CreateProfileModel class.
  • The submit() method doesn't contain any UI code. The previous implementation was calling Navigator.of(context).pop() on success. Instead, the new code returns true or false 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 like Consumer<T> or Provider.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).

Unidirectional data-flow via publish/subscribe pattern
Unidirectional data-flow via publish/subscribe pattern

ChangeNotifier with Provider 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 single null 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 with Provider, make sure to add both the state_notifier and flutter_state_notifier packages to your pubspec.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 like flutter_bloc offer a BlocListener 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:

Movie app screenshots
Movie app screenshots

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!

Want More?

Invest in yourself with my high-quality Flutter courses.

Flutter Foundations Course

Flutter Foundations Course

Learn about State Management, App Architecture, Navigation, Testing, and much more by building a Flutter eCommerce app on iOS, Android, and web.

Flutter & Firebase Masterclass

Flutter & Firebase Masterclass

Learn about Firebase Auth, Cloud Firestore, Cloud Functions, Stripe payments, and much more by building a full-stack eCommerce app with Flutter & Firebase.

The Complete Dart Developer Guide

The Complete Dart Developer Guide

Learn Dart Programming in depth. Includes: basic to advanced topics, exercises, and projects. Fully updated to Dart 2.15.

Flutter Animations Masterclass

Flutter Animations Masterclass

Master Flutter animations and build a completely custom habit tracking application.