Launch Offer

Flutter Foundations Course

Buy now and get 25% off the regular price!

View Course

How to handle loading and error states with StateNotifier & AsyncValue in Flutter

Loading and error states are very common in apps that perform some asynchronous work.

If we fail to show a loading or error UI when appropriate, users may think that the app is not working and won't know if the operation they're trying to do has succeeded.

For example, here is a page with a button that we can use to pay for a product with Stripe:

Example payment page with Stripe
Example payment page with Stripe

As we can see, a loading indicator is presented as soon as the "Pay" button is pressed. And the payment sheet itself also shows a loading indicator until the payment methods are available.

And if the payment fails for any reason, we should show some error UI to inform the user.

So let's dive in and learn how we may handle these concerns in our Flutter apps.

Loading and error states using StatefulWidget

Loading and error states are very common and we should handle them on every page or widget that performs some asynchronous work.

For example, suppose we have a PaymentButton that we can use to make a payment:

class PaymentButton extends StatelessWidget { @override Widget build(BuildContext context) { // note: this is a *custom* button class that takes an extra `isLoading` argument return PrimaryButton( text: 'Pay', // this will show a spinner if loading is true isLoading: false, onPressed: () { // use a service locator or provider to get the checkout service // make the payment }, ); } }

If we wanted, we could make this widget stateful and add two state variables:

class _PaymentButtonState extends State<PaymentButton> { // loading and error state variables bool _isLoading = false; String _errorMessage = ''; Future<void> pay() async { // make payment, update state variables, and show an alert on error } @override Widget build(BuildContext context) { // same as before, return PrimaryButton( text: 'Pay', // use _isLoading variable defined above isLoading: _isLoading, onPressed: _isLoading ? null : pay, ); } }

This approach will work but it's quite repetitive and error prone.

After all, we don't want to make all our widgets stateful and add state variables everywhere, right?

Making loading and error states more DRY

What we really want is a consistent way to manage loading and error states across the entire app.

To do that, we'll use AsyncValue and StateNotifier from the Riverpod package.

Once we're done, we'll be able to show any loading and error UI with a few lines of code, just like this:

class PaymentButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // error handling ref.listen<AsyncValue<void>>( paymentButtonControllerProvider, (_, state) => state.showSnackBarOnError(context), ); final paymentState = ref.watch(paymentButtonControllerProvider); // note: this is a *custom* button class that takes an extra `isLoading` argument return PrimaryButton( text: 'Pay', // show a spinner if loading is true isLoading: paymentState.isLoading, // disable button if loading is true onPressed: paymentState.isLoading ? null : () => ref.read(paymentButtonControllerProvider.notifier).pay(), ); } }

But let's take one step at a time.

Basic setup: the PaymentButton widget

Let's start with the basic PaymentButton widget we introduced earlier on:

import 'package:flutter_riverpod/flutter_riverpod.dart'; // note: this time we subclass from ConsumerWidget so that we can get a WidgetRef below class PaymentButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // note: this is a custom button class that takes an extra `isLoading` argument return PrimaryButton( text: 'Pay', isLoading: false, onPressed: () => ref.read(checkoutServiceProvider).pay(), ); } }

When the button is pressed, we call ref.read() to get the checkout service and use it to pay.

If you're not familiar with ConsumerWidget and the ref.read() syntax, see my Essential Guide to Riverpod.

For reference, here's how the CheckoutService and corresponding provider may be implemented:

// sample interface for the checkout service abstract class CheckoutService { // this will succeed or throw an error Future<void> pay(); } final checkoutServiceProvider = Provider<CheckoutService>((ref) { // return some concrete implementation of CheckoutService });

This works, but the pay() method can take a few seconds and we don't have any loading or error UI in place.

Let's address that.

Managing loading and error states with AsyncValue

The UI in our example needs to manage three possible states:

  • not loading (default)
  • loading
  • error

To represent these states, we can use the AsyncValue class that ships with the Riverpod package.

For reference, here is how this class is defined:

@sealed @immutable abstract class AsyncValue<T> { const factory AsyncValue.data(T value) = AsyncData<T>; const factory AsyncValue.loading() = AsyncLoading<T>; const factory AsyncValue.error(Object error, {StackTrace? stackTrace}) = AsyncError<T>; }

Note that this class is abstract and that we can only instantiate it using one of the existing factory constructors.

And under the hood, these constructors are implemented with the following concrete classes:

class AsyncData<T> implements AsyncValue<T> class AsyncLoading<T> implements AsyncValue<T> class AsyncError<T> implements AsyncValue<T>

What matters the most is that we can use AsyncValue to represent the three states we care about:

