pub package pub package

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 the reduce 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:

My Medium Articles:

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