DEV Community

loading...
Cover image for Async Redux: Flutter’s non-boilerplate version of Redux

Async Redux: Flutter’s non-boilerplate version of Redux

Marcelo Glasberg
CEO. Engineer. Software Architect. Flutter/Dart, Java, TypeScript.
Originally published at Medium on ・7 min read

Async Redux: Flutter’s non-boilerplate version of Redux

AsyncRedux is a special version of Redux which:

  1. Is easy to learn
  2. Is easy to use
  3. Is easy to test
  4. 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

Flutter Community


Discussion (0)