If you’ve been at least a bit active when it comes to Flutter packages in the last year or so, you’ve surely heard about Riverpod, a reactive caching and data-binding, or as some would say, state management package that is sort of an upgrade of the beloved Provider. I actually covered it with a tutorial quite some time ago when its API was still unstable.

Riverpod has come a long way since then - it’s much more mature, helpful, and versatile. All these changes naturally mean that it’s time for a new tutorial to prepare you to fully utilize the power of Riverpod 2.0 and, most likely, also its upcoming versions.

The basic principles of Riverpod remain the same no matter which version you use; it's just the way you do certain things that's different. Therefore, if you'd like to see the areas where the Provider package comes short and why Riverpod is so good, check out my older tutorial, where I go into more detail regarding Provider. This article will focus purely on Riverpod, and you don't need any previous knowledge to follow along.
If you are a total beginner with little or no experience with state management, I recommend you read this official Flutter article before continuing to learn about Riverpod.

The purpose of Riverpod has a lot in common with classes and packages like InheritedWidget, Provider, get_it, and partly GetX. That is, to allow you to access objects across different parts of your app without passing all kinds of callbacks and objects as constructor parameters to the Widgets.

So what sets it apart from all of these other options offered to you from each side? It's the fact that it combines ease of use, clean coding practices, complete independence from Flutter (great for testing!), compile-time safety (as opposed to dealing with run-time errors), and performance optimization in one package. To achieve all this, Riverpod has a unique approach to how you declare the objects you want to provide around your app.

The version of the package we're using is 2.0.0-dev.5, so make sure to use at least that version in your pubspec. Everything we write in this tutorial will be valid once the stable version is released too.

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.0.0-dev.5

Providers

Let’s first deal with the simplest possible example and say that you want a String to be accessible throughout your app.

main.dart

// Provider declaration is top-level (global)
final myStringProvider = Provider((ref) => 'Hello world!');

If we break down the line of code above, you’ve just declared a provider under the name myStringProvider, which will provide the “Hello world!” String wherever you need. This is the most basic type of a Provider that simply exposes a read-only value. We’ll take a look at the other more advanced types of providers shortly. The ref parameter is of type ProviderReference and it’s used, among other things, to interact with other providers - we’ll explore the ref parameter later on as well.

From now on, we'll call the provided object “state”

This provider declaration is highly similar to declaring a class. A class declaration is accessible globally, but once you instantiate an object, it's no longer global, which tremendously helps with the app's maintainability and makes testing possible since hard-coded use of globals doesn't allow mocking.

main.dart

class MyClass {
  int myField;

  MyClass(this.myField);
}

// The object has to be passed into the function.
// We can't access it globally.
void myFunction(MyClass object) {

  object.myField = 5;

}

In much the same way, a provider declaration is global, but the actual state it provides is not global. It is instead stored and managed in a widget called ProviderScope, at which we'll take a closer look soon, and this keeps our app maintainable and testable. Essentially, we could say that we get all the benefits of global variables without any of their drawbacks. Yes, sometimes things that sound too good to be true are indeed true.

Riverpod & Widget Tree

Let's now look at a more real-world example - a counter app! Yes, precisely that counter app that you're so fed up with but don't despair because you will learn something new this time. I promise!

Here's the end result:

Naturally, we’re going to use Riverpod for the state management instead of the classic StatefulWidget. By now, you’ve seen the most basic Provider class that provides read-only data. Since we want to increment the counter when the user presses a button, we obviously need to write to the data too. The simplest way to achieve this is with a StateProvider.

main.dart

final counterProvider = StateProvider((ref) => 0);
 
void main() => runApp(MyApp());
 
...
Riverpod is really just a way to provide objects around the app, and while it comes both with some built-in simple & advanced ways to manage state, you are not limited to them at all. You can use ChangeNotifier, Bloc, Cubit or anything else you want in conjunction with Riverpod.

Oh, and what about the single ProviderScope widget I’ve mentioned earlier? That just simply needs to wrap the whole app widget in the main method.

main.dart

