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 dispatch to dispatch actions.

class MyWidget extends StatelessWidget {

Widget build(context) {
return ElevatedButton(
onPressed: () => 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 mixins to your actions

You can use mixins 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 {
...
}

 

Fresh

Mixin Fresh marks the data loaded by your action as fresh for a certain period of time. The same action dispatched again will not run unless the fresh period has ended and the data is considered stale.

class LoadUserCart extends AppAction with Fresh {

final String userId;
LoadUserCart(this.userId);

// Each different `userId` in action LoadUserCart has its own fresh period.
Object? freshKeyParams() => userId;

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

 

Throttle

Mixin Throttle limits how often an action can run, acting as a simple rate limit. The first dispatch runs right away. Any later dispatches during the throttle period are ignored. Once the period ends, the next dispatch is allowed to run again.

class RefreshFeed extends Action with Throttle {
final int throttle = 3000; // Milliseconds

Future<AppState> reduce() async {
final items = await loadJson('https://example.com/feed');
return state.copy(feedItems: items);
}
}

 

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

 

OptimisticCommand

Mixin OptimisticCommand helps you provide instant feedback on blocking actions that save information to the server. You immediately apply state changes as if they were already successful. The UI prevents the user from making other changes until the server confirms the update. If the update fails, the change is rolled back.

 

OptimisticSync

Mixin OptimisticSync helps you provide instant feedback on non-blocking actions that save information to the server. The UI does not prevent the user from making other changes. Changes are applied locally right away, while the mixin synchronizes those changes with the server in the background.

 

OptimisticSyncWithPush

Mixin OptimisticSyncWithPush is similar to OptimisticSync, but it also assumes that the app listens to the server, for example via WebSockets. It supports server versioning and multiple clients updating the same data concurrently.

 

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