Implement comprehensive error handling for actions...
AsyncRedux provides a comprehensive error handling system with multiple layers: action-level wrapping, global error transformation, and error observation for logging/monitoring.
When errors occur during action execution:
before() throws an error, the reducer doesn't execute and state remains unchangedreduce() throws an error, execution halts without state modificationafter() method always runs, even when errors occur (like a finally block)Processing order: wrapError() → GlobalErrorObserver
Actions can throw errors using throw. When an error is thrown, the reducer stops and state is not modified:
class TransferMoney extends AppAction {
final double amount;
TransferMoney(this.amount);
AppState? reduce() {
if (amount == 0) {
throw UserException('You cannot transfer zero money.');
}
return state.copy(cash: state.cash - amount);
}
}
UserException is a built-in class for errors that users can understand and potentially fix (not code bugs):
class SaveUser extends AppAction {
final String name;
SaveUser(this.name);
Future<AppState?> reduce() async {
if (name.length < 4)
throw UserException('Name must have 4 letters.');
await saveUser(name);
return null;
}
}
When a UserException is thrown, it's added to a special error queue in the store and can be displayed via UserExceptionDialog.
Wrap your home page with UserExceptionDialog below both StoreProvider and MaterialApp:
UserExceptionDialog<AppState>(
onShowUserExceptionDialog: (context, exception) => showDialog(...),
child: MyHomePage(),
)
The wrapError() method acts as a catch block for entire actions. It receives the original error and stack trace, and must return:
null (to suppress/disable the error)class LogoutAction extends AppAction {
@override
Object? wrapError(Object error, StackTrace stackTrace) {
return LogoutError("Logout failed", cause: error);
}
Future<AppState?> reduce() async {
await authService.logout();
return state.copy(user: null);
}
}
Create mixins for consistent error transformation across multiple actions:
mixin ShowUserException on AppAction {
String getErrorMessage();
@override
Object? wrapError(Object error, StackTrace stackTrace) {
return UserException(getErrorMessage()).addCause(error);
}
}
class LoadDataAction extends AppAction with ShowUserException {
@override
String getErrorMessage() => 'Failed to load data. Please try again.';
Future<AppState?> reduce() async {
var data = await api.loadData();
return state.copy(data: data);
}
}
Return null from wrapError() to suppress errors without further propagation:
@override
Object? wrapError(Object error, StackTrace stackTrace) {
if (error is CancelledException) {
return null; // Silently ignore cancellation
}
return error;
}
GlobalErrorObserver processes all action errors centrally, after the action's wrapError(). Use it to transform third-party library errors (like Firebase or platform exceptions) into UserExceptions, and to log errors to services like Sentry or Crashlytics:
var store = Store<AppState>(
initialState: AppState.initialState(),
globalErrorObserver: (store) => MyGlobalErrorObserver(),
);
class MyGlobalErrorObserver extends GlobalErrorObserver<AppState> {
@override
Object? observe() {
// Transform platform exceptions to user-friendly messages
if (error is PlatformException && (error as PlatformException).code == "Error performing get") {
return UserException('Check your internet connection').addCause(error);
}
// Transform Firebase errors
if (error is FirebaseException) {
return UserException('Service temporarily unavailable').addCause(error);
}
// Log unexpected errors (not UserExceptions) to crash reporting.
// Note `action` is null when the error came from the Persistor.
if (error is! UserException) {
print("Error during ${action?.runtimeType ?? 'persistence'}: $error");
crashlytics.recordError(error, stackTrace);
}
// Pass through all other errors unchanged
return error;
}
}
Inside observe() you have access to:
error: The error, after the action's wrapError()originalError: The error before wrapError()stackTrace: The stack traceaction: The action that failed, or null if the error didn't come from an action (for example, from the Persistor). Always check for null before using it.store: Use it to read store.state, store.environment or store.configuration. Do not use it to dispatch actions.The observe method returns:
UserExceptions then go to the error queue (shown by UserExceptionDialog) and are not thrown; other errors are thrown.null to swallow the error silentlyErrors thrown by Persistor.persistDifference() (for both the persistor and the cloudSync) also go through the GlobalErrorObserver, with action == null. They are first processed by Persistor.wrapError(), which works like the action's wrapError(). Then error is the error after Persistor.wrapError(), and originalError is the error before it. As with actions, a resulting UserException goes to the error queue. The persistor can also report errors with its addError() method, even before the store exists (for example, from readState()); these also go to the GlobalErrorObserver with action == null, but not to Persistor.wrapError(). See the asyncredux-persistence skill for details.
AsyncRedux also provides GlobalErrorObserverDummy (does nothing), GlobalErrorObserverForDevelopment (also shows non-UserException errors in the dialog), and SwallowGlobalErrorObserver (swallows all errors, not recommended).
For showing error feedback while allowing the action to continue (without stopping execution):
class ConvertAction extends AppAction {
final String text;
ConvertAction(this.text);
Future<AppState?> reduce() async {
var value = int.tryParse(text);
if (value == null) {
// Show error but continue action
dispatch(UserExceptionAction('Please enter a valid number'));
return null; // No state change
}
return state.copy(counter: value);
}
}
After dispatching with dispatchAndWait(), check the status:
var status = await store.dispatchAndWait(SaveAction());
if (status.isCompletedOk) {
Navigator.pop(context);
} else if (status.isCompletedFailed) {
var error = status.wrappedError;
print('Save failed: $error');
}
ActionStatus properties:
isCompletedOk: Action finished without errorsisCompletedFailed: Action encountered errorsoriginalError: The error as thrown from before or reducewrappedError: The error after transformation by wrapError()Check action failure state in the UI:
Widget build(BuildContext context) {
if (context.isFailed(LoadDataAction)) {
var exception = context.exceptionFor(LoadDataAction);
return Column(
children: [
Text('Error: ${exception?.message}'),
ElevatedButton(
onPressed: () => context.dispatch(LoadDataAction()),
child: Text('Retry'),
),
],
);
}
if (context.isWaiting(LoadDataAction)) {
return CircularProgressIndicator();
}
return DataWidget(data: context.state.data);
}
The error is cleared automatically when the action is dispatched again.
To manually clear the error:
context.clearExceptionFor(LoadDataAction);
Test that actions fail with expected errors:
test('action throws UserException for invalid input', () async {
var store = Store<AppState>(initialState: AppState.initialState());
var status = await store.dispatchAndWait(SaveUser('abc')); // too short
expect(status.isCompletedFailed, isTrue);
var error = status.wrappedError;
expect(error, isA<UserException>());
expect((error as UserException).msg, 'Name must have 4 letters.');
});
Test multiple exceptions via the error queue:
test('multiple actions accumulate errors', () async {
var store = Store<AppState>(initialState: AppState.initialState());
await store.dispatchAndWaitAll([
InvalidAction1(),
InvalidAction2(),
InvalidAction3(),
]);
var errors = store.errors;
expect(errors.length, 3);
expect(errors[0].msg, 'First error message');
});
var store = Store<AppState>(
initialState: AppState.initialState(),
globalErrorObserver: (store) => MyGlobalErrorObserver(),
actionObservers: [Log.printer(formatter: Log.verySimpleFormatter)],
);
class MyGlobalErrorObserver extends GlobalErrorObserver<AppState> {
@override
Object? observe() {
if (error is SocketException) {
return UserException('No internet connection').addCause(error);
}
// Skip logging UserExceptions (they're expected)
if (error is! UserException) {
crashlytics.recordError(error, stackTrace);
}
return error;
}
}
URLs from the documentation: