Mastering State Management in Flutter

Learn to build a state management solution with ValueNotifier and InheritedNotifier.

Flutter logo, hammer and the words state management

Imagine a bunch of nerds fighting about how to manipulate their data for five years. That’s basically what has been happening in the Flutter community.

It’s been five years, and posts about GetX still conjure up a storm.

I am one of those nerds. So, I want to nerd out for the last time and move on with my life. This is my final word on State Management.

Final Word

Use whatever state management you feel like using. And if you care about what other people are using, find something better to care about.

I don’t care what you use. However, I would highly advise you to be informed about what you use and understand how to manage your state using only the tools provided by Flutter.

What is a State?

The phrase State Management breaks down to a system for managing your state. What is a State?

State is a type of data. There are two types of data in an application:

  • regular data (hard coded and cannot change)
  • state (fancy data that can change)

A typical example of a state is user information. For example, you can have a Hello, Tadas displayed on your home screen, and the Tadas part of the information is retrieved from the user information state. Pretend you have a settings page where you can go in and change the name to T-Dog. This would update the user information state with the new value, and your app will change to show Hello, T-Dog. A state management solution would facilitate this process of changing the data and propagating those changes throughout the application.

Some other common examples of state are News Feed, Follower Count, To-do Items, Countdown Timer, etc.

The simplest definition of state is data that can be changed.

What is Management?

State Management is in charge of managing that state. What does it mean to manage it? And why is it important to do it?

Let’s take the above example of having a user information state. There are so many ways that the information here can be updated.

  • When the user creates an account.
  • On the settings page.
  • Whenever they are followed.

As well as many places where the information can be read.

  • Name on the home page.
  • Name on the profile page.
  • The profile picture on every page.
  • Follower count on the profile page.
  • Whether they are following a specific person on their profile page.

What would happen if we didn’t have state management and needed to build an app that could do all the above?

You would need to pass the user information state to Every. Single. Screen. That sounds bad, but what’s even worse? The state can be updated in different parts of the app. If your user changes their name to T-Dog without state management, it will only get updated on one screen. You would have to set up a way to let every single other screen know the name was updated.

This sounds like a complex mess. Thankfully, we do have state management.

A proper state management solution solves two problems.

  1. It centralizes all the data in one place, so there is a single source of truth.
  2. Updates the user interface whenever the data has changed.

Client Vs Server State

The “State” in “State Management” refers to the current data within your application, which is being used to display your user interface. Server state is data outside your application (local storage, database, custom backend). Don’t get the app state confused with persisted data coming from your database.

Client State vs Server State

These two are not the same. Often, the goal of your state management solution is to minimize the gap from the server state. Just because they usually work together does not mean they are the same.

The state is held within your application and determines the user interface. The server handles the data outside your application and doesn’t directly impact anything within the application. Even if you are streaming the data from your database, you must handle the stream locally before it gets reflected in the user interface.

State Management with Flutter Only

Whenever you see State Management, your mind probably thinks of State Management Packages. But you do not need to use a package to manage your state.

Every state management package has been built using Flutter itself, so it is possible to do this with strictly Flutter code. In the next part of this article, we’ll build a counter application using only the tools provided by Flutter.

setState is the simplest form of managing state. It can update your user interface whenever you call it, but it is limited to a single widget, so we don’t mention it. However, for simple apps, setState might be all you need.

Centralizing the Data

The most important thing is having one source of truth for all the data related to a specific function. Since we are making a counter application, the main feature is the counter. We create a class called CounterModel that contains a counter and a username (to keep things interesting).

class CounterModel {
  CounterModel({
    required this.username,
    this.counter = 0,
  });

  final String username;
  final int counter;

  CounterModel copyWith({
    final String? username,
    final int? counter,
  }) {
    return CounterModel(
      username: username ?? this.username,
      counter: counter ?? this.counter,
    );
  }
}

I create a copyWith method whenever using a data object. This makes it easy to copy that object with the previous data and only change the values you define.

For example, let’s say you instantiate a new CounterModel with the username “Tadas” and a counter value of 1.

CounterModel _counterModel = CounterModel(username: "Tadas", counter: 1);

To create a new object with the same username but a new updated counter value, call the copyWith method.

_counterModel.copyWith(counter: 2);

You can use a VSCode extension called Dart Data Class Generator to create this copyWith method.

At first glance, this might seem like extra work, but this approach, called immutability, is convenient, especially in Flutter applications.

Immutability means that you can’t change your object once it’s created. This helps with reasoning and debugging your application because the object is independent of any happenings within your app. This removes issues that come with editing the same object, and it’s also relatively efficient, given that Dart can reuse old references.

