In the previous article, we learned how to check if a widget is mounted after performing some asynchronous work.
An example of this is when we submit some form data and attempt to dismiss the page before the operation is complete:
As we have seen, we can handle the form submission with a _submitReview
method that is called from the onPressed
callback of the submit button:
class _LeaveReviewFormState extends ConsumerState<LeaveReviewForm> {
// called from a button callback
Future<void> _submitReview() async {
try {
// create a review object with the rating and comment
final review = Review(rating: _rating, comment: _controller.text);
// obtain the review service from a Riverpod provider
final reviewsService = ref.read(reviewsServiceProvider);
// use it to submit the review (productId is a property from the widget class)
await reviewsService.submitReview(productId: widget.productId, review: review);
// check if the widget is still mounted after the async operation
if (context.mounted) {
// pop the current route (uses GoRouter extension)
context.pop();
}
} catch (e, st) {
// TODO: show alert error
}
}
}
We've also learned that for a better separation of concerns, we can move the business logic into an AsyncNotifier
subclass (from the Riverpod package).
Based on the "leave review" example above, we ended up with this code:
class LeaveReviewController extends AutoDisposeAsyncNotifier<void> {
@override
FutureOr<void> build() {
// nothing to do
}
Future<void> submitReview({
required ProductID productId,
required double rating,
required String comment,
}) async {
// create a review object with the rating and comment
final review = Review(rating: rating, comment: comment);
// obtain the review service from a Riverpod provider
final reviewsService = ref.read(reviewsServiceProvider);
// set the widget state to loading
state = const AsyncLoading();
// submit the review and update the state when done
state = await AsyncValue.guard(() =>
reviewsService.submitReview(productId: productId, review: review));
// TODO: check for mounted
if (state.hasError == false) {
// get the GoRouter instance and call pop on it
ref.read(goRouterProvider).pop();
}
}
}
This works by getting the GoRouter
instance from a provider, so that we can pop the current route:
ref.read(goRouterProvider).pop();
However, if we submit the form and close the page before the asynchronous operation completes, we end up with a "Bad state" error:
To prevent this, we need some way to check if the AsyncNotifier
is mounted.
This may seem like a simple question. But as we're about to see, there's no easy answer. And in this article, we'll explore some solutions and their tradeoffs.
Ready? Let's go!
This article assumes you're already familiar with
AsyncNotifier
. To learn more, read: How to use Notifier and AsyncNotifier with the new Flutter Riverpod Generator
Checking if an AsyncNotifier is mounted
To prevent the "Bad state" error, we could try to write something like this inside our LeaveReviewController
:
final newState = await AsyncValue.guard(someFuture);
if (mounted) { // Error -> Undefined name 'mounted'
// * only set the state if the controller is still mounted
state = newState;
if (state.hasError == false) {
// get the GoRouter instance and call pop on it
ref.read(goRouterProvider).pop();
}
}
Unfortunately, there's currently no plan to add a mounted
property to Notifier/AsyncNotifier, and the code above won't compile.
So let's see what we can do about it. 👇
1. Roll out our own "mounted" property
To focus on the problem at hand, we'll leave the LeaveReviewController
aside and use this simple notifier instead:
// just a simple notifier
class SomeNotifier extends AutoDisposeAsyncNotifier<void> {
@override
FutureOr<void> build() {
}
Future<void> doAsyncWork() async {
state = const AsyncLoading();
final newState = await AsyncValue.guard(someFuture);
// TODO: Check if mounted, then:
state = newState;
}
}
// the corresponding provider
final someNotifierProvider =
AutoDisposeAsyncNotifierProvider<SomeNotifier, void>(
SomeNotifier.new);
As a first attempt, we can try to add a mounted
property as a boolean:
class SomeNotifier extends AutoDisposeAsyncNotifier<void> {
// 1. initialize to true
bool mounted = true;
@override
FutureOr<void> build() {
// 2. set to false on dispose
ref.onDispose(() => mounted = false);
}
Future<void> doAsyncWork() async {
state = const AsyncLoading();
final newState = await AsyncValue.guard(someFuture);
// 3. check before setting the state
if (mounted) {
state = newState;
}
}
}
Warning: using a boolean mounted flag is not recommended, and we'll explore a more reliable solution shortly.
The code above will prevent the "Bad state" error by adding a boolean mounted
property that:
- is set to
true
on initialization - is set to
false
on dispose (using theonDispose
lifecycle callback) - can be checked after awaiting for some async work to complete (note how
newState
is only assigned tostate
ifmounted
is true)
But we have one crucial question: will the mounted
flag inside our notifier be set to false
as soon as the widget is unmounted?
And the answer is yes because we are using an AutoDisposeAsyncNotifier. This guarantees that the provider is disposed as soon as the last listener is removed.
To learn more about the Riverpod providers lifecycle, read: Riverpod Data Caching and Providers Lifecycle: Full Guide
While the solution above is not recommended, it appears to work well in practice but it's not very DRY.
Implementing a NotifierMounted mixin
To avoid copy-pasting all the mounted logic to each notifier in our app, we can create a handy mixin:
mixin NotifierMounted {
bool _mounted = true;
void setUnmounted() => _mounted = false;
bool get mounted => _mounted;
}
Here's now to use it:
class SomeNotifier extends AutoDisposeAsyncNotifier<void>
// 1. add mixin
with NotifierMounted {
@override
FutureOr<void> build() {
// 2. set to false on dispose
ref.onDispose(setUnmounted);
}
Future<void> doAsyncWork() async {
state = const AsyncLoading();
final newState = await AsyncValue.guard(someFuture);
// 3. check before setting the state
if (mounted) {
state = newState;
}
}
}
I've been using this technique without problems in my projects for a while.
However, I recently came across this issue where Remi explained that using a mounted
property is misleading:
If the author of Riverpod himself says not to do something, chances are he knows what he's talking about. 😅
Indeed, if it were possible to add a mounted
flag safely, it would have been built into Notifier/AsyncNotifier from the start.
So let's go back to the drawing board. 👇
2. Using Object rather than bool
To solve the "mounted" issue, Remi recommends using an Object
. So here's an adaptation of the proposed solution:
class SomeNotifier extends AutoDisposeAsyncNotifier<void> {
Object? key; // 1. create a key
@override
FutureOr<void> build() {
key = Object(); // 2. initialize it
ref.onDispose(() => key = null); // 3. set to null on dispose
}
Future<void> doAsyncWork() async {
state = const AsyncLoading();
final key = this.key; // 4. grab the key before doing any async work
final newState = await AsyncValue.guard(someFuture);
if (key == this.key) { // 5. check if the key is still the same
state = newState;
}
}
}
This solution is technically correct but requires a total of five steps just to check if the notifier is mounted.
To improve things, I tried moving some logic back into a mixin, but Remi didn't approve my solution.
So how do we move forward?
3. Use a StateNotifier
Since adding mounted
to Notifier/AsyncNotifier is a no-no, we could use a good old StateNotifier
instead.
So here's an equivalent implementation of the same class:
class SomeNotifier extends StateNotifier<AsyncValue<void>> {
SomeNotifier(this.ref) : super(const AsyncData(null));
final Ref ref;
Future<void> doAsyncWork() async {
state = const AsyncLoading();
final newState = await AsyncValue.guard(someFuture);
// mounted is a property and we can use it if needed
if (mounted) {
state = newState;
}
}
}
// the corresponding provider
final someNotifierProvider =
StateNotifierProvider.autoDispose<SomeNotifier, AsyncValue<void>>(
(ref) => SomeNotifier(ref));
As we can see, the syntax is slightly different:
- we need to provide an initial value to the super constructor, synchronously
- if we need a
ref
, we need to declare it explicitly and pass it to the constructor - we need to use the
autoDispose
modifier in the provider declaration
But on the plus side, mounted
is already a property of StateNotifier
, and we can use it as needed.
Indeed, this is a good way to go as long as you don't need the modern Notifier APIs (asynchronous initialization, multiple arguments, code generation).
Conclusion
We started with a simple question: can we check if an AsyncNotifier
is mounted?
Surprisingly, there is no simple answer, and we ended up with three alternatives:
- add our own
mounted
flag inside aNotifierMounted
mixin → not recommended - implement some convoluted logic using
Object
→ correct but error-prone and not DRY - use the good old
StateNotifier
→ works well but does not use the modernNotifier
syntax
What's next for Riverpod?
Overall, AsyncNotifier
is very versatile. But as we have seen, there's no way to check the mounted state, and that can be problematic in practice.
Another area of improvement is support for data mutations, which would further streamline the AsyncNotifier
syntax when setting loading/error/result states.
As it turns out, this is a planned feature:
And once this is implemented, I'll be sure to cover it in a future article. 👍
Happy coding!
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: