Wait, fail, succeed
Most async processes in your app take some time to finish, and they can either succeed or fail. For example, loading data from a server or saving something to a database. You want to display a spinner while it's running, show the result when it completes, or present an error message if it fails.
These are called "process states":
- Waiting: The process is currently running.
- Failed: The process failed with an error.
- Succeeded: The process succeeded.
In AsyncRedux, these processes happen when actions are dispatched, which means we need a way to know if an action is currently being processed, if it succeeded, or if it just failed.
Thankfully, this is very easy to do with AsyncRedux, by using the following methods:
isWaiting(actionType): Returns true if the given action type is currently being processed.isFailed(actionType): Returns true if the given action type just failed, with any error.exceptionFor(actionType): Returns theUserExceptionthat caused the action to fail.clearExceptionFor(actionType): Clears the failed state of the action.
In widgets
In widgets, we have access to those methods by using the built-in extension methods on
the context object.
We have already previously seen how to read the state and dispatch actions from widgets:
Widget build(BuildContext context) {
return Column(
children: [
Text('Counter: ${context.state.counter}'),
ElevatedButton(
onPressed: () => context.dispatch(IncrementAction()),
child: Text('Increment'),
],
);
Now, let's see how to show a spinner while an action is being processed, and show an error message.
Show a spinner
Method isWaiting(actionType) returns true if the given action type is currently being
processed. By using this method, you can show a spinner while an action is being processed:
Widget build(BuildContext context) {
return context.isWaiting(IncrementAction)
? CircularProgressIndicator(),
: Text('Counter: ${context.state.counter}');
}
Try running the: Show Spinner Example. When you press the "+" button, it dispatches an increment action that takes 2 seconds to finish. Meanwhile, a spinner is shown in the button, and the counter text gets grey.
Show an error message
Method isFailed(actionType) returns true if the given action type just failed, with any
error. By using this method, you can show an error message when an action fails:
Widget build(BuildContext context) {
if (context.isFailed(IncrementAction)) return Text('Failed');
else return Text('Counter: ${context.state.counter}');
}
If the action failed with a UserException, you can get the exception by doing
var exception = context.exceptionFor(actionType) and then get the error message
to eventually show it in the UI. For any other kind of error, isFailed is still true,
but exceptionFor returns null. If you want a message for those errors too, your action
can turn them into UserExceptions in its wrapError method.
Widget build(BuildContext context) {
if (context.isFailed(IncrementAction)) {
var exception = context.exceptionFor(IncrementAction);
return Text('Failed: ${exception.message}');
}
else return Text('Counter: ${context.state.counter}');
}
Combining isWaiting and isFailed
Suppose we have an async action that gets a list of items from a server. You can show a spinner while the action is being processed, and show an error message if the action fails:
Widget build(BuildContext context) {
if (context.isWaiting(GetItemsAction)) return CircularProgressIndicator();
else if (context.isFailed(GetItemsAction)) return Text('Failed');
else return ListView.builder(
itemCount: context.state.items.length,
itemBuilder: (context, index) => Text(context.state.items[index].name),
);
}
Now let's repeat the previous code, but add a button (in a Column) that retries the action:
Widget build(BuildContext context) {
if (context.isWaiting(GetItemsAction)) return CircularProgressIndicator();
else if (context.isFailed(GetItemsAction)) return Column(
children: [
Text('Failed'),
ElevatedButton(
onPressed: () => context.dispatch(GetItemsAction()),
child: Text('Retry'),
),
],
);
else return ListView.builder(
itemCount: context.state.items.length,
itemBuilder: (context, index) => Text(context.state.items[index].name),
);
}
As soon as the user presses the retry button, the spinner will be shown again, and the error message will be cleared. This happens because the error message is cleared automatically when the action is dispatched again.
You can always clear the error message explicitly by
calling context.clearExceptionFor(actionType), but it's not necessary to do so before
dispatching the action again.
Status per key
The methods above work per action class. context.isWaiting(LoadUser) is true while any
LoadUser is running, and context.isFailed(LoadUser) is true if any LoadUser failed.
Suppose a list of users, where each row dispatches LoadUser(userId) to load its own user.
With the methods above, every row would show a spinner while any one user loads.
To tell the actions apart, give the action a statusKey. It's a getter that returns a value
made from the action's own fields:
class LoadUser extends ReduxAction<AppState> {
final String userId;
LoadUser(this.userId);
Object? get statusKey => userId;
Future<AppState?> reduce() async {
var user = await loadUser(userId);
return state.copy(users: state.users.add(userId, user));
}
}
Then, pass the same value to the methods, in the statusKey parameter. Each row now sees only
its own user:
Widget build(BuildContext context) {
if (context.isWaiting(LoadUser, statusKey: userId)) return CircularProgressIndicator();
if (context.isFailed(LoadUser, statusKey: userId)) {
var exception = context.exceptionFor(LoadUser, statusKey: userId);
return Text(exception?.message ?? 'Loading failed');
}
return Text(context.state.users[userId]?.name ?? '');
}
The same parameter works in store.isWaiting, store.isFailed, store.exceptionFor and
store.clearExceptionFor, in their versions inside actions and in view-model factories, and
in context.clearExceptionFor. Without the parameter, they consider all actions of the type,
whatever their keys, as before.
The key can be any immutable value with == and hashCode: a String, a number, an enum, or a
record like (userId, cartId) for a composite key. Records are compared by content.
Two rules:
-
The key must always be the same. Make it only from the fields set in the action's constructor. Don't use anything that can change: the state, the current time, or a field the action changes while it runs. The store reads the key when the action is dispatched, when it fails, and every time
isWaitingis asked, so a key that changes would leave an old error on screen, or make the spinner stop matching the action. -
Failures stay in memory until cleared. For
isWaiting, nothing extra is kept. ForisFailedandexceptionFor, the store keeps a small record of each failure (the action type, the key, and the error). WithoutstatusKeythere is at most one record per action class. WithstatusKey, there is one record per key that failed, for example one per user whoseLoadUserfailed. A record goes away when the action runs again for that key, when you callcontext.clearExceptionFor(LoadUser, statusKey: userId), or when you callcontext.clearExceptionFor(LoadUser)without a key, which clears all keys of that type.store.clearAllExceptions()clears all types. You rarely need it, becausestore.internalMixinProps.clear()(which you call on logout) andstore.shutdown()already call it.
See Advanced waiting for a complete example, with a list of buttons that each show their own progress indicator.
Now, let's see how AsyncRedux supports interacting with widgets that manage their own internal state, as well as how to trigger one-time side effects, like opening a dialog or hiding the keyboard.