ChangeNotifier

As we mentioned, a state management solution needs to do two things:

  1. Centralize all the data in one place so there is a single source of truth.
  2. Update the user interface whenever the data has changed.

This second point is where most of the debate around state management comes from. There are many ways to update the user interface, and the “best way” is based on your preference.

Flutter provides several ways to update your user interface. The ChangeNotifier widget is one of them. It notifies the widgets listening to the changes. I have to commend the Flutter team for their clear naming of widgets; they named each one exactly what it does.

To use it, you implement ChangeNotifier into your data class and write functions directly in that no-longer-just-a-data class. When using ChangeNotifier, it’s best to make your counter a private variable and expose it using a getter. This way, you can only modify the values using the functions you define within the ChangeNotifier. You must call notifyListeners() whenever you update that data. Once again, the Flutter team nails it with the naming. This function notifies the widgets listening to the ChangeNotifier that changes have occurred.

class CounterModel with ChangeNotifier {
  int _count = 0;
  int get count => _count;

  void increment() {
    _count += 1;
    notifyListeners();
  }
}

This code is an example to give more context about ChangeNotifier, but we won’t be using it in our demo.

When ready to use that data inside your application, pass your ChangeNotifier to a ListenableBuilder widget. The ListenableBuilder listens to things that are Listenable (like a ChangeNotifier) and builds your user interface whenever the data changes.

ValueNotifier

ValueNotifier takes what ChangeNotifier is doing and adds immutability. As we mentioned earlier, immutable objects cannot be edited. Because of this, we have to use a copyWith method to create new objects when we want to change any data.

When using ChangeNotifier, we need to call a function to notify listeners that the data has changed. ValueNotifier instead notifies the listeners if a new object has been assigned to its value.

This leads to a clean solution, where we get to keep the original CounterModel class as a data class and create a CounterNotifier that handles all the data manipulation.

The ValueNotifier can only have one object as its value. When we update that value, we use the copyWith method to create a new object.

class CounterNotifier extends ValueNotifier<CounterModel> {
  CounterNotifier(super.state);

  void increment() {
    value = value.copyWith(counter: value.counter + 1);
  }
}

Single Source of Truth

Whether you use ChangeNotifier or ValueNotifier, this approach is about having a single source of truth. With the ChangeNotifier, we made the data private so that nobody could modify it outside the ChangeNotifier. With ValueNotifier, the data is immutable, so it can’t be modified by definition. Data can only change for either by using the functions defined within.

Why is that so important? Let’s say you are working on a big application with counters all over the place. If you decide that the counters should be incremented by 2 instead of 1, you would have to go through every place in the code updating counters and ensuring that they get incremented by 2. Having it all in one place means you can update it in that one place, which will be reflected throughout the rest of the application.

Again, with a simple example like this, it might not seem like such a big deal, but it becomes crucial when you have a giant application.

The other significant benefit comes when you run into bugs. It becomes a lot easier to figure out where the problem is since the data is always traceable back to the source of truth.

InheritedWidget

Using our CounterNotifier, we can manipulate and read the state, and using a ListenableBuilder, we can listen to changes and update the user interface. However, we’re left with one big issue: We would have to manually pass the CounterNotifier to every screen where we want to use it.

Thankfully, Flutter provides an InheritedWidget that allows widgets lower in the widget tree access to information. This is how Theme.of(context) can be called anywhere in your application. It is an InheritedWidget within the MaterialApp. We can create our own InheritedWidget called “Provider” (since it will provide data to our application*).

You will see later why I call it that.

class Provider extends InheritedWidget {
   const Provider(this.data, {Key? key, required Widget child})
       : super(key: key, child: child);

   final CounterModel data;

   static CounterModel of(BuildContext context) {
     return context.dependOnInheritedWidgetOfExactType<Provider>()!.data;
   }

   @override
   bool updateShouldNotify(Provider oldWidget) {
     return data != oldWidget.data;
   }
 }

The updateShouldNotify function lets you define when the InheritedWidget should update your application. Whenever the function returns true, all the widgets using your data know that they should be rebuilt. In this case, we will inform our app about the changes whenever the new data doesn’t match the old data.

As I mentioned, InheritedWidget lets the widgets below it in the widget tree know that the data has been updated, and thus they get rebuilt. We want all the widgets to know the data has been updated, so we wrap the entire application with our InheritedWidget. Now, using Provider.of(context), we get access to our CounterNotifier.

void main() {
  runApp(
    Provider(
      notifier: CounterNotifier(CounterModel(username: "Tadas")),
      child: const MyApp(),
    ),
  );
}

