Async Redux: Flutter’s non-boilerplate version of Redux
Marcelo Glasberg
Posted on September 4, 2019
Async Redux: Flutter’s non-boilerplate version of Redux
AsyncRedux is a special version of Redux which:
- Is easy to learn
- Is easy to use
- Is easy to test
- Has no boilerplate
AsyncRedux is currently used in a large-scale project I’m involved in, and I finally took the time to write its documentation and publish it. I’ll assume you already know Redux and Flutter, and need to quickly understand some of what AsyncRedux has to offer. If you want all of the details and features, please go to the AsyncRedux Flutter Package.
The most obvious feature of AsyncRedux is that there is no middleware, since reducers can be both sync and async. But let’s start from the beginning.
Declare your store and state , like this:
var state = AppState.initialState();
var store = Store<AppState>(
initialState: state,
);
If you want to change the store state you must “dispatch” some action. In AsyncRedux all actions extend ReduxAction
. You dispatch actions as usual:
store.dispatch(IncrementAction(amount: 3));
store.dispatch(QueryAndIncrementAction());
The reducer of an action is simply a method of the action itself, called reduce. All actions must override this method.
Sync Reducer
If you want to do some synchronous work, simply declare the reducer to return AppState
, then change the state and return it. For example, let’s start with a simple action to increment a counter by some value:
class IncrementAction extends ReduxAction<AppState> {
final int amount;
IncrementAction({this.amount}) : assert(amount != null);
@override
AppState reduce() {
return state.copy(counter: state.counter + amount));
}
}
Simply dispatching the action above is enough to run the reducer and change the state. Unlike other Redux versions, there is no need to list middleware functions during the store’s initialization, or manually wire the reducers.
Try running the: Sync Reducer Example.
Async Reducer
If you want to do some asynchronous work, you simply declare the action’reducer to return Future<AppState>
then change the state and return it. There is no need of any "middleware", like for other Redux versions.
As an example, suppose you want to increment a counter by a value you get from the database. The database access is async, so you must use an async reducer:
class QueryAndIncrementAction extends ReduxAction<AppState> {
@override
Future<AppState> reduce() async {
int value = await getAmount();
return state.copy(counter: state.counter + value));
}
}
Try running the: Async Reducer Example.
Changing state is optional
For both sync and async reducers, returning a new state is optional. You may return null
, which is the same as returning the state unchanged.
This is useful because some actions may simply start other async processes, or dispatch other actions. For example:
class QueryAction extends ReduxAction<AppState> {
@override
Future<AppState> reduce() async {
dispatch(IncrementAction(amount: await getAmount()));
return null;
}
}
Before and After the Reducer
Sometimes, while an async reducer is running, you want to prevent the user from touching the screen. Also, sometimes you want to check preconditions like the presence of an internet connection, and don’t run the reducer if those preconditions are not met.
To help you with these use cases, you may override methods ReduxAction.before()
and ReduxAction.after()
, which run respectively before and after the reducer.
Future<void> before() async => await checkInternetConnection();
If before throws an error, then reduce will NOT run. This means you can use it to check any preconditions and throw an error if you want to prevent the reducer from running. This method is also capable of dispatching actions, so it can be used to turn on a modal barrier:
void before() => dispatch(WaitAction(true));
The after()
method runs after reduce, even if an error was thrown by before()
or reduce()
(akin to a "finally" block), so it can be used to do stuff like turning off a modal barrier when the reducer ends, even if there was some error in the process:
void after() => dispatch(WaitAction(false));
This is a complete example action:
// Increment a counter by 1, and then get some description text.
class IncrementAndGetDescriptionAction extends ReduxAction\<AppState\> {
@override
Future<AppState> reduce() async {
dispatch(IncrementAction());
String description = await read("http://numbersapi.com/${state.counter}");
return state.copy(description: description);
}
void before() => dispatch(WaitAction(true));
void after() => dispatch(WaitAction(false));
}
Try running the: Before and After the Reducer Example.
You may also provide reusable abstract classes with default before and after methods. For example, any action which extends the BarrierAction
class below will display a modal barrier while it runs:
abstract class BarrierAction extends ReduxAction\<AppState\> {
void before() => dispatch(WaitAction(true));
void after() => dispatch(WaitAction(false));
}
class IncrementAndGetDescriptionAction extends BarrierAction {
@override
Future<AppState> reduce() async { ... }
}
The above BarrierAction is demonstrated in this example.
Processing errors thrown by Actions
Suppose a logout action that checks if there is internet connection, and then deletes the database and sets the store to its initial state:
class LogoutAction extends ReduxAction<AppState> {
@override
Future<AppState> reduce() async {
await checkInternetConnection();
await deleteDatabase();
return AppState.initialState();
}
}
In the above code, the checkInternetConnection()
function checks if there is an internet connection, and if there isn't it throws an error:
Future<void> checkInternetConnection() async {
if (await Connectivity().checkConnectivity() == ConnectivityResult.none)
throw NoInternetConnectionException();
}
All errors thrown by action reducers are sent to the ErrorObserver
, which you may define during store creation. For example:
var store = Store<AppState>(
initialState: AppState.initialState(),
errorObserver: errorObserver,
);
bool errorObserver(Object error, ReduxAction action, Store store, Object state, int dispatchCount) {
print("Error thrown during $action: $error);
return true;
}
If your error observer returns true
, the error will be rethrown after the ErrorObserver
finishes. If it returns false
, the error is considered dealt with, and will be "swallowed" (not rethrown).
User exceptions
To show error messages to the user, make your actions throw an UserException
. The special UserException
error class represents “user errors” which are meant as warnings to the user, and not as “code errors”. Then wrap your home-page with UserExceptionDialog
, below StoreProvider
and MaterialApp
:
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context)
=> StoreProvider<AppState>(
store: store,
child: MaterialApp(
home: UserExceptionDialog<AppState>(
child: MyHomePage(),
)));
}
The above code will make sure all user exceptions are shown in a dialog to the user. Try running the: Show Error Dialog Example.
Testing
AsyncRedux provides the StoreTester
class that makes it easy to test both sync and async reducers. Start by creating the store-tester:
var storeTester
= StoreTester<AppState>(initialState: AppState.initialState());
Then, dispatch some action, wait for it to finish, and check the resulting state:
storeTester.dispatch(SaveNameAction("Mark"));
TestInfo<AppState> info = await storeTester.wait(SaveNameAction);
expect(info.state.name, "Mark");
The variable info above will contain information about after the action reducer finishes executing, no matter if the reducer is sync or async.
While the above example demonstrates the testing of a simple action, real-world apps have actions that dispatch other actions, sync and async. You may use the many different StoreTester
methods to expect any number of expected actions, check their order, their end state, or each intermediary state between actions. For example:
TestInfoList<AppState> infos = await storeTester.waitAll([
IncrementAndGetDescriptionAction,
WaitAction,
IncrementAction,
WaitAction,
]);
var info = infos[IncrementAndGetDescriptionAction];
expect(info.state.waiting, false);
expect(info.state.description, isNotEmpty);
expect(info.state.counter, 1);
Try running the: Store Tester Example .
Route Navigation
AsyncRedux comes with a NavigateAction
which you can dispatch to navigate your Flutter app:
dispatch(NavigateAction.pop());
dispatch(NavigateAction.pushNamed("myRoute"));
dispatch(NavigateAction.pushReplacementNamed("myRoute"));
dispatch(NavigateAction.pushNamedAndRemoveAll("myRoute"));
dispatch(NavigateAction.popUntil("myRoute"));
For this to work, during app initialization you must statically inject your navigator key into the NavigateAction
:
**final** navigatorKey = GlobalKey<NavigatorState>();
**void** main() **async** {
NavigateAction.setNavigatorKey(navigatorKey);
...
}
Try running the: Navigate Example.
Events
In a real Flutter app it’s not practical to assume that a Redux store can hold all of the application state. Widgets like TextField
and ListView
make use of controllers, which hold state, and the store must be able to work alongside these. For example, in response to the dispatching of some action you may want to clear a text-field, or you may want to scroll a list-view to the top.
AsyncRedux solves these problems by introducing the concept of “events”:
var clearTextEvt = Event();
var changeTextEvt = Event<String>("Hello");
var myEvt = Event<int>(42);
Actions may use events as state:
class ClearTextAction extends ReduxAction<AppState> {
AppState reduce() => state.copy(clearTextEvt: Event());
}
And events may be passed down by the StoreConnector
to some StatefulWidget
, just like any other state:
class MyConnector extends StatelessWidget {
Widget build(BuildContext context) {
return StoreConnector<AppState, ViewModel>(
model: ViewModel(),
builder: (BuildContext context, ViewModel vm) =\> MyWidget(
initialText: vm.initialText,
clearTextEvt: vm.clearTextEvt,
onClear: vm.onClear,
));
}
}
Your widget will “consume” the event in its didUpdateWidget
method, and do something with the event payload. So, for example, if you use a controller to hold the text in a TextField
:
@override
void didUpdateWidget(MyWidget oldWidget) {
super.didUpdateWidget(oldWidget);
consumeEvents();
}
void consumeEvents() {
if (widget.clearTextEvt.consume())
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) controller.clear();
});
}
Try running the: Event Example.
This was just a quick look into AsyncRedux. The package documentation has more details, and shows many other features not mentioned here.
This article has a Brazilian Portuguese version. English is not my mother language, so please let me know about any grammar corrections or typos.
The AsyncRedux code is based upon packages redux and flutter_redux by Brian Egan and John Ryan. Also uses code from package equatable by Felix Angelov. Special thanks: Eduardo Yamauchi and Hugo Passos helped me with the async code, checking the documentation, testing everything and making suggestions. This work started after Thomas Burkhart explained to me why he didn’t like Redux. Reducers as methods of action classes were shown to me by Scott Stoll and Simon Lightfoot.
https://github.com/marcglasberg
https://twitter.com/marcglasberg
https://stackoverflow.com/users/3411681/marcg
Posted on September 4, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.