If you've been using Riverpod for some time, you probably know how to declare providers and use them inside your widgets.
You may also know that providers are global, but their state isn't.
But how do providers really work under the hood?
Have you ever wondered:
- When are providers initialized?
- When and how do they get disposed?
- What happens when a widget listens to a provider?
- What is the lifecycle of a provider?
- How does Riverpod do data caching?
This article will answer all these questions and help you:
- better understand the relationship between providers and widgets
- learn how data caching works and how it's related to provider lifecycle events
- choose the most appropriate data caching behaviour according to your needs
It will also help you view Riverpod for what it is: a Reactive Caching and Data-binding Framework that helps you solve complex problems (like data caching) with simple code.
Data caching is a broad topic, so we'll cover cache invalidation and other advanced techniques in a follow-up article.
But for now, we've got plenty to cover already!
Ready? Let's go! 🚀
This article assumes that you already know the basics. If you're new to Riverpod, read this first: Flutter Riverpod 2.0: The Ultimate Guide
Provider Lifecycle Basics: Counter App Example
If we want to deeply understand how providers work, there are many things to consider.
Before we get to the meaty stuff, let's review the basics using a simple counter app as an example:
final counterStateProvider = StateProvider<int>((ref) {
return 0;
});
As we can see, counterStateProvider
is declared as a global variable, and inside the provider body we return its initial state (the integer value 0
).
Then, we can create the following ConsumerWidget
:
class CounterWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// 1. watch the provider and rebuild when the value changes
final counter = ref.watch(counterStateProvider);
return ElevatedButton(
// 2. use the value
child: Text('Value: $counter'),
// 3. increment the counter when the button is pressed
onPressed: () => ref.read(counterStateProvider.notifier).state++,
);
}
}
Inside the build
method, we watch the provider declared above, and use it to show the counter value inside a Text
widget.
And we should remember to wrap our app with a ProviderScope
:
void main() {
runApp(ProviderScope(
child: MaterialApp(
home: CounterWidget(),
),
));
}
The example is simple, but can you figure out when the provider is initialized?
Take a minute to think about it. I'll wait. ⏱
When is the provider initialized?
Here are two plausible answers:
- When we call
runApp
insidemain
and give it a top-levelProviderScope
- When the
CounterWidget
widget is first mounted, and we callref.watch
inside thebuild
method
To get the correct answer, we don't have to guess.
In fact, we can add some print statements to our code:
void main() {
print('Inside main');
runApp(ProviderScope(
child: MaterialApp(
home: CounterWidget(),
),
));
}
final counterStateProvider = StateProvider<int>((ref) {
print('counterStateProvider initialized');
return 0;
});
class CounterWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
print('CounterWidget build');
final counter = ref.watch(counterStateProvider);
return ElevatedButton(
child: Text('Value: $counter'),
onPressed: () => ref.read(counterStateProvider.notifier).state++,
);
}
}
If we run this code in Dartpad, we'll see this output in the console log:
Inside main
CounterWidget build
counterStateProvider initialized
This means that the counterStateProvider
is initialized only when we call ref.watch(counterStateProvider)
inside the widget.
And that's because all Riverpod providers are lazy-loaded.
But let's dig deeper since some interesting stuff happens under the hood.
Using print statements and debug breakpoints is a great way to explore the runtime behaviour of your app. I've diagnosed and fixed countless bugs with these two tools, so make sure you use them too. 👍
Registering a Listener
Let's take a closer look at the CounterWidget
:
class CounterWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// 1. watch the provider and rebuild when the value changes
final counter = ref.watch(counterStateProvider);
return ElevatedButton(
// 2. use the value
child: Text('Value: $counter'),
// 3. increment the counter when the button is pressed
onPressed: () => ref.read(counterStateProvider.notifier).state++,
);
}
}
When we call ref.watch(counterStateProvider)
inside the build
method, two things happen:
- We get the provider's state (the counter value), so we can show it in the UI
- The
CounterWidget
becomes a listener of thecounterStateProvider
, so it can rebuild itself when the provider state changes (such as when we increment it inside theonPressed
callback)
Something interesting happens inside the provider too:
- The provider's state is initialized when the first listener is registered
- Every time the state changes, all listeners will be notified so they can update/rebuild themselves
The Observable Pattern
The counter app above only had one provider and one listener widget.
But providers can have more than one listener.
And providers can listen to other providers too.
In fact, Riverpod builds upon the observable pattern, where a provider is an observable, and other providers or widgets are the observers (listeners):
Both
ref.watch()
andref.listen()
can be used to register as a listener to a provider. This is in contrast withref.read()
, which only does a one-time read and does not register a listener.
When are providers disposed?
So far, we have learned that providers are lazy-loaded and are only initialized when a listener is attached (either via ref.watch
or ref.listen
).
But what happens if the CounterWidget
is removed from the widget tree?
Well, that depends on whether we have declared the provider with autoDispose
(or the new keepAlive
flag).
In other words, if we declare the provider like this:
// without autoDispose
final counterStateProvider = StateProvider<int>((ref) {
return 0;
});
Then the provider will maintain the state and keep it in memory until the enclosing ProviderScope
is disposed (which happens when the user or OS kills the app if we have a top-level ProviderScope
inside main
).
But we could also declare the provider like this:
// with autoDispose
final counterStateProvider = StateProvider.autoDispose<int>((ref) {
return 0;
});
In this case, the autoDispose
modifier tells Riverpod that the provider's state should be disposed once the last listener has been removed.
Let me say this again:
- If we declare a provider without
autoDispose
, its state will remain alive until the enclosingProviderScope
is disposed (unless we explicitly dispose it in some other way - more on this below) - If we declare a provider with
autoDispose
, its state will be disposed as soon as the last listener is removed (typically when the widget is unmounted)
And now that we understand the basics, let's explore a more interesting example. 👇
A More Complex Example: Items List → Detail Page
On mobile, it's very common to show a list of items and navigate to a detail page when we select an item.
Here's an example based on the jsonplaceholder API:
The full source code for this example can be found on this GitHub repo.
To fetch the data needed to show the UI, we may create a PostsRepository
such as this:
// Posts repository based on the Dio http client.
// We'll see how to use [CancelToken] later.
class PostsRepository {
PostsRepository({required this.dio});
final Dio dio;
// Fetch all posts
Future<List<Post>> fetchPosts({CancelToken? cancelToken}) { ... }
// Fetch a specific post by ID
Future<Post> fetchPost(int postId, {CancelToken? cancelToken}) { ... }
}
And we can use the new @riverpod
syntax to create some functions for fetching the data:
// used to generate a postsRepositoryProvider (as a regular Provider)
@riverpod
PostsRepository postsRepository(PostsRepositoryRef ref) {
return PostsRepository(dio: ref.watch(dioProvider));
}
// used to generate a fetchPostsProvider (as a FutureProvider)
@riverpod
Future<List<Post>> fetchPosts(FetchPostsRef ref) {
return ref.watch(postsRepositoryProvider).fetchPosts();
}
// used to generate a fetchPostProvider (as a FutureProvider.family)
@riverpod
Future<Post> fetchPost(FetchPostRef ref, int postId) {
return ref.watch(postsRepositoryProvider).fetchPost(postId);
}
The syntax above relies on the new Riverpod Generator package. To learn more, read: How to Auto-Generate your Providers with Flutter Riverpod Generator.
Then, we can watch the fetchPostsProvider
inside a PostsList
widget (styling omitted for simplicity):
class PostsList extends ConsumerWidget {
const PostsList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// watch the fetchPostsProvider
final postsAsync = ref.watch(fetchPostsProvider);
// use it to show a list of posts
return postsAsync.when(
data: (posts) => ListView.separated(
itemCount: posts.length,
itemBuilder: (context, index) {
final post = posts[index];
return ListTile(
leading: Text(post.id.toString(),
title: Text(post.title),
// on tap, navigate to the [PostDetailsScreen]
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (context) => PostDetailsScreen(postId: post.id),
)),
);
},
separatorBuilder: (context, index) => const Divider(),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, st) => Center(child: Text(e.toString())),
);
}
}
And when a list item is tapped, we can navigate to a new page where we show the post details:
class PostDetailsScreen extends ConsumerWidget {
const PostDetailsScreen({super.key, required this.postId});
final int postId;
@override
Widget build(BuildContext context, WidgetRef ref) {
// watch the fetchPostProvider, passing the postId as an argument
final postsAsync = ref.watch(fetchPostProvider(postId));
return Scaffold(
appBar: AppBar(title: Text('Post $postId')),
body: postsAsync.when(
// show the post details
data: (post) => Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(post.title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 32),
Text(post.body),
],
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, st) => Center(child: Text(e.toString())),
),
);
}
}
Note how the
PostDetailsScreen
takes thepostId
as an argument and uses it when fetching the data from thefetchPostProvider
. Alternatively, we could have passed thePost
object directly from theListView
builder (without using thefetchPostProvider
at all). This is more efficient but can't be used for URL-based navigation since the URL is just a string, and we can't encode custom objects inside the path.
Once again, we can use the widgets above to create these two pages and navigate back and forth:
Next, let's figure out when the providers are initialized and disposed.
When are Riverpod providers disposed?
To figure out what happens at runtime, let's add some print
statements to our provider:
// used to generate a fetchPostProvider (as a FutureProvider)
@riverpod
Future<Post> fetchPost(FetchPostRef ref, int postId) {
print('init: fetchPost($postId)');
ref.onDispose(() => print('dispose: fetchPost($postId)'));
return ref.watch(postsRepositoryProvider).fetchPost(postId);
}
And we can also print something in the fetchPost
method of the PostsRepository
:
Future<Post> fetchPost(int postId, {CancelToken? cancelToken}) async {
print('dio: fetchPost($postId)');
// await dio.get(...)
}
If we run the app and select the first item from the list, this log will appear:
flutter: init: fetchPost(1)
flutter: dio: fetchPost(1)
And if we navigate back, we get this:
flutter: dispose: fetchPost(1)
Likewise, if we select another item and get back again, we'll get three more logs:
flutter: init: fetchPost(2) <-- tap on second item
flutter: dio: fetchPost(2)
flutter: dispose: fetchPost(2) <-- navigate back
If we want, we can select the first item one more time and navigate back.
And once we're done, the full console log should look like this:
flutter: init: fetchPost(1) <-- tap on first item
flutter: dio: fetchPost(1)
flutter: dispose: fetchPost(1) <-- navigate back
flutter: init: fetchPost(2) <-- tap on second item
flutter: dio: fetchPost(2)
flutter: dispose: fetchPost(2) <-- navigate back
flutter: init: fetchPost(1) <-- tap on first item
flutter: dio: fetchPost(1)
flutter: dispose: fetchPost(1) <-- navigate back
The console output tells us that:
- every time we select an item, we fetch (and cache) the data from the network
- as soon as we navigate back, the cached data is disposed
In other words, providers are auto-disposed by default if we use the @riverpod
syntax, meaning that we don't keep the state once it's no longer needed:
// if we use @riverpod, the state will be disposed after the last listener has been removed
@riverpod
Future<Post> fetchPost(FetchPostRef ref, int postId) {
print('init: fetchPost($postId)');
ref.onDispose(() => print('dispose: fetchPost($postId)'));
return ref.watch(postsRepositoryProvider).fetchPost(postId);
}
When we watch a
FutureProvider
above for the first time, it will fetch the data from the network and cache it for later use. If we watch the provider again (before it has been disposed), a new network request won't be made, and the cached data will be returned instead.
Keeping state with keepAlive
If we want to keep the state, we can annotate our provider with keepAlive: true
:
// set keepAlive: true to keep the state after the last listener has been removed
@Riverpod(keepAlive: true)
Future<Post> fetchPost(FetchPostRef ref, int postId) {
print('init: fetchPost($postId)');
ref.onDispose(() => print('dispose: fetchPost($postId)'));
return ref.watch(postsRepositoryProvider).fetchPost(postId);
}
// also declare this provider with keepAlive to prevent a compile error:
@Riverpod(keepAlive: true)
PostsRepository postsRepository(PostsRepositoryRef ref) {
return PostsRepository(dio: ref.watch(dioProvider));
}
If we run the app now, we can navigate to the first item:
flutter: init: fetchPost(1)
flutter: dio: fetchPost(1)
But if we return to the list of items, no dispose log is printed on the console.
And if we select the first item again, the data is available immediately, and no loading UI is presented.
That's because with keepAlive
, Riverpod holds on to each Post
object we have fetched and can return it immediately if we ask again for the same postId
.
How does keepAlive work?
Using keepAlive: true
, we have implemented a cache that never expires (for as long as the app is running).
On the other hand, if we use keepAlive: false
(which is the default), Riverpod will cache the data only while there are active listeners. But as soon as the last listener is removed, the data will be disposed.
In other words:
- with
keepAlive: true
, the data stays in memory forever (even when no longer needed) - with
keepAlive: false
, we re-fetch the data from the network every time (after the provider has been disposed)
These seem like two extremes of a wide spectrum:
But can we somehow customize the caching strategy and get the best of both worlds?
Caching with Timeout
If desired, we can call ref.keepAlive()
inside the provider and use it to set a timeout-based cache.
Here's how we may implement this:
@riverpod
Future<Post> fetchPost(FetchPostRef ref, int postId) {
// some logs for monitoring purposes
print('init: fetchPost($postId)');
ref.onCancel(() => print('cancel: fetchPost($postId)'));
ref.onResume(() => print('resume: fetchPost($postId)'));
ref.onDispose(() => print('dispose: fetchPost($postId)'));
// get the [KeepAliveLink]
final link = ref.keepAlive();
// a timer to be used by the callbacks below
Timer? timer;
// An object from package:dio that allows cancelling http requests
final cancelToken = CancelToken();
// When the provider is destroyed, cancel the http request and the timer
ref.onDispose(() {
timer?.cancel();
cancelToken.cancel();
});
// When the last listener is removed, start a timer to dispose the cached data
ref.onCancel(() {
// start a 30 second timer
timer = Timer(const Duration(seconds: 30), () {
// dispose on timeout
link.close();
});
});
// If the provider is listened again after it was paused, cancel the timer
ref.onResume(() {
timer?.cancel();
});
// Fetch our data and pass our `cancelToken` for cancellation to work
return ref
.watch(postsRepositoryProvider)
.fetchPost(postId, cancelToken: cancelToken);
}
The code above uses the onDispose
, onCancel
, and onResume
lifecycle callbacks to implement some custom timeout logic and cancel any in-flight network requests if we no longer need the response (handy if we navigate away before the request completes).
Once again, let's use this sample app as a reference:
If we run the app with the changes above, we can select the first item to reveal the post details:
flutter: init: fetchPost(1)
flutter: dio: fetchPost(1)
As soon as we navigate back, we get this:
flutter: cancel: fetchPost(1)
And if we select the same item again before the 30 second timeout, we get this:
flutter: resume: fetchPost(1)
Otherwise, we get this:
flutter: dispose: fetchPost(1)
This means that we won't fetch the post data again if we open the details page again within the 30 seconds limit.
Provider Lifecycle Callbacks
In the caching example above, we've used three provider lifecycle callbacks to execute custom dispose logic at runtime.
In total, there are five different callbacks that we can use:
ref.onDispose
: triggered right before the provider is destroyedref.onCancel
: triggered when the last listener of the provider is removedref.onResume
: triggered when a provider is listened again after it was pausedref.onAddListener
: triggered whenever a new listener is added to the providerref.onRemoveListener
: triggered whenever a new listener is removed from the provider
All these callbacks and many other useful methods are documented in the API docs of the Ref class.
To better understand when these callbacks are triggered, consider this diagram which represents all the possible states of a provider:
Here's how it works:
Non Cached
- When the app starts, each provider's state is "non cached" by default since there are no active listeners.
- When a listener is added, the provider transitions to the "active" state and remains there as long as there is at least one listener.
Cached (active)
- When we add more listeners, the cached data is returned immediately
- When we remove listeners, nothing changes as long as there is one listener left
- When the last listener is removed, the provider checks the
keepAlive
flag. IfkeepAlive == false
, it returns to the "non cached" state. IfkeepAlive == true
, it goes to the "paused" state.
Cached (paused)
- When the
KeepAliveLink
is closed, it returns to the "non cached" state. - When a listener is added, it returns to the "active" state.
Note: it's also possible for a provider to go from "non-cached" to "paused" if we do a one-time read with
ref.read()
. After that, the provider stays in "paused" state (ifkeepAlive
is true) or returns to "non cached" (ifkeepAlive
is false).
The diagram above is a conceptual model for how providers transition between different states. It doesn't account for some edge cases that can happen when dependencies change, but for our purposes, it will do.
With this mental model, it should be easier to understand how providers behave.
And if you want to inspect their runtime behaviour, add some print statements inside any providers that you want to monitor:
ref.onCancel(() => print('cancel: fetchPost($postId)'));
ref.onResume(() => print('resume: fetchPost($postId)'));
ref.onDispose(() => print('dispose: fetchPost($postId)'));
Then, you can play around with your app and have fun looking at the console logs. 😎
Answers to common questions
So far, we've covered a lot of ground and explored how data caching works in Riverpod.
Before we wrap up, let's try to answer some common questions. 👇
When should we set keepAlive: false?
We can use keepAlive
to customize the caching behaviour inside any provider (not just FutureProvider
).
By default, we annotate providers with the @riverpod
syntax, which is the same as @Riverpod(keepAlive: false)
.
This is a sensible default value because it guarantees that a provider is disposed as soon as all its listeners are removed (thus not wasting memory).
Here are some other examples where we should use keepAlive: false
(or the equivalent autoDispose
):
- When we create a notifier to manage the state of a single widget. In this scenario, we can use the default
@riverpod
syntax to ensure theNotifierProvider
gets disposed as soon as the widget is unmounted. - When we use a
StreamProvider
and we want the stream connection to be closed as soon as all listener widgets are removed. We can accomplish this withautoDispose
.
For more details about the new
Notifier
classes in Riverpod 2.0, read: How to use Notifier and AsyncNotifier with the new Flutter Riverpod Generator
When should we set keepAlive: true?
If we want to keep some application state in memory and always have it available while the app is running, we should use @Riverpod(keepAlive: true)
.
Another example is when we use Riverpod for dependency injection and create long-lived providers for objects that we want to instantiate just once (such as wrappers for 3rd party APIs like FirebaseAuth
, SharedPreferences
etc.).
Conclusion
We've reached the end of this article, so let's do a summary of the most important points:
- Providers are lazy loaded, and they are initialized when we first use them
- When the last listener of a provider is removed, the provider will be disposed (if
keepAlive
is false) or go to a paused state (ifkeepAlive
is true) - We can use lifecycle callbacks like
onDispose
,onCancel
,onResume
to implement some custom caching logic - We can also use
onDispose
to cancel any in-flight network requests that are no longer needed
The most important takeaway is that Riverpod is a Reactive Caching and Data-binding Framework (just like the docs say). And it helps you solve complex problems with simple code using flexible APIs with sensible defaults.
Since data caching is a broad topic, we'll cover cache invalidation and some other advanced techniques in a follow-up article.
And if you want to go even more in-depth, check out my latest Flutter course where, you'll learn how to build a complete eCommerce app with Riverpod 2.x. 👇
Flutter Foundations Course Now Available
I launched a brand new course that covers state management with Riverpod in great depth, along with other important topics like app architecture, routing, testing, and much more.
You can get it for 33% off with my Black Friday sale until the 29th of November: