Skip to main content
State Management for Flutter

Async Redux

Powerful State Management for Flutter

Get Started  »
On pub.dev since Aug 2019.
Also available for React as Kiss State.

Store and state

The store holds all the application state.

// The application state
class AppState {
final String name;
final int age;
AppState(this.name, this.age);
}

// Create the store with the initial state
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<AppState>(
store: store,
child: MaterialApp( ... ),
);
}

 

Widgets use the state

Using context.state, your widgets rebuild when the state changes.

class MyWidget extends StatelessWidget {

Widget build(context)
=> Text('${context.state.name} is ${context.state.age} years old');
}

 

Or use context.select() to get only the parts of the state you need.

Widget build(context) {

var state = context.select((st) => (
name: st.user.name,
age: st.user.age),
);

return Text('${state.name} is ${state.age} years old');
}

This also works:

Widget build(context) {
var name = context.select((st) => st.name);
var age = context.select((st) => st.age);

return Text('$name is $age years old');
}

 

Actions change the state

The application state is immutable, so the only way to change it is by dispatching an action.

// Dispatch an action
dispatch(Increment());

// Dispatch multiple actions
dispatchAll([Increment(), LoadText()]);

// Dispatch an action and wait for it to finish
await dispatchAndWait(Increment());

// Dispatch multiple actions and wait for them to finish
await dispatchAndWaitAll([Increment(), LoadText()]);

 

An action is a class with a name that describes what it does, like Increment, LoadText, or BuyStock.

It must include a method called reduce. This "reducer" has access to the current state, and must return a new one to replace it.

class Increment extends AppAction {

// The reducer has access to the current state
AppState reduce()
=> AppState(state.name, state.age + 1); // Returns new state
}

 

Widgets can dispatch actions

In your widgets, use context.dispatch to dispatch actions.

class MyWidget extends StatelessWidget {

Widget build(context) {
return ElevatedButton(
onPressed: () => context.dispatch(Increment());
}
}

 

Actions can do asynchronous work

Actions may download information from the internet, or do any other async work.

class LoadText extends AppAction {

// This reducer returns a Future
Future<AppState> reduce() async {

// Download something from the internet
var response = await http.get('https://dummyjson.com/todos/1');
var newName = state.response.body;

// Change the state with the downloaded information
return AppState(newName, state.age);
}
}

 

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 AppAction {

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 AppAction {

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 AppAction {

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 AppAction {
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 use mixins in your actions, to accomplish common tasks.

Check for Internet connectivity

Mixin CheckInternet ensures actions only run with internet, otherwise an error dialog prompts users to check their connection:

class LoadText extends AppAction with CheckInternet {

Future<String> reduce() async {
var response = await http.get('https://dummyjson.com/todos/1');
...
}
}

 

Mixin NoDialog can be added to CheckInternet so that no dialog is opened. Instead, you can display some information in your widgets:

class LoadText extends AppAction with CheckInternet, NoDialog {
...
}

class MyWidget extends StatelessWidget {
Widget build(context) {
if (context.isFailed(LoadText)) Text('No Internet connection');
}
}

 

Mixin AbortWhenNoInternet aborts the action silently (without showing any dialogs) if there is no internet connection.

 

NonReentrant

Mixin NonReentrant prevents an action from being dispatched while it's already running.

class LoadText extends AppAction with NonReentrant {
...
}

 

Retry

Mixin Retry retries the action a few times with exponential backoff, if it fails. Add UnlimitedRetries to retry indefinitely:

class LoadText extends AppAction with Retry, UnlimitedRetries {
...
}

 

Throttle

Mixin Throttle prevents a dispatched action from running too often. If the action loads information, the information is considered fresh. Only after the throttle period ends is the information considered stale, allowing the action to run again to reload the information.

class LoadPrices extends AppAction with Throttle {

final int throttle = 5000; // Milliseconds

Future<AppState> reduce() async {
var result = await loadJson('https://example.com/prices');
return state.copy(prices: result);
}
}

 

Debounce

Mixin Debounce limits how often an action occurs in response to rapid inputs. 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 AppAction 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);
}
}

 

OptimisticUpdate

Mixin OptimisticUpdate helps you provide instant feedback on actions that save information to the server. You immediately apply 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 AppAction with OptimisticUpdate {

async reduce() { ... }
}

 

Events

You can use Evt() to create events that perform one-time operations, to work with widgets like TextField or ListView that manage their own internal state.

// Action that changes the text of a TextField
class ChangeText extends AppAction {
final String newText;
ChangeText(this.newText);
AppState reduce() => state.copy(changeText: Evt(newText));
}
}

// Action that scrolls a ListView to the top
class ScrollToTop extends AppAction {
AppState reduce() => state.copy(scroll: Evt(0));
}
}

Then, consume the events in your widgets:

Widget build(context) {

var clearText = context.event((st) => st.clearTextEvt);
if (clearText) controller.clear();

var newText = context.event((st) => st.changeTextEvt);
if (newText != null) controller.text = newText;

return ...
}

 

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 AppAction {
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);
}
}