Riverpod Data Caching and Providers Lifecycle: Full Guide

Source code on GitHub

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:

  1. When we call runApp inside main and give it a top-level ProviderScope
  2. When the CounterWidget widget is first mounted, and we call ref.watch inside the build 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 the counterStateProvider, so it can rebuild itself when the provider state changes (such as when we increment it inside the onPressed 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):

    The observable pattern in Riverpod. By watching a provider, listeners can rebuild themselves when the state changes
    The observable pattern in Riverpod. By watching a provider, listeners can rebuild themselves when the state changes

    Both ref.watch() and ref.listen() can be used to register as a listener to a provider. This is in contrast with ref.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 enclosing ProviderScope 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:

    Left: a ListView showing a list of posts. Right: a post detail page that is shown when we select an item from the list
    Left: a ListView showing a list of posts. Right: a post detail page that is shown when we select an item from the list

    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 the postId as an argument and uses it when fetching the data from the fetchPostProvider. Alternatively, we could have passed the Post object directly from the ListView builder (without using the fetchPostProvider 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:

    Left: a ListView showing a list of posts. Right: a post detail page that is shown when we select an item from the list
    Left: a ListView showing a list of posts. Right: a post detail page that is shown when we select an item from the list

    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:

    With keepAlive: false, the state is disposed as soon as the last listener is removed. With keepAlive: true, the state is never disposed, and remains cached until the app is killed
    With keepAlive: false, the state is disposed as soon as the last listener is removed. With keepAlive: true, the state is never disposed, and remains cached until the app is killed

    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:

    Left: a ListView showing a list of posts. Right: a post detail page that is shown when we select an item from the list
    Left: a ListView showing a list of posts. Right: a post detail page that is shown when we select an item from the list

    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 destroyed
    • ref.onCancel: triggered when the last listener of the provider is removed
    • ref.onResume: triggered when a provider is listened again after it was paused
    • ref.onAddListener: triggered whenever a new listener is added to the provider
    • ref.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:

    The lifecycle of a provider can be represented with three states: non cached, cached (active), cached (paused). Note that async providers will emit an AsyncLoading state before transitioning to the “cached” state when the initialization is complete
    The lifecycle of a provider can be represented with three states: non cached, cached (active), cached (paused). Note that async providers will emit an AsyncLoading state before transitioning to the “cached” state when the initialization is complete

    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. If keepAlive == false, it returns to the "non cached" state. If keepAlive == 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 (if keepAlive is true) or returns to "non cached" (if keepAlive 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 the NotifierProvider 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 with autoDispose.

    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 (if keepAlive 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. 👇

    New Flutter 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:

    Want More?

    Invest in yourself with my high-quality Flutter courses.

    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 - Full Course

    Flutter Animations Masterclass - Full Course

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