void main() {
 runApp(
   ProviderScope(
     child: MyApp(),
   ),
 );
}
 
class MyApp extends StatelessWidget {
 const MyApp({Key? key}) : super(key: key);
 
 @override
 Widget build(BuildContext context) {
   return MaterialApp(
     title: 'Counter App',
     home: const HomePage(),
   );
 }
}

We’re also adding a little twist to this app by not doing the counting right in the “home” route. Instead, we’ll make the user first navigate to the CounterPage widget from the HomePage, so the HomePage will contain only one button.

main.dart

class HomePage extends StatelessWidget {
 const HomePage({Key? key}) : super(key: key);
 
 @override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: AppBar(
       title: const Text('Home'),
     ),
     body: Center(
       child: ElevatedButton(
         child: const Text('Go to Counter Page'),
         onPressed: () {
           Navigator.of(context).push(
             MaterialPageRoute(
               builder: ((context) => const CounterPage()),
             ),
           );
         },
       ),
     ),
   );
 }
}

As you can see, we haven’t used the counterProvider anywhere thus far. We made it possible to be used by declaring the provider itself and setting up the ProviderScope but if we ran the app right now, no actual counterProvider would ever be created, let alone utilized and incremented. Let’s change that in the CounterPage.

main.dart

// ConsumerWidget is like a StatelessWidget
// but with a WidgetRef parameter added in the build method.
class CounterPage extends ConsumerWidget {
 const CounterPage({Key? key}) : super(key: key);
 
 @override
 Widget build(BuildContext context, WidgetRef ref) {
   // Using the WidgetRef to get the counter int from the counterProvider.
   // The watch method makes the widget rebuild whenever the int changes value.
   //   - something like setState() but automatic
   final int counter = ref.watch(counterProvider);
 
   return Scaffold(
     appBar: AppBar(
       title: const Text('Counter'),
     ),
     body: Center(
       child: Text(
         counter.toString(),
         style: Theme.of(context).textTheme.displayMedium,
       ),
     ),
     floatingActionButton: FloatingActionButton(
       child: const Icon(Icons.add),
       onPressed: () {
         // Using the WidgetRef to read() the counterProvider just one time.
         //   - unlike watch(), this will never rebuild the widget automatically
         // We don't want to get the int but the actual StateNotifier, hence we access it.
         // StateNotifier exposes the int which we can then mutate (in our case increment).
         ref.read(counterProvider.notifier).state++;
       },
     ),
   );
 }
}

Not Preserving the State

The app now successfully increments the counter and even preserves the state, in this case, the one counter integer for the lifetime of the app session. But what if we want the user to always start counting from zero once the CounterPage is opened (even reopened after being closed previously)?

That’s simple! The only thing we need to add is the autoDispose modifier to the counterProvider at the very top of the file.

main.dart

final counterProvider = StateProvider.autoDispose((ref) => 0);

How does this work? Why is the counterProvider’s state now disposed after the user closed and disposed the CounterPage?

Riverpod knows which widgets use the individual providers. After all, we are continuously subscribed to the counterProvider in the CounterPage by calling the watch method. In our case, this also happens to be the only subscription to the counterProvider in the entire app, so once that subscription no longer exists because the CounterPage widget has been closed and disposed, Riverpod knows that the counterProvider’s state can also be disposed.

And by what kind of magic is that done? Well, the CounterPage is a subclass of ConsumerWidget that comes from the Riverpod package too, so all the necessary code responsible for disposing of provider state is hidden in there.

Resetting the State Manually

Disposing of the state and thus releasing resources when the provider is no longer in use is one thing, but you may sometimes want to manually reset the state, for example, with a button. This is very easy with ref.invalidate.

main.dart

class CounterPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Counter'),
        actions: [
          IconButton(
            onPressed: () {
              ref.invalidate(counterProvider);
            },
            icon: const Icon(Icons.refresh),
          ),
        ],
      ),
...
While you should prefer what we've done above, you may sometimes want to call ref.refresh instead, which will return the newly reset state - in our case, that would be the integer 0.

