Flutter architecturally is pretty agnostic about how one wires the state and business logic into the Flutter UI. It has no “standard” way of doing it although it does provide the foundational architecture to build declarative UIs. Most people use a framework on top of that to achieve it in a more intuitive and streamlined way. One such popular framework is Riverpod , “A Reactive Caching and Data-binding Framework”. I’ve recently switched from another framework to this and have enjoyed the experience a lot. However there are some nuances to the nature of the lifecyle of the components which I have found myself getting confused on half way through an implementation. These aren’t complicated concepts when looked at in isolation. It’s just something that I keep having to “relearn” if I haven’t seen it in awhile.
I thought it’d be good to document it for myself, and by extension for anyone else. This won’t cover the basics of Riverpod like setup, design pattern best practices, et cetera. except as needed to explore the concept of the lifecycle of the Riverpod components. Also, I am not a Riverpod expert so this is my relative new-to-the-framework point of view as well. If anything stands out to experts as being an over-simplification to the point of being wrong or adding confusion to what is happening please ping me on my socials. You can find the completed demo in this GitLab repo directory
One other note, I am intentionally not talking about the various names of the Riverpod provider types that are being created here. It is important to learn them but out of scope. It was a far more important thing when you don’t use the Riverpod code generators. With the code generators however I find that trying to determine what is the most appropriate one or not is handled by the generators itself. Trying to parse out the differences between them caused more confusion than insight to me when I was getting started. Please see the documentation to fully learn the Riverpod framework.
A Most Basic Riverpod Provider
Riverpod is about setting up various types of “providers” that can help you perform operations, provide/manage state, propagate notifications about state change, etc. Making a provider is extremely simple when using the Riverpod code generation tools. Here is a most basic “Hello World” provider:
@riverpod
String hello(Ref ref) {
return 'Hello World!';
}
The ref
objects are used to allow you to compose providers referring to other providers. Otherwise this looks like a pretty straight forward function. If we look at how we would use it in a widget or another provider we’d get its value in one of two ways:
// to read the value
final h1 = ref.read(helloProvider);
// to read and watch for changes to the value
final h2 = ref.watch(helloProvider);
Riverpod allows us to just read a value one time or to have the calling component watch for changes to the provider state. If a change does happen then our referring widget/provider will automatically rebuild itself in the latter case. Let’s explore the practicalities of this with a quick app.
The Demo App
The core of the demo app is a pretty standard boiler plate Flutter hierarchy but using a Riverpod specific version of the StatelessWidget called “ConsumerWidget” (there is a corresponding StatefulWidget equivalent as well). Inside of it we will have a Column
widget with each of the provider references we are going to create, and later some buttons for updating those states. Initially the app looks like this:
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
title: 'Riverpod Lifecycle Demo',
home: MyHomePage(),
);
}
}
class MyHomePage extends ConsumerWidget {
const MyHomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
debugPrint('[MyHomePage] Build');
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(ref.watch(helloProvider)),
],
),
),
);
}
}
We are mostly going to be looking at the array of “children” widgets, and later a similar collection of buttons. We will add Text widgets that pull the value of the various providers to illustrate the lifecycles:
children: [
Text(ref.watch(helloProvider)),
],
We are going to demonstrate these lifecycles by having each of the methods write debug messages out as they are called. So the “hello” provider now looks like:
@riverpod
String hello(Ref ref) {
debugPrint('[HelloProvider] Build');
return 'Hello World!';
}
Hello Provider Lifecycle
Let’s look at some interesting points about Riverpod through the Hello Provider’s usage. When the app runs we will see log output that reads as follows:
flutter: [MyHomePage] Build
flutter: [HelloProvider] Build
You can see this corresponds to the build message at the top of the MyHomePage build
method as well as the line at the top of the provider’s implementation as well. This provides the first insight into how the providers work. Although the helloProvider
is always defined it wasn’t instantiated until it is needed. In this case that happened in our build
method when we assigned a watched value of the provider for our Text widget. What happens if we had two calls into the provider in our home page, so the array of objects reads as:
children: [
Text(ref.watch(helloProvider)),
Text(ref.watch(helloProvider)),
],
Now when we run the app we will see two print outs of “Hello World!” in our UI but the log will read exactly the same:
flutter: [MyHomePage] Build
flutter: [HelloProvider] Build
Why is that? Along with lazy instantiation, Riverpod also keeps track of whether the value of the provider is current or not. If it doesn’t need to be recomputed it’s not going to recompute it. Instead it returns the existing value. In this way stuff like expensive network calls that initialize objects aren’t going to be performed over and over again. The results are cached, with some control over caching duration as well but beyond the scope here, and returned on any subsequent calls.
Passing Arguments To Providers
Providers can also have arguments passed into them. Like with the above instantiation and caching behaviors, Riverpod is smart about remembering the results for a given set of input arguments and only recomputing as necessary. Let’s look at a slightly modified helloProvider
, we will call greetingProvider
:
@riverpod
String greeting(Ref ref, String otherMessage) {
debugPrint('[GreetingProvider] Build for $otherMessage');
return 'Hello World! $otherMessage';
}
This does the same sort of string greeting return but passed back a second message along with “Hello World!” to the caller. Calling into a provider with arguments is a slightly different syntax. We have to pass the requested argument into the provider:
// the hello provider with no arguments
ref.watch(helloProvider)
// the greeting provider that takes one argument
ref.watch(greetingProvider('How are you today?')
We will add a few calls to these new providers to our list of children:
children: [
Text(ref.watch(helloProvider)),
Text(ref.watch(helloProvider)),
Text(ref.watch(greetingProvider('How are you today?'))),
Text(ref.watch(greetingProvider('How are you today?'))),
Text(ref.watch(greetingProvider('What time is it?'))),
Text(ref.watch(greetingProvider('What time is it?'))),
],
Like with the helloProvider
we are calling into these with the same set of arguments twice to show the caching behavior. If we look at the output log from the app now we will see:
flutter: [MyHomePage] Build
flutter: [HelloProvider] Build
flutter: [GreetingProvider] Build for How are you today?
flutter: [GreetingProvider] Build for What time is it?
This time we see two different greetingProvider
log messages, one for each of the different greeting types. But like with helloProvider
once the provider exists and the value is current the second call will just used the cached value rather than recomputing it.
Usages and Lifecycles With State Changing Providers
So far we see some of the lifecycle behavior, and its benefits, for unchanging providers but what about when you have code that needs to initiate changes in a provider? For example, something like if you want the user to be able to refresh a dataset from the server, or change something in the data set. There is a syntax for just such a provider as well. Let’s look at a contrived example of a “Hello At” provider which lets the user specify hello to a person and writes out when that was last transmitted:
@riverpod
class HelloAt extends _$HelloAt {
@override
String build(String othersName) {
debugPrint('[HelloAtProvider] Build for $othersName');
return _buildGreeting();
}
void refresh() {
debugPrint('[HelloAtProvider] update for $othersName');
state = _buildGreeting();
if (othersName == 'Terry') {
ref.invalidateSelf();
}
}
String _buildGreeting() => 'Hello $othersName! @ ${DateTime.now()})';
}
This obviously looks quite a bit different than the above. Now we are explicitly making a class versus something that looks like a function call. Both of these things are generating backing classes for the providers themselves. This class extends the generated class as well, the _$HelloAt
class. At a bare minimum we need to override the build method, just like we would for a Flutter widget. This is the place where we are injecting our state. So if we look at:
String build(String othersName) {
debugPrint('[HelloAtProvider] Build for $othersName');
return _buildGreeting();
}
You can see it looks pretty much the same as the hello and greet provider definitions. The value we return from the build method will be tracked as the state of this provider. We interact with this provider in the Widget build method the same as we do the previous providers:
children: [
Text(ref.watch(helloProvider)),
Text(ref.watch(helloProvider)),
Text(ref.watch(greetingProvider('How are you today?'))),
Text(ref.watch(greetingProvider('How are you today?'))),
Text(ref.watch(greetingProvider('What time is it?'))),
Text(ref.watch(greetingProvider('What time is it?'))),
Text(ref.watch(helloAtProvider('Terry'))),
Text(ref.watch(helloAtProvider('Terry'))),
Text(ref.watch(helloAtProvider('Pat'))),
Text(ref.watch(helloAtProvider('Pat'))),
// Text(ref.watch(seenNumbersProvider).toString()),
// Text(ref.watch(timestampProvider).dateTime.toString())
],
And just like before if we looked at the logging we’d see that we are getting exactly one instance of the provider each for “Terry” as an argument and “Pat” as an argument. Now lets go further and try to change the state. We will do this by adding some floating action buttons to our app. A floating action button allows us to specify an icon or text to a button and have it perform an action when pressed:
FloatingActionButton(
child: const Icon(Icons.refresh),
onPressed: () {
// do something
},
),
We will add a row of these to our app, one for each of the updates we are allowing the user to invoke. Starting off our MyHomePage widget now looks like:
class MyHomePage extends ConsumerWidget {
const MyHomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
debugPrint('[MyHomePage] Build');
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(ref.watch(helloProvider)),
Text(ref.watch(helloProvider)),
Text(ref.watch(greetingProvider('How are you today?'))),
Text(ref.watch(greetingProvider('How are you today?'))),
Text(ref.watch(greetingProvider('What time is it?'))),
Text(ref.watch(greetingProvider('What time is it?'))),
Text(ref.watch(helloAtProvider('Terry'))),
Text(ref.watch(helloAtProvider('Terry'))),
Text(ref.watch(helloAtProvider('Pat'))),
Text(ref.watch(helloAtProvider('Pat'))),
],
),
),
floatingActionButton: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
FloatingActionButton(
child: const Text('T'),
onPressed: () {
ref.read(helloAtProvider('Terry').notifier).refresh();
},
),
FloatingActionButton(
child: const Text('P'),
onPressed: () {
ref.read(helloAtProvider('Pat').notifier).refresh();
},
),
],
),
);
}
}
Let’s look at how we change the “hello at” state on these button presses. We will start by investigating the refresh
method, reprinted below for ease of reference:
void refresh() {
debugPrint('[HelloAtProvider] update for $othersName');
state = _buildGreeting();
if (othersName == 'Terry') {
ref.invalidateSelf();
}
}
Let’s look at some of the interesting things here. First, the core of the refresh is the regeneration of the message with a new timestamp in the _buildGreeting
method which is then assigned to this state
property. What is “state” and where did it come from? The code generation does all the wiring up of all that. But “state” is what we are managing in this. It is initialized by whatever we returned from the build
method. In other methods we can assign new things to it. When we do that Riverpod looks for a change in the value of state
before and after assignment. If there is one then Riverpod notifies anyone that is still watching this instance of this provider for a change. Another thing to notice is our usage of the othersName
property just like we did in the build method. Where did that come from? The code generator again did some work for us. Any of the variables passed into the build method are made available as properties of the same name for us to interact with in any other methods in the class. Lastly, when the name of the person is “Terry” we want to invalidate ourself. What does that mean and is that any different to a state change? There is an important difference, which is why I put it here. We will explore in a moment. First, let’s look at how we call these provider methods. Looking at the buttons you see code in the onPressed
method that looks like:
ref.read(helloAtProvider('Pat').notifier).refresh();
Let’s compare that to reading a value:
final msg = ref.read(helloAtProvider('Pat'));
By adding the .notifier
after the provider reference we will get a reference to the instance of our provider rather than its current state. That then makes the refresh
method available. Some subtleties about this. First, when getting a reference to the provider to make method calls one should always use ref.read
rather than ref.watch
. Second, even if one is getting the value in one place in the code and using methods elsewhere one should call it twice. Do not try to get an instance to the provider and then call the build method directly from there:
// Wrong!
final p = ref.read(helloAtProvider('Pat').notifier);
final msg = p.build('Pat');
p.refresh();
// Right
final msg = ref.read(helloAtProvider('Pat')); //or watch
ref.read(helloAtProvider('Pat').notifier).refresh();
So lets look at the log messages when we have the providers refresh themselves. First lets look at “Pat”, which just changed the state. When we hit the button we will see the screen refresh with the updated values and the log messages:
flutter: [HelloAtProvider] update for Pat
flutter: [MyHomePage] Build
When we initiated the update and changed the state, Riverpod noticed the state change and notified our home widget that there was a change. That caused its build
method to be called. What happens when we do the same thing for Terry? Remember that in this case we are not only changing the state but we are calling ref.invalidateSelf()
to invalidate our provider:
flutter: [HelloAtProvider] update for Terry
flutter: [HelloAtProvider] Build for Terry
flutter: [MyHomePage] Build
We notice this time that both our home widget and the provider were rebuilt. This is an important lifecycle flow difference. A state change simply notifies anyone listening that the provider has a new state. Invalidating it however causes the whole provider to rebuild itself. For this contrived example it doesn’t matter much but there are times where there could be a difference between the two. For example if there is some bootstrapping in the build method that really doesn’t need to be called again just because of a state update et cetera this would get called again. Also, if there a state initialization to a default state that too will happen, thus stomping on any changes that have been made since the last build. When getting started with Riverpod it can sometimes be tempting to just invalidate providers to initiate a rebuild of components when we see that update notifications aren’t firing. Sometimes we get into these predicaments because of nuances of how Riverpod determines state changes with more complex types. That’s what we will explore next.
Manually Telling Riverpod There Is a State Change
Let’s build yet another provider to illustrate some of the state change detection nuances. This SeenNumbers
provider will return an array of numbers which can be incrementally added to on user’s request. Let’s start with this implementation:
@riverpod
class SeenNumbers extends _$SeenNumbers {
@override
List<num> build() {
debugPrint('[SeenNumbersProvider] Build');
return [1, 2, 3];
}
void addNumberNewState() {
debugPrint(
'[SeenNumbersProvider] adding number new state. Before state = $state');
final newNum = state.last + 1;
state = [...state, newNum];
debugPrint(
'[SeenNumbersProvider] adding number new state. After state = $state');
}
}
On the initial build it will always return an array with the three numbers: 1, 2, 3. Each time the user asks for one more by calling into addNumberNewState
it takes the last number and adds one to it. It then creates a new array from the old array and the new number and assigns it to state. Once this state assignment is done our widget that is watching this provider will again be told to rebuild. So the log messages look like:
flutter: [SeenNumbersProvider] adding number new state. Before state = [1, 2, 3]
flutter: [SeenNumbersProvider] adding number new state. After state = [1, 2, 3, 4]
flutter: [MyHomePage] Build
That’s fine for a small array but what if we have a massive array that we don’t want to thrash on. One could decide it’d be better to just modify the existing array doing something like this:
void addNumbersInPlace() {
debugPrint(
'[SeenNumbersProvider] adding number in place. Before state = $state');
final newNum = state.last + 1;
state.add(newNum);
debugPrint(
'[SeenNumbersProvider] adding number in place. After state = $state');
}
If we called this addNumbersInPlace
method instead and look at the log messages we will see:
flutter: [SeenNumbersProvider] adding number in place. Before state = [1, 2, 3]
flutter: [SeenNumbersProvider] adding number in place. After state = [1, 2, 3, 4]
Notice that there is no call to our home widget’s build method. Why? We changed the state didn’t we? Well, we did and we didn’t. The state property assignment is what is doing the heavy lifting for us on determining if the state has changed. By default it does this by looking to see if the previous and the new state are “identical”, meaning pointing to the same instance of an object in memory. Because we changed the array in place it comes back saying that the states are equal since it is the same array. Therefore there is no update registered. This would be true even if we did something like state = state..add(newNum)
. Obviously there is a state change. So what can we do?
Perhaps there is a way to override how that comparison happens? There is, but it will suffer the same problem of the “previous” and the “next” state referring to the exact same object. Therefore even if you override that calculation, which I’ll show how in the next step, you won’t be able to do a comparison with the before state. This is where new Riverpod developers, including me, will sometimes jump to just calling ref.invalidateSelf()
to try to force the refresh. But again, this is wrong because it’ll reset the whole thing back to just being “1, 2, 3” again. Instead we want to call ref.notifyListeners()
to explicitly tell Riverpod there is a state change happening that it has to propagate. Thus our final implementation of this method is:
void addNumbersInPlace() {
debugPrint(
'[SeenNumbersProvider] adding number in place. Before state = $state');
final newNum = state.last + 1;
state.add(newNum);
ref.notifyListeners();
debugPrint(
'[SeenNumbersProvider] adding number in place. After state = $state');
}
Which produces the correct log output:
flutter: [SeenNumbersProvider] adding number in place. Before state = [1, 2, 3, 4, 5, 6]
flutter: [SeenNumbersProvider] adding number in place. After state = [1, 2, 3, 4, 5, 6, 7]
flutter: [MyHomePage] Build
Overriding the Should Notify Calculation
While for the particular case above we wouldn’t be able to manually override the calculation of whether state changed, there are times that we have to do this. Let’s say for example that the state being stored is an object that is returned from another calculation that may have the exact same values as the current object even if it is a different instance. Because it is a different instance even if all the values are the same Riverpod will still flag it as a state change. By overriding the updateShouldNotify
method one can address this problem. For this example lets say there is a wrapper class for times that looks like:
class TimestampWrapper {
final DateTime dateTime;
TimestampWrapper(this.dateTime);
factory TimestampWrapper.now() => TimestampWrapper(DateTime.now());
TimestampWrapper copy() => TimestampWrapper(dateTime);
}
And we have a “Timestamp” provider that includes a copy
method that calls into the current TimestampWrapper’s own copy
method:
@riverpod
class Timestamp extends _$Timestamp {
@override
TimestampWrapper build() {
debugPrint('[TimestampProvider] Build');
return TimestampWrapper.now();
}
void copy() {
debugPrint('[TimestampProvider] copy');
state = state.copy();
}
}
When we wire this version up into our app and have it invoke the Provider’s copy method we will get the log printout:
flutter: [TimestampProvider] copy
flutter: [MyHomePage] Build
As coded the state is going to have the same value. So the widget build here is extraneous. An implementation of the overridden updateShouldNotify
method can fix this. Because our wrapper class didn’t override the equals and hashCode methods if we simply did a previous != next
check we will again be checking if this is the same instance of the class, since that is the default implementation in Dart. In this case we could just implement equals and hashCode but we could also just check for the values directly:
@override
bool updateShouldNotify(TimestampWrapper previous, TimestampWrapper next) {
return previous.dateTime != next.dateTime;
}
Now when we invoke this change the build method isn’t called because our overriden method correctly determined that the state, for our purposes, didn’t change.
Summary
Riverpod is a very useful framework for wiring together business logic and state into our Flutter UI. With the generated code mechanisms in the latest version it is pretty straight forward to get providers up and running in easy ways. By exploring some of the nuances of the lifecycles here it will hopefully be a little clearer of what is happening and why, at least at a functionally useful level. It’s not complicated per se but it can be confusing if you are new to it and up to your elbows developing new code. The source code for the completed demo can be found [in this GitLab repo subdirectory](in this GitLab repo directory .