Async Redux | state management
- Simple to learn and easy to use
- Powerful enough to handle complex applications with millions of users
- Testable
This means you'll be able to create apps much faster, and other people on your team will easily understand and modify your code.
What is it?
An optimized reimagined version of Redux. A mature solution, battle-tested in hundreds of real-world applications. Written from the ground up, created by Marcelo Glasberg (see all my packages).
There is also a version for React
Optionally use it with Provider or Flutter Hooks
Documentation
The complete docs are published at https://asyncredux.com
Below is a quick overview.
Store, state, actions and reducers
The store holds all the application state. A few examples:
// Here, the state is a number
var store = Store<int>(initialState: 1);
// Here, the state is an object
class AppState {
final String name;
final int age;
State(this.name, this.age);
}
var store = Store<AppState>(initialState: AppState('Mary', 25));
To use the store, add it in a StoreProvider
at the top of your widget tree.
Widget build(context) {
return StoreProvider<int>(
store: store,
child: MaterialApp( ... ),
);
}
Widgets use the state
class MyWidget extends StatelessWidget {
Widget build(context) {
return Text('${context.state.name} has ${context.state.age} years old');
}
}
Actions and reducers
An action is a class that contain its own reducer.
class Increment extends Action {
// The reducer has access to the current state
int reduce() => state + 1; // It returns a new state
}
Dispatch an action
The store state is immutable.
The only way to change the store state is by dispatching an action. The action reducer returns a new state, that replaces the old one.
// Dispatch an action
store.dispatch(Increment());
// Dispatch multiple actions
store.dispatchAll([Increment(), LoadText()]);
// Dispatch an action and wait for it to finish
await store.dispatchAndWait(Increment());
// Dispatch multiple actions and wait for them to finish
await store.dispatchAndWaitAll([Increment(), LoadText()]);
Widgets can dispatch actions
The context extensions to dispatch actions are dispatch
, dispatchAll
etc.
class MyWidget extends StatelessWidget {
Widget build(context) {
return ElevatedButton(
onPressed: () => context.dispatch(Increment());
}
}
Actions can do asynchronous work
They download information from the internet, or do any other async work.
var store = Store<String>(initialState: '');
class LoadText extends Action {
// This reducer returns a Future
Future<String> reduce() async {
// Download something from the internet
var response = await http.get('https://dummyjson.com/todos/1');
// Change the state with the downloaded information
return response.body;
}
}
If you want to understand the above code in terms of traditional Redux patterns, all code until the last
await
in thereduce
method is the equivalent of a middleware, and all code after that is the equivalent of a traditional reducer. It's still Redux, just written in a way that is easy and boilerplate-free. No need for Thunks or Sagas.
Actions can throw errors
If something bad happens, you can simply throw an error. In this case, the state will not change. Errors are caught globally and can be handled in a central place, later.
In special, if you throw a UserException
, which is a type provided by Async Redux,
a dialog (or other UI) will open automatically, showing the error message to the user.
class LoadText extends Action {
Future<String> reduce() async {
var response = await http.get('https://dummyjson.com/todos/1');
if (response.statusCode == 200) return response.body;
else throw UserException('Failed to load');
}
}
To show a spinner while an asynchronous action is running, use isWaiting(action)
.
To show an error message inside the widget, use isFailed(action)
.
class MyWidget extends StatelessWidget {
Widget build(context) {
if (context.isWaiting(LoadText)) return CircularProgressIndicator();
if (context.isFailed(LoadText)) return Text('Loading failed...');
return Text(context.state);
}
}
Actions can dispatch other actions
You can use dispatchAndWait
to dispatch an action and wait for it to finish.
class LoadTextAndIncrement extends Action {
Future<AppState> reduce() async {
// Dispatch and wait for the action to finish
await dispatchAndWait(LoadText());
// Only then, increment the state
return state.copy(count: state.count + 1);
}
}
You can also dispatch actions in parallel and wait for them to finish:
class BuyAndSell extends Action {
Future<AppState> reduce() async {
// Dispatch and wait for both actions to finish
await dispatchAndWaitAll([
BuyAction('IBM'),
SellAction('TSLA')
]);
return state.copy(message: 'New cash balance is ${state.cash}');
}
}
You can also use waitCondition
to wait until the state
changes in a certain way:
class SellStockForPrice extends Action {
final String stock;
final double limitPrice;
SellStockForPrice(this.stock, this.limitPrice);
Future<AppState?> reduce() async {
// Wait until the stock price is higher than the limit price
await waitCondition(
(state) => state.stocks[stock].price >= limitPrice
);
// Only then, post the sell order to the backend
var amount = await postSellOrder(stock);
return state.copy(
stocks: state.stocks.setAmount(stock, amount),
);
}
Add features to your actions
You can add mixins to your actions, to accomplish common tasks.
Check for Internet connectivity
CheckInternet
ensures actions only run with internet,
otherwise an error dialog prompts users to check their connection:
class LoadText extends Action with CheckInternet {
Future<String> reduce() async {
var response = await http.get('https://dummyjson.com/todos/1');
...
}
}
NoDialog
can be added to CheckInternet
so that no dialog is opened.
Instead, you can display some information in your widgets:
class LoadText extends Action with CheckInternet, NoDialog {
...
}
class MyWidget extends StatelessWidget {
Widget build(context) {
if (context.isFailed(LoadText)) Text('No Internet connection');
}
}
AbortWhenNoInternet
aborts the action silently (without showing any dialogs) if there is no
internet connection.
NonReentrant
To prevent an action from being dispatched while it's already running,
add the NonReentrant
mixin to your action class.
class LoadText extends Action with NonReentrant {
...
}
Retry
Add Retry
to retry the action a few times with exponential backoff, if it fails.
Add UnlimitedRetries
to retry indefinitely:
class LoadText extends Action with Retry, UnlimitedRetries {
...
}
UnlimitedRetryCheckInternet
Add UnlimitedRetryCheckInternet
to check if there is internet when you run some action that needs
it. If there is no internet, the action will abort silently and then retried unlimited times,
until there is internet. It will also retry if there is internet but the action failed.
class LoadText extends Action with UnlimitedRetryCheckInternet {
...
}
Debounce (soon)
To limit how often an action occurs in response to rapid inputs, you can add the Debounce
mixin
to your action class. For example, when a user types in a search bar, debouncing ensures that not
every keystroke triggers a server request. Instead, it waits until the user pauses typing before
acting.
class SearchText extends Action with Debounce {
final String searchTerm;
SearchText(this.searchTerm);
final int debounce = 350; // Milliseconds
Future<AppState> reduce() async {
var response = await http.get(
Uri.parse('https://example.com/?q=' + encoded(searchTerm))
);
return state.copy(searchResult: response.body);
}
}
Throttle (soon)
To prevent an action from running too frequently, you can add the Throttle
mixin to your
action class. This means that once the action runs it's considered fresh, and it won't run
again for a set period of time, even if you try to dispatch it.
After this period ends, the action is considered stale and is ready to run again.
class LoadPrices extends Action with Throttle {
final int throttle = 5000; // Milliseconds
Future<AppState> reduce() async {
var result = await loadJson('https://example.com/prices');
return state.copy(prices: result);
}
}
OptimisticUpdate (soon)
To provide instant feedback on actions that save information to the server, this feature immediately applies state changes as if they were already successful, before confirming with the server. If the server update fails, the change is rolled back and, optionally, a notification can inform the user of the issue.
class SaveName extends Action with OptimisticUpdate {
async reduce() { ... }
}
Events
Flutter widgets like TextField
and ListView
hold their own internal state.
You can use Events
to interact with them.
// Action that changes the text of a TextField
class ChangeText extends Action {
final String newText;
ChangeText(this.newText);
AppState reduce() => state.copy(changeText: Event(newText));
}
}
// Action that scrolls a ListView to the top
class ScrollToTop extends Action {
AppState reduce() => state.copy(scroll: Event(0));
}
}
Persist the state
You can add a persistor
to save the state to the local device disk.
var store = Store<AppState>(
persistor: MyPersistor(),
);
Testing your app is easy
Just dispatch actions and wait for them to finish. Then, verify the new state or check if some error was thrown.
class AppState {
List<String> items;
int selectedItem;
}
test('Selecting an item', () async {
var store = Store<AppState>(
initialState: AppState(
items: ['A', 'B', 'C']
selectedItem: -1, // No item selected
));
// Should select item 2
await store.dispatchAndWait(SelectItem(2));
expect(store.state.selectedItem, 'B');
// Fail to select item 42
var status = await store.dispatchAndWait(SelectItem(42));
expect(status.originalError, isA<>(UserException));
});
Advanced setup
If you are the Team Lead, you set up the app's infrastructure in a central place, and allow your developers to concentrate solely on the business logic.
You can add a stateObserver
to collect app metrics, an errorObserver
to log errors,
an actionObserver
to print information to the console during development,
and a globalWrapError
to catch all errors.
var store = Store<String>(
stateObserver: [MyStateObserver()],
errorObserver: [MyErrorObserver()],
actionObservers: [MyActionObserver()],
globalWrapError: MyGlobalWrapError(),
For example, the following globalWrapError
handles PlatformException
errors thrown
by Firebase. It converts them into UserException
errors, which are built-in types that
automatically show a message to the user in an error dialog:
Object? wrap(error, stackTrace, action) =>
(error is PlatformException)
? UserException('Error connecting to Firebase')
: error;
}
Advanced action configuration
The Team Lead may create a base action class that all actions will extend, and add some common functionality to it. For example, getter shortcuts to important parts of the state, and selectors to help find information.
class AppState {
List<Item> items;
int selectedItem;
}
class Action extends ReduxAction<AppState> {
// Getter shortcuts
List<Item> get items => state.items;
Item get selectedItem => state.selectedItem;
// Selectors
Item? findById(int id) => items.firstWhereOrNull((item) => item.id == id);
Item? searchByText(String text) => items.firstWhereOrNull((item) => item.text.contains(text));
int get selectedIndex => items.indexOf(selectedItem);
}
Now, all actions can use them to access the state in their reducers:
class SelectItem extends Action {
final int id;
SelectItem(this.id);
AppState reduce() {
Item? item = findById(id);
if (item == null) throw UserException('Item not found');
return state.copy(selected: item);
}
}
To learn more, the complete Async Redux documentation is published at https://asyncredux.com
The AsyncRedux code is based upon packages redux by Brian Egan, and flutter_redux by Brian Egan and John Ryan. Also uses code from package equatable by Felix Angelov. The dependency injection idea in AsyncRedux was contributed by Craig McMahon. 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.
The Flutter packages I've authored:
- async_redux
- fast_immutable_collections
- provider_for_redux
- i18n_extension
- align_positioned
- network_to_file_image
- image_pixels
- matrix4_transform
- back_button_interceptor
- indexed_list_view
- animated_size_and_fade
- assorted_layout_widgets
- weak_map
- themed
- bdd_framework
My Medium Articles:
- Async Redux: Flutter’s non-boilerplate version of Redux ( versions: Português)
- i18n_extension ( versions: Português)
- Flutter: The Advanced Layout Rule Even Beginners Must Know ( versions: русский)
- The New Way to create Themes in your Flutter App
- A new BDD tool for TypeScript/React, and Flutter/Dart
My article in the official Flutter documentation:
Marcelo Glasberg:
https://github.com/marcglasberg
https://linkedin.com/in/marcglasberg
https://twitter.com/glasbergmarcelo
https://stackoverflow.com/users/3411681/marcg
https://medium.com/@marcglasberg