Performing Actions Based on the State

By now we’ve seen that watch is used within the build method for getting the provider state and rebuilding a widget with it, while read is for doing just one-off actions with the provider outside of the build method - usually in button onPressed or similar callbacks.

But how can we, for example, navigate, show snackbars, alerts, or do any kind of other action whenever the state of the provider changes to the desired value? We can’t use the state we get from watch and just do these actions directly in the build method because we’ll get the infamous “setState() or markNeedsBuild() called during build” error thrown in our face. Instead, we need to use the listen method.

Let's say that we consider the number 5 to be dangerously large and want to show a dialog warning the user about it like this:

The following ref.listen call is what needs to go into the CounterPage.

main.dart

class CounterPage extends ConsumerWidget {
  
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final int counter = ref.watch(counterProvider);

    ref.listen<int>(
      counterProvider,
      // "next" is referring to the new state.
      // The "previous" state is sometimes useful for logic in the callback.
      (previous, next) {
        if (next >= 5) {
          showDialog(
            context: context,
            builder: (context) {
              return AlertDialog(
                title: Text('Warning'),
                content:
                    Text('Counter dangerously high. Consider resetting it.'),
                actions: [
                  TextButton(
                    onPressed: () {
                      Navigator.of(context).pop();
                    },
                    child: Text('OK'),
                  )
                ],
              );
            },
          );
        }
      },
    );

    ...
}

Inter-Provider Dependencies

Apps aren't always as simple as having a single provider for the counter which means you are usually going to have multiple providers at once. As if that wasn't enough, the objects that the providers provide are often going to depend on one another. For example, you may have a Cubit or a ChangeNotifier that depends on a Repository from which it gets the data.

Providers make dealing with dependencies between classes totally simple. In fact, you've already seen the syntax that's used for it in this very article, and if you're new to Riverpod, you may not have even noticed.

Let's say that we want to upgrade our familiar counter app to be a lot fancier. Everybody knows that keeping all of your state local is lame, and the cool kids put everything on serverless servers™, and we surely don't want to fall behind! We will create the ultimate counter app that gets its counter integer value through a WebSocket. Sort of...

To keep the app fit for a tutorial, we'll just fake the WebSocket and simply return a locally generated Stream of integers that are incremented every half a second. We'll also utilize an abstract class to serve as an interface so that the code can be easily swapped for a real implementation or tested.

main.dart

abstract class WebsocketClient {
  Stream<int> getCounterStream();
}

class FakeWebsocketClient implements WebsocketClient {
  @override
  Stream<int> getCounterStream() async* {
    int i = 0;
    while (true) {
      await Future.delayed(const Duration(milliseconds: 500));
      yield i++;
    }
  }
}

Providing the FakeWebsocketClient object is very straightforward:

main.dart

final websocketClientProvider = Provider<WebsocketClient>(
  (ref) {
    return FakeWebsocketClient();
  },
);

This is not the end provider we want the UI to have access to though. We need to call the getCounterStream method on the WebsocketClient to get the counter Stream we’re after all along.

Naturally, we’re going to create a new counterProvider of type StreamProvider. In order to call the getCounterStream method though, we first need to have the WebsocketClient object that is provided by the provider we created in the code snippet above.

StreamProvider is just another type of provider that has its declaration syntax identical to all the other providers we've already seen. Obviously, the object you provide with it must be of type Stream. This allows for some nice syntax when consuming the data from the widget tree - no more clunky StreamBuilders!

To get access to the WebsocketClient, we can simply read the websocketClientProvider using the ref parameter that’s included in every single provider’s creation callback.

main.dart

final counterProvider = StreamProvider<int>((ref) {
  final wsClient = ref.watch(websocketClientProvider);
  return wsClient.getCounterStream();
});

The ref.watch call looks familiar, doesn’t it? Of course it does - it’s exactly the same thing you do within the widget tree with the minor difference being that here the ref parameter is not of type WidgetRef but rather StreamProviderRef<int>.

