Errors thrown by actions
When your action runs, it may encounter problems. Examples include:
- A bug in your code
- A network request fails
- A database operation fails
- A file is not found
- A user types an invalid value in a form
- A user tries to log in with an invalid password
- A user tries to delete a non-existing item
In Async Redux, if your action encounters a problem, you are allowed to do the obvious thing and simply throw an error. In this case, we say that the action "failed".
Async Redux has special provisions for dealing with errors thrown by actions, including observing errors, showing errors to users, and wrapping errors into more meaningful descriptions.
What happens
As previously discussed, your actions can implement the
functions before()
,
reduce()
,
and after()
.
This is what happens if an action throws an error:
-
Before: If an action throws an error in its
before()
function, the reducer will not be executed, will not return a new state, and the store state will not be modified. -
Reduce: If an action throws an error in its
reduce()
function, the reducer will stop in its tracks before completing. It will not return a new state, and the store state will not be modified. -
After: The action's
after()
function will always be called, no matter if the other two functions threw errors or not. For this reason, if you need to clean up some action resources, you should do it here.
And if at any point you need to know if and how the action failed, you can check its action status.
Now let's create an example to help us think about error handling in actions.
Here is a LogoutAction
that checks if there is an internet connection,
in which case it deletes the app database, sets the store to its initial state,
and navigates to the login screen:
class LogoutAction extends Action {
async reduce() {
await this.checkInternetConnection();
await this.deleteDatabase();
this.dispatch(new NavigateToLoginScreenAction());
return (state) => State.initialState();
}
async checkInternetConnection() { ... }
async deleteDatabase() { ... }
}
In the above code, suppose the checkInternetConnection()
function checks if there is an
internet connection. If there isn't, it throws an error.
Here is the code
for React
and React Native:
- React
- React Native
async checkInternetConnection() {
if (!navigator.onLine) {
throw new NoInternetConnectionError();
}
}
async checkInternetConnection() {
const state = await NetInfo.fetch();
if (!state.isConnected) {
throw new NoInternetConnectionError();
}
}
With this example in mind, let's explore our options.
Local error handling
If your action throws some error, you probably 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 that you have a connection problem,
but you also want to know this happened during the logout action.
In fact, you want all errors thrown by this action to reflect that.
The solution is overriding your action's wrapError()
function.
It acts as a sort of "catch" statement of the action, getting all errors thrown by the action. You if you wish, this function can then return a new error to be thrown. In other words:
- To modify the error, override the
wrapError()
function and return something. - To keep the error the same, just return it unaltered, or don't override
wrapError()
.
Usually, you'll want to wrap the error inside another that better describes the failed action,
or contains more information. For example, this is how you could do it in the LogoutAction
:
class LogoutAction extends Action {
async reduce() {
// ...
}
wrapError(error: any) {
return new LogoutError("Logout failed", error);
}
}
Note the LogoutError
above includes the original error as a cause, so no information is lost.
Showing a dialog to the user
Now suppose we want to show a dialog to the user, saying the logout failed, no matter what the error was.
As previously discussed,
throwing a UserException
will automatically show a dialog to the user,
where the dialog's message is the exception's message.
This is a possible solution, using try/catch
:
class LogoutAction extends Action {
async reduce() {
try {
await this.checkInternetConnection();
await this.deleteDatabase();
} catch (error) {
throw new UserException('Logout failed', {hardCause: error});
}
this.dispatch(new NavigateToLoginScreenAction());
return (state) => State.initialState();
}
}
However, you can achieve the same by overriding the wrapError()
function:
class LogoutAction extends Action {
async reduce() {
await this.checkInternetConnection();
await this.deleteDatabase();
this.dispatch(new NavigateToLoginScreenAction());
return (state) => State.initialState();
}
wrapError(error: any) {
return new UserException('Logout failed', {hardCause: error});
}
}
Creating a base action
You may also modify your base action to make it easier to add this behavior to multiple actions:
import { ReduxAction } from 'async-redux-react';
import { State } from 'State';
export abstract class Action extends ReduxAction<State> {
wrapErrorMessage = undefined;
wrapError(error) {
if (this.wrapErrorMessage !== undefined)
return new UserException(wrapErrorMessage(), {hardCause: error});
}
}
Now you can easily add the wrapErrorMessage
function in all your desired actions,
to make sure all action errors are wrapped in a UserException
:
class LogoutAction extends Action {
wrapErrorMessage = () => 'The logout failed';
async reduce() {
await this.checkInternetConnection();
await this.deleteDatabase();
this.dispatch(new NavigateToLoginScreenAction());
return (state) => State.initialState();
}
}
Global error handling
Third-party code may also throw errors which should not be considered bugs, but simply messages to be displayed in a dialog to the user.
For example, Firebase may throw some PlatformException
errors
in response to a bad connection to the server.
In this case, it may be a good idea to convert this error into a UserException
,
so that a dialog appears to the user with the error message.
There are two ways to do that. One of them we already discussed above:
Just convert it in the action itself
by implementing the optional wrapError()
function:
class MyAction extends Action {
wrapError(error: any) {
if (error instanceof PlatformException
&& error.code === "Error performing get"
&& error.message === 'Failed to get document because the client is offline'
) {
return new UserException('Check your internet connection').addCause(error);
} else {
return error;
}
}
}
However, then you'd have to add this code to all actions that use Firebase.
A better way is providing it globally as the globalWrapError
parameter, when you create the store:
const store = createStore<State>(
initialState: new State(),
globalWrapError: globalWrapError,
);
function globalWrapError(error :any) {
if (error instanceof PlatformException
&& error.code === "Error performing get"
&& error.message === 'Failed to get document because the client is offline'
) {
return new UserException('Check your internet connection').addCause(error);
} else {
return error;
}
}
The globalWrapError
function will be given all errors,
and it's called after the action's own wrapError()
function, if it exists.
It may then return a UserException
which will be used instead of the original exception.
Otherwise, it just returns the original error
, so that it will not be modified.
It may also return null
to disable (swallow) the error.
If instead of returning an error you throw an error inside the globalWrapError
function, AsyncRedux will catch this error and use it instead the original error. In other
words, returning an error or throwing an error works the same way. But it's recommended that
you return the error instead of throwing it anyway.
Don't use the globalWrapError
to log errors, as you should prefer doing that
in the global errorObserver
that will be discussed below.
The globalWrapError
is always called before the errorObserver
.
Disabling errors
If you want your action to disable its own errors, locally,
the action's wrapError()
function may simply return null
.
For example, suppose you want to let all errors pass through, except for errors of
type MyException
:
wrapError(error :any) {
return (error instanceof MyException) ? null : error;
}
If you want this to happen globally, use the globalWrapError
instead:
const store = createStore<State>(
initialState: new State(),
globalWrapError: globalWrapError,
);
function globalWrapError(error :any) {
return (error instanceof MyException) ? null : error;
}
Error observer
An errorObserver
function can be set during the store creation.
This function will be given all errors that survive the action's wrapError
and
the globalWrapError
, including those of type UserException
.
You also get the action
and a reference to the store
. IMPORTANT: Don't use the store to
dispatch any actions, as this may have unpredictable results.
The errorObserver
is the ideal place to log errors, as you have all the information you may
need, including the action
that dispatched the error, which you can use to log the action
name, as well as any action properties you may find interesting.
After you log the error, you may then return true
to let the error throw,
or false
to swallow it.
For example, if you want to disable all errors in production, but log them; and you want to throw all errors during development and tests, this is how you can do it:
const store = createStore<State>(
initialState: new State(),
errorObserver: errorObserver,
);
function errorObserver(error: any, action: Action, store: Store<State>) {
// In development and tests, we throw the error so that we can
// see it in the emulator/console. We also always let UserExceptions
// pass through, so that they can be shown to the user.
if (inDevelopment() || inTests() || (error instanceof UserException)) {
return true;
}
// In production, we log the error and swallow it by returning false.
else {
Logger.error(`Got ${error} in action ${action}.`);
return false;
}
}
As you can see, the error observer returns a boolean:
-
If it returns
true
, the error will be rethrown after theerrorObserver
finishes. -
If it returns
false
, the error is considered dealt with, and will be "swallowed" (not rethrown). This is usually what we want to do in production, after logging the error.
UserExceptionAction
As previously discussed, the UserException
is a special type of error
that Async Redux automatically catches and shows to the user in a dialog, or other UI of your
choice.
For this to work, you must throw the UserException
from inside an
action's before()
or reduce()
functions. Only then, Async Redux will be able to
catch the exception and show it to the user.
However, if you are not inside an action, but you still want to show an error dialog to the
user, you may use the provided UserExceptionAction
.
dispatch(UserExceptionAction('Please enter a valid number'));
This action simply throws a corresponding UserException
from its own reduce()
function.
The UserExceptionAction
is also useful inside of actions themselves,
if you want to display an error dialog to the user,
but you don't want to interrupt the action by throwing an exception.
For example, here an invalid number will show an error dialog to the user,
but the action will continue running and set the counter state to 0
:
class ConvertAction {
constructor(private text: string) {}
reduce() {
let value = parseInt(this.text);
if (isNaN(value)) {
dispatch(new UserExceptionAction('Please enter a valid number'));
value = 0;
}
return { counter: value };
}
}