Handles complex applications with millions of users
Testable
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.
Created by Marcelo Glasberg
(see all my packages).
// The application stateclassAppState{
finalString name;
finalint age;
AppState(this.name, this.age);
}
// Create the store with the initial statevar store = Store<AppState>(
initialState: AppState('Mary', 25)
);
To use the store, add it in a StoreProvider at the top of your widget tree.
Using context.state, your widgets rebuild when the state changes.
classMyWidgetextendsStatelessWidget{
Widgetbuild(context)
=> Text('${context.state.name} has ${context.state.age} years old');
}
Or use context.select() to get only the parts of the state you need.
Widgetbuild(context) {
var state = context.select((st) => (
name: st.user.name,
age: st.user.age),
);
returnText('${state.name} has ${state.age} years old');
}
This also works:
Widgetbuild(context) {
var name = context.select((st) => st.name);
var age = context.select((st) => st.age);
returnText('$name has $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 actiondispatch(Increment());
// Dispatch multiple actionsdispatchAll([Increment(), LoadText()]);
// Dispatch an action and wait for it to finishawaitdispatchAndWait(Increment());
// Dispatch multiple actions and wait for them to finishawaitdispatchAndWaitAll([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.
classIncrementextendsAction{
// The reducer has access to the current stateAppStatereduce()
=> AppState(state.name, state.age + 1); // Returns new state
}
Widgets can dispatch actions
In your widgets, use context.dispatch to dispatch actions.
Actions may download information from the internet, or do any other async work.
classLoadTextextendsAction{
// This reducer returns a FutureFuture<AppState> reduce() async {
// Download something from the internetvar response = await http.get('https://dummyjson.com/todos/1');
var newName = state.response.body;
// Change the state with the downloaded informationreturnAppState(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.
classLoadTextextendsAction{
Future<String> reduce() async {
var response = await http.get('https://dummyjson.com/todos/1');
if (response.statusCode == 200) return response.body;
elsethrowUserException('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).
classMyWidgetextendsStatelessWidget{
Widgetbuild(context) {
if (context.isWaiting(LoadText)) returnCircularProgressIndicator();
if (context.isFailed(LoadText)) returnText('Loading failed...');
returnText(context.state);
}
}
Actions can dispatch other actions
You can use dispatchAndWait to dispatch an action and wait for it to finish.
classLoadTextAndIncrementextendsAction{
Future<AppState> reduce() async {
// Dispatch and wait for the action to finishawaitdispatchAndWait(LoadText());
// Only then, increment the statereturn state.copy(count: state.count + 1);
}
}
You can also dispatch actions in parallel and wait for them to finish:
classBuyAndSellextendsAction{
Future<AppState> reduce() async {
// Dispatch and wait for both actions to finishawaitdispatchAndWaitAll([
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:
classSellStockForPriceextendsAction{
finalString stock;
finaldouble limitPrice;
SellStockForPrice(this.stock, this.limitPrice);
Future<AppState?> reduce() async {
// Wait until the stock price is higher than the limit priceawaitwaitCondition(
(st) => st.stocks[stock].price >= limitPrice
);
// Only then, post the sell order to the backendvar amount = awaitpostSellOrder(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:
Mixin UnlimitedRetryCheckInternet checks 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.
Mixin Fresh prevents a dispatched action from reloading the same information
while it is still up to date. The first dispatch always runs and loads the data.
While the data is fresh, later dispatches do nothing. When the fresh period
ends, the data becomes stale and the action may run again.
classLoadPricesextendsActionwithFresh{
finalint freshFor = 5000; // MillisecondsFuture<AppState> reduce() async {
var result = awaitloadJson('https://example.com/prices');
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.
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.
// Run only oncedispatch(GetStockPrice());
// Start pollingdispatch(GetStockPrice(Poll.start));
// Stop pollingdispatch(GetStockPrice(Poll.stop));
By default, polling runs never overlap: each tick waits for the previous run to
finish, and only then does pollInterval start counting for the next tick.
In other words, the interval is measured from the END of each run.
If you'd rather have ticks at a fixed rate, measured from the START of each run,
override pollWaitsForRun:
boolget pollWaitsForRun => false;
In that case, runs may overlap when they take longer than pollInterval, so
consider adding NonReentrant, Throttle or Sequential to the action
returned by createPollingAction.
Note: When combining Polling with CheckInternet, AbortWhenNoInternet,
NonReentrant, Throttle, Fresh or Sequential, add those to the action
returned by createPollingAction, and not to the action that starts and stops
the polling. All of them can abort or fail a dispatch, and they can't tell a
Poll.stop apart from a regular tick, so on the controller they may block the
Poll.stop itself and leave you unable to stop the polling.
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.
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.
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.
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 TextFieldclassChangeTextextendsAction{
finalString newText;
ChangeText(this.newText);
AppStatereduce() => state.copy(changeText: Evt(newText));
}
}
// Action that scrolls a ListView to the topclassScrollToTopextendsAction{
AppStatereduce() => state.copy(scroll: Evt(0));
}
}
Then, consume the events in your widgets:
Widgetbuild(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.
classAppState{
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 42var 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:
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.