The ref parameter in the callback allows you to do everything that a WidgetRef allows you to do (watch, read, listen, invalidate...) and more, such as adding different callbacks.

Our new and fancy CounterPage with the counter state management outsourced to a fake serverless server will now look as follows. Notice the ease with which we consume the Stream within the UI. We actually don’t even see it since it gets transformed to an AsyncValue by Riverpod.

main.dart

class CounterPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // AsyncValue is a union of 3 cases - data, error and loading
    final AsyncValue<int> counter = ref.watch(counterProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Counter'),
      ),
      body: Center(
        child: Text(
          counter
              .when(
                data: (int value) => value,
                error: (Object e, _) => e,
                // While we're waiting for the first counter value to arrive
                // we want the text to display zero.
                loading: () => 0,
              )
              .toString(),
          style: Theme.of(context).textTheme.displayMedium,
        ),
      ),
    );
  }
}

Passing an Argument to a Provider

The current implementation of the counter client always starts from zero but our customers started giving us one-star reviews saying that they need to be able to modify the starting value of the counter. So we naturally implement their feature request as follows:

main.dart

abstract class WebsocketClient {
  Stream<int> getCounterStream([int start]);
}

class FakeWebsocketClient implements WebsocketClient {
  @override
  Stream<int> getCounterStream([int start = 0]) async* {
    int i = start;
    while (true) {
      await Future.delayed(const Duration(milliseconds: 500));
      yield i++;
    }
  }
}

That would be it for the WebsocketClient and its “fake” implementation but we also need to somehow pass the starting value to the counterProvider since that is what the widgets actually use to get to the counter Stream.

Until now, we’ve never passed anything to a provider. We could read, watch or listen to it but it always contained everything needed and didn’t get anything passed from the outside. Passing arguments to providers is luckily very simple thanks to the family modifier.

main.dart

// The "family" modifier's first type argument is the type of the provider
// and the second type argument is the type that's passed in.
final counterProvider = StreamProvider.family<int, int>((ref, start) {
  final wsClient = ref.watch(websocketClientProvider);

  return wsClient.getCounterStream(start);
});
The family modifier can be combined with the autoDispose modifier like StreamProvider.autoDispose.family<int, int>

The family modifier makes our counterProvider to be a callable class, so to pass in a start value from the CounterPage, we can simply do this:

main.dart

class CounterPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Just hardcoding the start value 5 for simplicity
    final AsyncValue<int> counter = ref.watch(counterProvider(5));
    ...
  }
}

Conclusion

And just like that, you've learned how to use the powerful Riverpod package by building and expanding upon the simple counter app. You're now ready to employ all this knowledge in your own complex and cool apps that Riverpod makes hassle-free to build and easy to maintain - at least when it comes to getting objects around the app 😉

About the author 

Matt Rešetár

Matt is an app developer with a knack for teaching others. Working as a Flutter freelancer and most importantly developer educator, he doesn't have a lot of free time 😅 Yet he still manages to squeeze in tough workouts 💪

You may also like

  • Thank you for the good information.
    My name is PONSUKE and I am a beginner.
    I have a question about Inter-Provider Dependencies in this guide.

    Why do I need to create a websocketClientProvider?
    Why not instantiate wsClient with counterProvider as follows?

    final counterProvider = StreamProvider((ref) {
    FakeWebsocketClient wsClient = FakeWebsocketClient();
    return wsClient.getCounterStream();
    });

    I would like to know if there are any problems if I do it this way.

    Thank you

    • You can and may call the implementation class directly. However, once you want to change the “fake” client with “real” client, you have to modify counterProvider itself instead of simply change the implementation supplied by dependency injection. This also make things complicated when using tests.

      • Thank you.
        It was a good awareness.
        I will try to learn more about dependency injection and Riverpod.

  • I had another question, which state management framework that you use for customer production? Is it BLOC or Riverpod now.

  • At the beginning of the article, “ref.read(counterProvider.notifier).state++;” is used. But what is the difference between that and “ref.read(counterProvider.state).state++;” where counterProvider.state is used instead of counterProvider.notifier?

  • {"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}
    >