Skip to main content

Errors thrown by actions

When an action runs, it may run into problems. For example:

  • A bug in your code
  • A network request fails
  • A database operation fails
  • A file is not found
  • A user types an invalid value
  • A user tries to log in with a wrong password
  • A user tries to delete an item that does not exist

In AsyncRedux, when an action hits a problem, you are allowed to do the obvious thing and simply throw an error. In this case, we say the action failed.

AsyncRedux has built in support for handling these errors. It lets you observe them, show them to the user, or wrap them with better descriptions.

Showing a dialog when an action fails​

We already explained how to show a dialog when an action fails in this page. Please read that first.

Stopping the action​

As explained before, every action must override the reduce() method, and you can also override the optional methods before() and after() (see here).

If before() throws an error, the reducer will not run. If reduce() throws an error, it stops before finishing. In both cases, no new state is returned, and the store state will not change.

However, after() will always run, even if there was an error. Use it to clean up any resources the action used.

If you need to know whether the action failed, you can check the action status.

Example​

Let's build an example to think about error handling.

Suppose a logout action first checks for internet, then deletes the database, resets the store, and navigates to the login screen:

class LogoutAction extends AppAction {

Future<AppState> reduce() async {
await checkInternetConnection();
await deleteDatabase();
dispatch(NavigateToLoginScreenAction());
return AppState.initialState();
}
}

Suppose checkInternetConnection() checks for an internet connection and throws an error when there is none:

Future<void> checkInternetConnection() async {
if (await Connectivity().checkConnectivity() == ConnectivityResult.none)
throw NoInternetConnectionError();
}

With this example in mind, let's explore our options.

Local error handling​

When an action throws an error, you may want to collect as much information as possible about it. This can be useful for debugging, or for showing the user a more informative error message.

In the above code, if checkInternetConnection() throws an error, you want to know it happened during logout. In fact, you want all errors in this action to include that context.

To do this, override wrapError(). It works like a catch block for the whole action. It receives the original error and must return the new one to throw.

In more detail:

  • To change the error, return something new.
  • To disable the error, return null.
  • To keep it the same, just return the same error unaltered.

A common use is to wrap the original error inside another error with more meaning:

class LogoutAction extends AppAction {

Future<AppState> reduce() async { ... }

Object wrapError(error, stacktrace)
=> LogoutError("Logout failed", cause: error);
}

Here, LogoutError keeps the original error as its cause, so no information is lost.

Showing a dialog to the user​

Suppose an action tries to convert a string into a number using parse(). If it fails, you want to show a dialog asking the user to enter a valid number.

The parse() method throws FormatException, but we need a UserException instead.

As previously discussed, throwing a UserException will automatically show a dialog to the user. Here is a possible solution using try/catch:

class ConvertAction extends AppAction {
final String text;
ConvertAction(this.text);

AppState reduce() async {
try {
var value = int.parse(text);
return state(counter: value);
} catch (error) {
throw UserException('Please enter a valid number').addCause(error);
}
}
}

But you can do the same thing with wrapError():

class ConvertAction extends AppAction {
final String text;
ConvertAction(this.text);

Future<AppState> reduce() async {
return int.parse(text);
}

Object wrapError(error, stacktrace)
=> UserException('Please enter a valid number').addCause(error);
}

Creating a Mixin​

To reuse this behavior, you can create a mixin:

mixin ShowUserException on AppAction {

abstract String getErrorMessage();

Object wrapError(error, stacktrace)
=> UserException(getErrorMessage()).addCause(error);
}

Then write with ShowUserException:

class ConvertAction extends AppAction with ShowUserException {
final String text;
ConvertAction(this.text);

Future<AppState> reduce() async {
return int.parse(text);
}

String getErrorMessage() => "Please enter a valid number.";
}

Global error handling​

Sometimes, third party code throws errors that are not bugs, but should still be shown to the user.

For example, Firebase may throw PlatformException when the client is offline. In that case, it may be better to convert it into a UserException so a dialog appears.

One way, discussed above, is to convert it inside the action using wrapError():

class MyAction extends AppAction {

Object? wrapError(error, stacktrace) {
if ((error is PlatformException) && (error.code == "Error performing get") &&
(error.message == "Failed to get document because the client is offline.")) {
return UserException('Check your internet connection').addCause(error);
} else {
return error;
}
}

But this would need to be added to all actions that use Firebase.

A better way is to handle it globally with a GlobalErrorObserver, when creating the store:

var store = Store<AppState>(
initialState: AppState.initialState(),
globalErrorObserver: (store) => MyErrorObserver(),
);

class MyErrorObserver extends GlobalErrorObserver<AppState> {


Object? observe() {
if ((error is PlatformException) && (error.code == "Error performing get") &&
(error.message == 'Failed to get document because the client is offline')) {
return UserException('Check your internet connection').addCause(error);
} else {
return error;
}
}
}

The GlobalErrorObserver receives all errors thrown by actions (and also the errors thrown by the persistor). Its observe() method may return a UserException, or return the original error unchanged, or return null to disable (swallow) the error.

Inside observe(), you can access error, originalError, stackTrace, action, and store. Note that action is null when the error came from the persistor, so check for null before using it. See the Error Monitoring page for complete details and a full example with environment-specific observers.

Note: The GlobalErrorObserver runs after wrapError() from the action.

Disabling errors​

To disable an error inside wrapError(), return null.

For example, if you want to swallow errors of type MyException:

wrapError(error, stacktrace)
=> (error is MyException) ? null : error

To do this globally, use GlobalErrorObserver:

class MyErrorObserver extends GlobalErrorObserver<AppState> {


Object? observe()
=> (error is MyException) ? null : error;
}

UserExceptionAction​

As explained in failed actions, UserException is a special error that AsyncRedux catches and shows to the user in a dialog or another UI.

For this to work, the UserException must be thrown from inside before() or reduce().

If you are not inside an action but still want to show a dialog, you can use UserExceptionAction:

dispatch(UserExceptionAction('Please enter a valid number'));

This action throws a UserException from its reduce() method.

It is also useful inside actions when you want to show an error but keep the action running:

class ConvertAction extends AppAction {
final String text;
ConvertAction(this.text);

Future<AppState> reduce() async {
var value = int.tryParse(text);

if (value == null) {
dispatch(UserExceptionAction('Please enter a valid number'));
}

return state(counter: value ?? 0);
}
}

In this case, an invalid number will show an error dialog, but the counter will be set to zero and the action continues.