This is fine, but we still need to use a ListenableBuiler to listen for updates. We can do better.

InheritedNotifier

Flutter has an InheritedNotifier widget that can only be used with a Listenable notifier (which includes both ChangeNotifier and ValueNotifier). Because of this, InheritedNotifier can notify the user interface that the data has been changed.

This removes the need for a ListenableBuilder, and we can use Provider.of<CounterNotifier>(context) directly. We must add a <CounterNotifier> because this new Provider is reusable. Because of that, we need to define which notifier we want to listen to.

class Provider<T extends Listenable> extends InheritedNotifier<T> {
  const Provider({
    super.key,
    required super.child,
    required super.notifier,
  });

  static T of<T extends Listenable>(BuildContext context) {
    final provider = context.dependOnInheritedWidgetOfExactType<Provider<T>>();

    if (provider == null) {
      throw Exception("No Provider found in context");
    }

    final notifier = provider.notifier;

    if (notifier == null) {
      throw Exception("No notifier found in Provider");
    }

    return notifier;
  }
}

Using the State

Now, we have access to a complete state management solution for our counter application.

We can access the CounterNotifier using our InheritedNotifier and use it throughout our application to either display the state or manipulate it using the functions we define.

The example code is located under the Table of Contents.

final counterNotifier = Provider.of<CounterNotifier>(context);

return Scaffold(
  body: Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        Text(
          '${counterNotifier.value.username} has pushed the button this many times:',
        ),
        Text(
          '${counterNotifier.value.counter}',
          style: Theme.of(context).textTheme.headlineMedium,
        ),
      ],
    ),
  ),
  floatingActionButton: FloatingActionButton(
    onPressed: () => counterNotifier.increment(),
    child: const Icon(Icons.add),
  ),
);

Why Use a Package?

So, what’s all the fuss about state management packages? It comes from a good place, from the drive to write more organized and efficient software.

In most cases, however, the best approach is to keep it simple and use the framework’s tools, but that doesn’t mean packages are useless.

Packages do provide benefits. They come with features and robustness that help you scale your state management while making it less set up for the developer.

Options

There are so many options for packages that manage your state, and everybody has their own strong opinions on which ones are better and worse. Feel free to explore them all, but three options stand out more than others due to their popularity and general acceptance within the community.

Provider

During this demo project, I called our InheritedWidget: Provider. The Provider package, for the most part, does the same thing we did in this demo with less boilerplate.

Provider is a wrapper around InheritedWidget, making it easier to use with different data types.

It has FutureProvider and StreamProvider, which handle passing futures and streams down your widget tree. It also has a ValueListenableProvider and ChangeNotifierProvider, which are similar to what we built.

Riverpod

Riverpod is what I recommend for most people who choose to use a package. Riverpod uses the concepts from Provider and incorporates them into a complete state management solution. For holding state, you should use a NotifierProvider which:

  1. Provides the state of your application.
  2. Creates an interface to update that state using a Notifier.

This is very similar to the approach we took in this article, except less boilerplate.

If you want to dive deeper into how you would Riverpod with Firebase, which is the most popular database choice for Flutter apps, I have built a course just on that.

Riverpod has the least boilerplate while also utilizing the core Flutter features appropriately.

Bloc

Bloc is another top-rated solution within the Flutter community. It is known to scale well and is a good choice for big projects.

I don’t choose Bloc for my projects because it contains a lot of boilerplate, which I was trying to avoid when using a package. You also have to learn the Bloc paradigm before using it.

Some people see this as a positive because it forces you to follow good code practices, which are essential when scaling your applications. But I enjoy packages with less overhead.

Others

There are many other options, so feel free to explore, but these three are the big dogs in the Flutter community, and you can’t go wrong with them.

Thank you for reading. Keep feeding your hungry mind △

Dive Deeper

You should think about state management from the first line of code. This makes the login stage crucial to get right. Everything within your app will be based on the login state and user data. The Ultimate Login System course teaches you how to organize your code.

This course uses Riverpod for state management and Firebase for the backend. As we discussed, Riverpod is one of the most popular packages amongst Flutter Experts, and for good reason. We use Firebase because it is the most used backend service for Flutter projects.

Seeking More?

Invest in yourself with our most loved courses.

Flutter Fundamentals

Flutter Fundamentals

Learn the fundamentals to start building your own apps and gain a deep understanding of Flutter.

Astro Fundamentals

Astro Fundamentals

Learn the fundamentals to start building your own websites and gain a deep understanding of Astro.

Ultimate Login System

Ultimate Login System

Build a robust login system using best practices with Riverpod and Firebase.