  • not loadingAsyncValue.data
  • loadingAsyncValue.loading
  • errorAsyncValue.error

But where should we put our logic?

For that, we need to define a StateNotifier subclass that will use the AsyncValue<void> as the state.

StateNotifier subclass

First, we'll define a PaymentButtonController class that takes the CheckoutService as a dependency and sets the default state:

class PaymentButtonController extends StateNotifier<AsyncValue<void>> { PaymentButtonController({required this.checkoutService}) // initialize state : super(const AsyncValue.data(null)); final CheckoutService checkoutService; }

Note: AsyncValue.data() is normally used to carry some data using a generic <T> argument. But in our case we don't have any data, so we can use AsyncValue<void> when defining our StateNotifier and AsyncValue.data(null) when setting the initial value.

Then, we can add a pay() method that will be called from the widget class:

Future<void> pay() async { try { // set state to `loading` before starting the asynchronous work state = const AsyncValue.loading(); // do the async work await checkoutService.pay(); } catch (e) { // if the payment failed, set the error state state = const AsyncValue.error('Could not place order'); } finally { // set state to `data(null)` at the end (both for success and failure) state = const AsyncValue.data(null); } } }

Note how the state is set multiple times so that our widget can rebuild and update the UI accordingly.

To make the PaymentButtonController available to our widget, we can define a StateNotifierProvider like so:

final paymentButtonControllerProvider = StateNotifierProvider<PaymentButtonController, AsyncValue<void>>((ref) { final checkoutService = ref.watch(checkoutServiceProvider); return PaymentButtonController(checkoutService: checkoutService); });

Updated PaymentButton widget

Now that we have a PaymentButtonController, we can use it in our widget class:

class PaymentButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // 1. listen for errors ref.listen<AsyncValue<void>>( paymentButtonControllerProvider, (_, state) => state.whenOrNull( error: (error) { // show snackbar if an error occurred ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(error)), ); }, ), ); // 2. use the loading state in the child widget final paymentState = ref.watch(paymentButtonControllerProvider); final isLoading = paymentState is AsyncLoading<void>; return PrimaryButton( text: 'Pay', isLoading: isLoading, onPressed: isLoading ? null // note: this was previously using the checkout service : () => ref.read(paymentButtonControllerProvider.notifier).pay(), ); } }

A few notes:

  • we use ref.listen() and state.whenOrNull() to show a snackbar if an error state is found
  • we check if the payment state is an AsyncLoading<void> instance (remember: AsyncLoading is a subclass of AsyncValue)
  • we pass the isLoading variable to the PrimaryButton, that will take care of showing the right UI

If you're not familiar with listeners in Riverpod, see the section about Listening to Provider State Changes in my Riverpod essential guide.

This works, but can we get the same result with less boilerplate code?

Dart extensions to the rescue

Let's define an extension on AsyncValue<void> so that we can more easily check the loading state and show a snackbar on error:

extension AsyncValueUI on AsyncValue<void> { // isLoading shorthand (AsyncLoading is a subclass of AsycValue) bool get isLoading => this is AsyncLoading<void>; // show a snackbar on error only void showSnackBarOnError(BuildContext context) => whenOrNull( error: (error, _) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(error.toString())), ); }, ); }

With these changes, we can simplify our widget class:

class PaymentButton extends ConsumerWidget { const PaymentButton({Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { // 1. listen for errors ref.listen<AsyncValue<void>>( paymentButtonControllerProvider, (_, state) => state.showSnackBarOnError(context), ); // 2. use the loading state in the child widget final paymentState = ref.watch(paymentButtonControllerProvider); return PrimaryButton( text: 'Pay', isLoading: paymentState.isLoading, onPressed: paymentState.isLoading ? null : () => ref.read(paymentButtonControllerProvider.notifier).pay(), ); } }

And with this in place, loading and error states are handled correctly for this specific page:

Example payment page with Stripe
Example payment page with Stripe

Conclusion

Here's the completed implementation for the AsyncValueUI extension:

import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; // Bonus: define AsyncValue<void> as a typedef that we can // reuse across multiple widgets and state notifiers typedef VoidAsyncValue = AsyncValue<void>; extension AsyncValueUI on VoidAsyncValue { bool get isLoading => this is AsyncLoading<void>; void showSnackBarOnError(BuildContext context) => whenOrNull( error: (error, _) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(error.toString())), ); }, ); }

Thanks to the AsyncValueUI extension methods, we can easily handle loading and error states in our app.

In fact, for each page that performs asynchronous work, we need to follow two steps:

  • add a StateNotifier<VoidAsyncValue> subclass that mediates between the widget class and the service or repository classes above
  • modify the widget build() method by handling the error state via ref.listen() and checking the loading state as needed

While it takes a bit of upfront work to set things up this way, the advantages are worthwhile:

  • we can handle loading and error states with little boilerplate code in our widgets
  • we can move all the state management logic from our widgets into separate controller classes

If you have any feedback about this article, let me know on Twitter.

Happy coding!

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.

The Complete Flutter Course Bundle

The Complete Flutter Course Bundle

Learn about State Management, App Architecture, Navigation, Testing, and much more by building a full-stack Flutter eCommerce app on iOS, Android, and web.

Flutter Animations Masterclass - Full Course

Flutter Animations Masterclass - Full Course

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