Handle user-facing errors with UserException. Covers throwing UserException from actions, setting up UserExceptionDialog, customizing error dialogs with `onShowUserExceptionDialog`, and using UserExceptionAction for non-interrupting error display.
UserException is a special error type for user-facing errors that should be displayed to the user rather than logged as bugs. These represent issues the user can address or should be informed about.
Throw UserException when an action encounters a user-facing error:
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);
}
}
For async actions with validation:
class SaveUser extends AppAction {
final String name;
SaveUser(this.name);
Future<AppState?> reduce() async {
if (name.length < 4)
throw UserException('Name must have at least 4 letters.');
await saveUser(name);
return null;
}
}
Use addCause() to preserve the original error while showing a user-friendly message:
class ConvertAction extends AppAction {
final String text;
ConvertAction(this.text);
Future<AppState?> reduce() async {
try {
var value = int.parse(text);
return state.copy(counter: value);
} catch (error) {
throw UserException('Please enter a valid number')
.addCause(error);
}
}
}
Wrap your home page with UserExceptionDialog below both StoreProvider and MaterialApp:
Widget build(context) {
return StoreProvider<AppState>(
store: store,
child: MaterialApp(
home: UserExceptionDialog<AppState>(
child: MyHomePage(),
),
),
);
}
If you omit the onShowUserExceptionDialog parameter, a default dialog appears with the error message and an OK button.
Use onShowUserExceptionDialog to create custom error dialogs:
UserExceptionDialog<AppState>(
onShowUserExceptionDialog: (BuildContext context, UserException exception) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Error'),
content: Text(exception.message ?? 'An error occurred'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('OK'),
),
],
),
);
},
child: MyHomePage(),
)
For non-standard error presentation (like snackbars or banners), you can modify the behavior by accessing the didUpdateWidget method in a custom implementation.
Use UserExceptionAction to show an error dialog without throwing an exception or stopping action execution:
// Show error dialog without failing the action
dispatch(UserExceptionAction('Please enter a valid number'));
This is useful when you want to notify the user of an issue mid-action while continuing execution:
class ConvertAction extends AppAction {
final String text;
ConvertAction(this.text);
Future<AppState?> reduce() async {
var value = int.tryParse(text);
if (value == null) {
// Shows dialog but action continues
dispatch(UserExceptionAction('Invalid number, using default'));
value = 0;
}
return state.copy(counter: value);
}
}
Create mixins to standardize UserException conversion across actions:
mixin ShowUserException on AppAction {
String getErrorMessage();
Object? wrapError(Object error, StackTrace stackTrace) {
return UserException(getErrorMessage()).addCause(error);
}
}
class ConvertAction extends AppAction with ShowUserException {
final String text;
ConvertAction(this.text);
@override
String getErrorMessage() => 'Please enter a valid number.';
Future<AppState?> reduce() async {
var value = int.parse(text); // Any error becomes UserException
return state.copy(counter: value);
}
}
Handle third-party or framework errors uniformly across all actions:
var store = Store<AppState>(
initialState: AppState.initialState(),
globalWrapError: MyGlobalWrapError(),
);
class MyGlobalWrapError extends GlobalWrapError {
@override
Object? wrap(Object error, StackTrace stackTrace, ReduxAction<dynamic> action) {
if (error is PlatformException &&
error.code == 'Error performing get') {
return UserException('Check your internet connection')
.addCause(error);
}
// Return the error unchanged for other cases
return error;
}
}
Processing order: Action's wrapError() -> GlobalWrapError -> ErrorObserver
Thrown UserException instances are stored in a dedicated error queue within the store. The queue is consumed by UserExceptionDialog to display error messages. You can configure the maximum queue capacity in the Store constructor.
Use these methods to check action failure status and display errors inline:
Widget build(BuildContext context) {
if (context.isFailed(SaveUserAction)) {
var exception = context.exceptionFor(SaveUserAction);
return Column(
children: [
Text('Failed: ${exception?.message}'),
ElevatedButton(
onPressed: () {
context.clearExceptionFor(SaveUserAction);
context.dispatch(SaveUserAction(name));
},
child: Text('Retry'),
),
],
);
}
return Text('User saved successfully');
}
Note: Error states automatically clear when an action is redispatched, so manual cleanup before retry is usually unnecessary.
Test that actions throw UserException correctly:
test('should throw UserException for invalid input', () async {
var store = Store<AppState>(initialState: AppState.initialState());
var status = await store.dispatchAndWait(TransferMoney(0));
expect(status.isCompletedFailed, isTrue);
var error = status.wrappedError;
expect(error, isA<UserException>());
expect((error as UserException).message, 'You cannot transfer zero money.');
});
Test multiple exceptions using the error queue:
test('should collect multiple UserExceptions', () 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].message, 'First error message');
});
URLs from the documentation: