Flutter app development tutorials by Andrea Bizzotto

Flutter State Management with Riverpod: The Essential Guide

According to the official documentation:

Riverpod is a complete rewrite of the Provider package to make improvements that would be otherwise impossible.

Riverpod lets you manage your application state in a compile-safe way, while sharing many of the advantages of Provider.

It also brings many additional benefits, making it a great solution for Flutter state management.

This tutorial will cover all the essential concepts so that you can start using Riverpod in your own apps.

We'll use some simple examples to start with, then move on to more practical use cases that you may encounter in real-world apps.

Why use Riverpod for Flutter state management?

To understand why we even need Riverpod, we should consider some of the inherent problems with Provider.

By design, Provider is an improvement over InheritedWidget and as such, it depends on the widget-tree.

This leads to some drawbacks:

Provider Drawback #1: Combining Providers is very verbose

When a provider has a dependency on another provider we often end up with code that looks like this:

Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); final sharedPreferences = await SharedPreferences.getInstance(); runApp(MultiProvider( providers: [ Provider<SharedPreferences>(create: (_) => sharedPreferences), ChangeNotifierProxyProvider<SharedPreferences, OnboardingViewModel>( create: (_) => OnboardingViewModel(sharedPreferences), update: (context, sharedPreferences, _) => OnboardingViewModel(sharedPreferences), ), ], child: Consumer<OnboardingViewModel>( builder: (_, viewModel) => OnboardingPage(viewModel), ), )); }

In this example, we have an OnboardingPage that takes an OnboardingViewModel argument. But because OnboardingViewModel itself depends on SharedPreferences, we need MultiProvider and ProxyProvider widgets to wire everything up.

This is a lot of boilerplate code for something that should be relatively simple. It would be better if all these provider dependencies could be specified outside the widget tree.

Provider Drawback #2: Getting Providers by type and runtime exceptions

Inside any widget class, we can access our providers by type with this syntax:

Provider.of<MyType>(context)

But if we're not careful we can end up with a ProviderNotFoundException at runtime:

Example: accessing Providers in the widget tree
Example: accessing Providers in the widget tree

This can become a problem with large apps and it would be much better if provider access could be resolved at compile-time.

In addition, if there are two or more ancestor providers of the same type, we can only access the one that is closest to our widget.


Riverpod is completely independent from the widget-tree and it doesn't suffer from any of these drawbacks.

So let's see how to use it. Along the way, we will learn about what kinds of providers are available, along with some important concepts.

Riverpod Installation

The first step is to add the latest version of flutter_riverpod as a dependency to our pubspec.yaml file:

dependencies: flutter: sdk: flutter flutter_riverpod: ^0.13.0-nullsafety.1

Many packages already have a pre-release version that supports null-safety, so we will have this enabled for this tutorial.

NOTE: If your application already uses flutter_hooks, you can install the hooks_riverpod package instead. This includes some extra features that make it easier to integrate Hooks with Riverpod.

Top Tip: To more easily add Riverpod providers in your code, install the Flutter Riverpod Snippets VSCode extension.

ProviderScope

Once Riverpod is installed, we can wrap our root widget with a ProviderScope:

void main() { runApp(ProviderScope( child: MyApp(), )); }

ProviderScope is a widget that stores the state of all the providers we create.

Creating a Provider

Let's see how we can modify the sample counter app to use Riverpod.

In our main.dart we can replace the default MyHomePage widget with this:

class MyHomePage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( body: Center( child: Text( 'Some text here 👍', style: Theme.of(context).textTheme.headline4, ), ), ); } }

And just above it we can add a global valueProvider:

final valueProvider = Provider<int>((ref) { return 36; });

But how can we read the provider value inside our widget?

Consumer

We can wrap our Text widget with a Consumer:

class MyHomePage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( body: Center( child: Consumer( builder: (_, ScopedReader watch, __) { final value = watch(valueProvider); return Text( 'Value: $value', style: Theme.of(context).textTheme.headline4, ); }, ), ), ); } }

The Consumer's builder argument gives us a ScopedReader that we can use to watch the value of the provider.

Riverpod consumers use watch() to access providers by reference, not by type. This means that we can have as many providers as we want of the same type.

The code above works, but adding a parent Consumer to the Text widget is a bit too verbose.

To make life easier, Riverpod also offers a ConsumerWidget.

ConsumerWidget

Here's how we can modify our example:

// 1. Widget class now extends [ConsumerWidget] class MyHomePage extends ConsumerWidget { @override // 2. build() method has an extra [ScopedReader] argument Widget build(BuildContext context, ScopedReader watch) { final value = watch(valueProvider); return Scaffold( body: Center( child: Text( 'Value: $value', style: Theme.of(context).textTheme.headline4, ), ), ); } }

By subclassing ConsumerWidget, our widget's build() method now has an extra ScopedReader argument, and we can use this to watch our provider. Less code, more win. 😎

StateProvider

So far we managed to read a provider's value inside a widget. But Provider itself doesn't give us any capability to change its value. For that we need to create a StateProvider:

final counterStateProvider = StateProvider<int>((ref) { return 0; });

Then, we can modify our widget code:

class MyHomePage extends ConsumerWidget { @override Widget build(BuildContext context, ScopedReader watch) { // 1. watch the new counterStateProvider final counter = watch(counterStateProvider); return Scaffold( body: Center( child: Text( // 2. this time we read counter.state 'Value: ${counter.state}', style: Theme.of(context).textTheme.headline4, ), ), ); } }

To verify that our code works, we can add a floatingActionButton argument to our Scaffold:

floatingActionButton: FloatingActionButton( // access the provider via context.read(), then increment its state. onPressed: () => context.read(counterStateProvider).state++, child: Icon(Icons.add), ),

If we run the app now, we can press the FAB and see that the counter is updated inside the Text widget.

Note: Inside the button callback we access the counter with context.read() rather than watch(). read() is an extension method of BuildContext and we should use it when we access a provider from a callback (more on this below).

ProviderListener

Sometimes we want to push a route, show a dialog or a SnackBar when some state changes.

This can be done by adding ProviderListener widget in our build() method:

Widget build(BuildContext context, ScopedReader watch) { final counter = watch(counterStateProvider); // 1. Add parent ProviderListener` return ProviderListener<StateController<int>>( // 2. Specify which provider we want to listen to provider: counterStateProvider, // 3. Run some imperative code in the onChange callback onChange: (context, counterState) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Value is ${counterState.state}')), ); }, child: Scaffold(...) ); }

In this case, the onChange callback gives us the state of our provider, and we use it to show a SnackBar.

ProviderListener is the equivalent of BlocListener from the flutter_bloc package.

StateNotifierProvider

Provider and StateProvider are sufficient for simple use cases like the counter example.

But in more complex apps we often need to store some state along with some business logic outside our widget classes.

The StateNotifier package offers a good solution for this. If you're not familiar with StateNotifier, you can read my previous tutorial about Freezed & StateNotifier.

Because flutter_riverpod depends on the state_notifier package, we can use it without adding it explicitly in our pubspec.yaml file.

As an example, let's see how to create a clock using StateNotifier and the Timer class:

import 'dart:async'; class Clock extends StateNotifier<DateTime> { // 1. initialize with current time Clock() : super(DateTime.now()) { // 2. create a timer that fires every second _timer = Timer.periodic(Duration(seconds: 1), (_) { // 3. update the state with the current time state = DateTime.now(); }); } late final Timer _timer; // 4. cancel the timer when finished @override void dispose() { _timer?.cancel(); super.dispose(); } }

Once we have this, we can create a new provider:

final clockProvider = StateNotifierProvider<Clock>((ref) { return Clock(); });

Then, we can add intl as a dependency to our pubspec.yaml and use it in our widget class:

import 'package:intl/intl.dart'; class MyHomePage extends ConsumerWidget { @override Widget build(BuildContext context, ScopedReader watch) { // this time we're watching the provider *state* final currentTime = watch(clockProvider.state); final timeFormatted = DateFormat.Hms().format(currentTime); return Scaffold( body: Center( child: Text( timeFormatted, style: Theme.of(context).textTheme.headline4, ), ), ); } }

Note how this time we're watching the provider's state via watch(clockProvider.state).

And we use DateFormat.Hms() from the intl package to format the time as hh:mm:ss.

If we run the app now we can see the current time updating every second.


This clock example is a minimal use case of StateNotifier. In general, you should always think of StateNotifier as the place where your business logic goes:

Interaction between widget and model classes

When you setup things this way, your widgets can:

  • watch the model's state and rebuild when it changes.
  • call methods in your model classes (using context.read<Model>), which in turn can update the state and (optionally) interact with external services.

Ok so this is how StateNotifierProvider works.

And in addition to this, Riverpod also has a ChangeNotifierProvider. which works in a very similar way.

That is: You can declare a Model class with ChangeNotifier as a mixin. and you can create a

So StateNotifierProvider and ChangeNotifierProvider are very similar.

But ChangeNotifier has various drawbacks that I listed in my previous tutorial, so I recommend using StateNotifier and StateNotifierProvider instead.

Next let's talk about

FutureProvider & StreamProvider

When we work with asynchronous code we often use some Future or Stream-based APIs.

By using Flutter's FutureBuilder and StreamBuilder widgets we can rebuild our UI when some asynchronous data changes.

Though these widgets are quite clunky to use.

For example, when we use StreamBuilder we need to perform multiple checks on the builder's snapshot to map our data to the UI.

final stream = Stream.fromIterable([21, 42]); StreamBuilder<int>( stream: stream, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.active) { if (snapshot.hasData) { return MyWidget(snapshot.data); // data } else if (snapshot.hasError) { return MyErrorWidget(snapshot.error); // error } else { return Text('No data'); // no data } } else { return CircularProgressIndicator(); // loading } } )

Luckily, Riverpod comes to the rescue with FutureProvider and StreamProvider. 💪

Here's how we can declare them:

final futureProvider = FutureProvider<int>((ref) { return Future.value(36); }); final streamProvider = StreamProvider<int>((ref) { return Stream.fromIterable([36, 72]); });

And here's how to use them:

class MyHomePage extends ConsumerWidget { @override Widget build(BuildContext context, ScopedReader watch) { final streamAsyncValue = watch(streamProvider); // final futureAsyncValue = watch(futureProvider); return Scaffold( body: Center( child: asyncValue.when( data: (data) => Text('Value: $data'), loading: () => CircularProgressIndicator(), error: (e, st) => Text('Error: $e'), ), ), ); } }

When we watch a FutureProvider or StreamProvider we get an AsyncValue<T>, which is a type that comes from the Freezed package.

And then we can use the when() method to map the data, loading, and error states to different widgets:

asyncValue.when( data: (data) => Text('Value: $data'), loading: () => CircularProgressIndicator(), error: (e, st) => Text('Error: $e'), )

Much easier than using StreamBuilder. 🙏


This concludes our overview of the core features of Riverpod. And you can already start using them to get some practice.

But Riverpod also has some advanced features that set it apart from other state management solutions.

So let's take a deeper dive and learn about them.

autoDispose

If we're working with FutureProvider or StreamProvider, we'll want to dispose of any listeners when our provider is no longer in use.

This is easily done by adding an .autoDispose modifier to our provider:

final streamProvider = StreamProvider.autoDispose<int>((ref) { return Stream.fromIterable([36, 72]); });

If we're working with streams that come from Firebase, this is all we need.

Another use case is when we're using FutureProvider as a wrapper for an HTTP request that fires when the user enters a new screen. And we want to cancel the HTTP request if the user leaves the screen before the request is completed.

We can use ref.onDispose() to perform some custom cancellation logic:

final myProvider = FutureProvider.autoDispose((ref) async { // An object from package:dio that allows cancelling http requests final cancelToken = CancelToken(); // When the provider is destroyed, cancel the http request ref.onDispose(() => cancelToken.cancel()); // Fetch our data and pass our `cancelToken` for cancellation to work final response = await dio.get('path', cancelToken: cancelToken); // If the request completed successfully, keep the state ref.maintainState = true; return response; });

We can also set the maintainState flag to preserve the state so that the request does not fire again if the user leaves and re-enters the same screen.

See the autoDispose documentation for more details on this.

family

.family is another modifier that we can use pass additional values to our providers.

final streamProvider = StreamProvider.autoDispose.family<int, int>((ref, offset) { return Stream.fromIterable([36 + offset, 72 + offset]); });

This works by adding a second type annotation and an additional argument that we can use inside the provider body.

And then, we can just pass the value we want to the provider inside the watch() call:

final streamAsyncValue = watch(streamProvider(10));

The family modifier has many practical applications.

For example, consider this code:

// some model class class Item { Item(this.id, this.name); final String id; final String name; /* many other properties here */ } class Database { Stream<Item> itemStream(String itemId) { /* return a single item matching the given id */ } }

Here we have a Database class that has access to a collection of items, and we can use the itemStream() method to get a single item by id.

We can then create some providers:

final databaseProvider = Provider<Database>((ref) => Database()); // 1. add an itemId argument with a family modifier final itemStreamProvider = StreamProvider.autoDispose.family<Item, String>((ref, itemId) { final database = ref.watch(databaseProvider); // 2. pass the itemId to the database method return database.itemStream(itemId); });

In this case the itemStreamProvider can get the database via ref.watch() (more on this later), and uses the itemId as an argument for the itemStream() method.

Then, we could create an ItemWidget that consumes this data:

class ItemWidget extends ConsumerWidget { const ItemWidget({required this.itemId}); final String itemId; @override Widget build(BuildContext context, ScopedReader watch) { final itemAsyncValue = watch(itemStreamProvider(itemId)); return itemAsyncValue.when( data: (job) => Text(job.name), loading: () => Container(), error: (_, __) => Container(), ); } }

A common use case for this is when the user selects an item inside a ListView, and we want to reveal a detail screen that needs to show additional information for that item.

Note: in some cases you may need to pass more than one value to a family. This is not supported by Riverpod. To work around this you can create a custom data class to hold all the properties you need. Alternatively you can pass a map of key-value pairs, though this is not type-safe.

Dependency overrides with Riverpod

Sometimes we want to create a Provider to store a value or object that is not immediately available.

For example, we can only get a SharedPreferences instance with a Future-based API:

final sharedPreferences = await SharedPreferences.getInstance();

But we can't return this inside a synchronous Provider:

final sharedPreferencesProvider = Provider<SharedPreferences>((ref) => SharedPreferences.getInstance()); // The return type Future<SharedPreferences> isn't a 'SharedPreferences', // as required by the closure's context.

Instead, we have to initialize this provider by throwing an UnimplementedError:

final sharedPreferencesProvider = Provider<SharedPreferences>((ref) { throw UnimplementedError(); });

And when the object we need is available, we can set a dependency override for our provider inside the ProviderScope widget:

Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); final sharedPreferences = await SharedPreferences.getInstance(); runApp(ProviderScope( overrides: [ // override the previous value with the new object sharedPreferencesProvider.overrideWithValue(sharedPreferences), ], child: MyApp(), )); }

As a result, the sharedPreferencesProvider object can be watched anywhere in our app, without using any Future-based APIs.

This example used the ProviderScope at the root of the widget tree, but we can also create nested ProviderScope widgets if needed.

Combining Providers with Riverpod

Providers can depend on other providers.

Consider this example:

final onboardingViewModelProvider = StateNotifierProvider<OnboardingViewModel>((ref) { final sharedPreferences = ref.watch(sharedPreferencesProvider); return OnboardingViewModel(sharedPreferences); });

Here we define an onboardingViewModelProvider provider that depends on the sharedPreferencesProvider we created above.

By using ref.watch() we ensure that the provider is updated when the parent provider changes. As a result, any dependent widgets are rebuilt.

We can then pass the sharedPreferences object as a constructor argument to a view model class based on StateNotifier:

class OnboardingViewModel extends StateNotifier<bool> { OnboardingViewModel(this.sharedPreferences) : super(sharedPreferences.getBool('onboardingComplete') ?? false); final SharedPreferences sharedPreferences; // OnboardingViewModel methods here }

With Riverpod, we can declare providers that depend on other providers outside the widget-tree. This is a great advantage over the Provider package, because our widgets remain simple.

ScopedProvider

We can use ScopedProvider when we need a provider that may behave differently for a specific part of the application.

An example of this is when we have a ListView that shows a list of products, and each item needs to know the correct product id or index:

class ProductList extends StatelessWidget { @override Widget build(BuildContext context) { return ListView.builder( itemBuilder: (_, index) => ProductItem(index: index), ); } }

In the code above, we pass the builder's index as a constructor argument to the ProductItem widget:

class ProductItem extends StatelessWidget { const ProductItem({Key? key, required this.index}) : super(key: key); final int index; @override Widget build(BuildContext context) { // do something with the index } }

This works but if the ListView rebuilds, all of its children will rebuild too.


An alternative way of doing this is to use a ScopedProvider:

// 1. Declare a ScopedProvider final currentProductIndex = ScopedProvider<int>((_) => throw UnimplementedError()); class ProductList extends StatelessWidget { @override Widget build(BuildContext context) { return ListView.builder(itemBuilder: (context, index) { // 2. Add a parent ProviderScope return ProviderScope( overrides: [ // 3. Add a dependency override on the index currentProductIndex.overrideWithValue(index), ], // 4. return a **const** ProductItem with no constructor arguments child: const ProductItem(), ); }); } } // 5. Access the index via ScopedReader class ProductItem extends ConsumerWidget { @override Widget build(BuildContext context, ScopedReader watch) { final index = watch(currentProductIndex); // do something with the index } }

In this case:

  • we can create a ScopedProvider that throws UnimplementedError by default.
  • we can override its value by adding a parent ProviderScope to the ProductItem widget.
  • we can watch the index inside the ProductItem's build() method.

This is better for performance because we can create ProductItem() as a const widget in the ListView.builder. This means that even if the ListView rebuilds, our ProductItem will not rebuild unless its index has changed.

When to use watch vs read with Riverpod

In the examples above, we have used watch() every time we needed to read the value of a provider inside a build() method.

This is the right thing to do because if the provider value changes, we will rebuild the widgets that depend on it.

Similarly, we should use ref.watch() when combining multiple providers:

final onboardingViewModelProvider = StateNotifierProvider<OnboardingViewModel>((ref) { final sharedPreferencesService = ref.watch(sharedPreferencesProvider); return OnboardingViewModel(sharedPreferencesService); });

But there are cases when watch() shouldn't be used.

For example, inside the onPressed callback of a button we should use context.read() instead:

void someButtonCallback(BuildContext context) { final sharedPreferences = context.read(sharedPreferencesProvider); sharedPreferences.setBool('onboardingCompleted', true); }

As a rule of thumb:

  • Use watch() inside build() methods.
  • Use ref.watch() inside the body of your providers.
  • Use context.read() inside button callbacks or callback handlers.

As I have explained in my Flutter state management basics, UI is a function of state:

UI = f(state)

So we should rebuild the UI when the state changes, and watch() gives us an elegant way of doing this.

On the other hand, widgets can use events or method calls to notify the relevant model classes or repositories when a user interaction has occurred, and they can use context.read() to get the objects they need.

Note: There is an open proposal to change the syntax for listening to providers with Riverpod. This article will be updated if API changes are introduced in the future.

Testing with Riverpod

When we use Riverpod, providers are global but their state isn't.

The state of a provider is stored inside a ProviderContainer, an object that is implicitly created by ProviderScope.

This means that separate widget tests will never share any state. So there is no need for setUp() and tearDown() methods.

For example, here is a simple counter application that uses a StateProvider to store the counter value:

final counterProvider = StateProvider((ref) => 0); void main() { runApp(ProviderScope(child: MyApp())); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: ElevatedButton( onPressed: () => context.read(counterProvider).state++, child: Consumer(builder: (context, watch, _) { final counter = watch(counterProvider); return Text('${counter.state}'); }), ), ); } }

The code above uses an ElevatedButton to show the counter value and increment it via the onPressed callback.

When writing widget tests, all we need is this:

await tester.pumpWidget(ProviderScope(child: MyApp()));

With this setup, multiple tests don't share any state because each test has a different ProviderScope:

void main() { testWidgets('incrementing the state updates the UI', (tester) async { await tester.pumpWidget(ProviderScope(child: MyApp())); // The default value is `0`, as declared in our provider expect(find.text('0'), findsOneWidget); expect(find.text('1'), findsNothing); // Increment the state and re-render await tester.tap(find.byType(RaisedButton)); await tester.pump(); // The state have properly incremented expect(find.text('1'), findsOneWidget); expect(find.text('0'), findsNothing); }); testWidgets('the counter state is not shared between tests', (tester) async { await tester.pumpWidget(ProviderScope(child: MyApp())); // The state is `0` once again, with no tearDown/setUp needed expect(find.text('0'), findsOneWidget); expect(find.text('1'), findsNothing); }); }

How to mock and override dependencies in tests

Many applications need to call REST APIs or communicate with external services.

For example, here is a MoviesRepository that we can use to get a list of favourite movies:

class MoviesRepository { Future<List<Movie>> favouriteMovies() async { // get data from the network or local database } }

And we can create create a moviesProvider to get the data we need:

final moviesRepositoryProvider = Provider((ref) => MoviesRepository()); final moviesProvider = FutureProvider<List<Movie>>((ref) { final repository = ref.watch(moviesRepositoryProvider); return repository.favouriteMovies(); });

When writing widget tests, we want to replace our MoviesRepository with a mock that returns a canned response rather than making a network call.

As we have seen, we can use dependency overrides to change the behaviour of a provider by replacing it with a different implementation.

So we can implement a MockMoviesRepository:

class MockMoviesRepository implements MoviesRepository { @override Future<List<Movie>> favouriteMovies() { return Future.value([ Movie(id: 1, title: 'Rick & Morty', posterUrl: 'https://nnbd.me/1.png'), Movie(id: 2, title: 'Seinfeld', posterUrl: 'https://nnbd.me/2.png'), ]); } }

And in our widget tests we override the repository provider:

void main() { testWidgets('Override moviesRepositoryProvider', (tester) async { await tester.pumpWidget( ProviderScope( overrides: [ moviesRepositoryProvider .overrideWithProvider(Provider((ref) => MockMoviesRepository())) ], child: MoviesApp(), ), ); }); }

As a result, the MoviesApp() widget will load the data from the MockMoviesRepository when the tests run.

This setup still works if you use mockito in your tests. You can stub your mock methods to return values or throw exceptions, and verify that certain methods are called on your mocks.

For more details about testing with Riverpod, see the official documentation.

Logging with ProviderObserver

Monitoring state changes is very useful in many apps.

And Riverpod includes a ProviderObserver that we can subclass to implement a Logger:

class Logger extends ProviderObserver { @override void didUpdateProvider(ProviderBase provider, Object? newValue) { print('[${provider.name ?? provider.runtimeType}] value: $newValue'); } }

We can enable logging for the entire app by adding the Logger to the list of observers inside the ProviderScope:

void main() { runApp( ProviderScope(observers: [Logger()], child: MyApp()), ); }

To improve the output of the logger, we can add a name to our providers:

final counterStateProvider = StateProvider<int>((ref) { return 0; }, name: 'counter');

And if needed, we can tweak the output of the logger based on the values being observed:

class Logger extends ProviderObserver { @override void didUpdateProvider(ProviderBase provider, Object? newValue) { if (newValue is StateController<int>) { print( '[${provider.name ?? provider.runtimeType}] value: ${newValue.state}'); } } }

ProviderObserver is versatile and we can configure our logger to only log values that match a certain type or provider name. Or we can use a nested ProviderScope and only log values inside a specific widget subtree.

This way we can evaluate state changes and monitor widget rebuilds without putting print() statements all over the place.

ProviderObserver is similar to the BlocObserver widget from the flutter_bloc package.

Conclusion

Riverpod borrows the best features of Provider and adds many benefits that make it easier and safer to manage state in our apps.

If you have an existing app that uses Provider, it is reasonably easy to migrate it to Riverpod.

For reference, I have ported my Starter Architecture App from Provider to Riverpod in less than one day. Check this updated tutorial for more information:

I have also built a Movie App that supports Provider, Riverpod, and flutter_bloc. You can browse the source code to learn about their differences.

For more information about Riverpod, check out the README on pub.dev and read the official documentation.

Happy coding!

Want more?

Support my work and fast-track your Flutter learning with my in-depth courses.