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:
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 theref.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 loading →
AsyncValue.data
- loading →
AsyncValue.loading
- error →
AsyncValue.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 useAsyncValue<void>
when defining ourStateNotifier
andAsyncValue.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()
andstate.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 ofAsyncValue
) - we pass the
isLoading
variable to thePrimaryButton
, 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:
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 viaref.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!