Action mixins
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 AppAction with CheckInternet {
Future<String> reduce() async {
var response = await http.get('https://dummyjson.com/todos/1');
...
}
}
It will automatically check if there is internet before running the action. If there is no internet, the action will fail, stop executing, and will show a dialog to the user with title: 'There is no Internet' and content: 'Please, verify your connection.'.
If you don't want the dialog to open, you can add the NoDialog
mixin too,
and then display the error 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');
}
}
Or, using the exception message itself:
if (context.isFailed(LoadText)) Text(context.exceptionFor(LoadText)?.errorText ?? 'No Internet connection');
Notes:
-
CheckInternet
only checks if the internet is on or off on the device, not if the internet provider is really providing the service or if the server is available. So, it is possible that the check passes but internet requests still fail. -
If you want to customize the dialog or the
errorText
, you can override methodconnectionException()
, which is a method added by the mixin to your action, and then return aUserException
with the desired message.
Compatibility:
-
The
CheckInternet
mixin can safely be combined withNonReentrant
orThrottle
(not both). -
It should not be combined with other mixins that override
before
. -
It should not be combined with other mixins that check the internet connection, like
AbortWhenNoInternet
orUnlimitedRetryCheckInternet
.
Retry until there is internet connectivity
Mixin UnlimitedRetryCheckInternet
can be used 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 retry the reduce
method unlimited times,
until there is internet. It will also retry if there is internet but the action failed.
Just add with UnlimitedRetryCheckInternet
to your action. For example:
class LoadText extends AppAction UnlimitedRetryCheckInternet {
Future<String> reduce() async {
var response = await http.get('http://numbersapi.com/42');
return response.body;
}
}
Notes:
-
This mixin combines
Retry
,UnlimitedRetries
,AbortWhenNoInternet
andNonReentrant
mixins. You should not combone it with those mixins. -
Make sure your
before
method does not throw an error, or the retry will not happen. -
All retries will be printed to the console. To remove the print message, or if you want to log the retries, override method
printRetries()
:void printRetries(String message) {}
-
UnlimitedRetryCheckInternet
only checks if the internet is on or off on the device, not if the internet provider is really providing the service or if the server is available. So, it is possible that the check passes but internet requests still fail.
Compatibility:
-
The
UnlimitedRetryCheckInternet
mixin should not be combined with other mixins that overridewrapReduce
orabortDispatch
. -
It should not be combined with other mixins that check the internet connection, like
CheckInternet
andAbortWhenNoInternet
.
Abort the action when there is no Internet
AbortWhenNoInternet
aborts the action silently (without showing any dialogs) if there is no
internet connection. For example:
class LoadText extends AppAction with AbortWhenNoInternet {
Future<String> reduce() async {
var response = await http.get('http://numbersapi.com/42');
return response.body;
}
}
Notes:
-
AbortWhenNoInternet
only checks if the internet is on or off on the device, not if the internet provider is really providing the service or if the server is available. So, it is possible that the check passes but internet requests still fail. -
If you want to customize the dialog or the
errorText
, you can override methodconnectionException()
, which is a method added by the mixin to your action, and then return aUserException
with the desired message.
Compatibility:
-
The
AbortWhenNoInternet
mixin can safely be combined withNonReentrant
orThrottle
(not both at the same time). -
It should not be combined with other mixins that override
before
. -
It should not be combined with other mixins that check the internet connection, like
CheckInternet
orUnlimitedRetryCheckInternet
.
NonReentrant
To prevent an action from being dispatched while it's already running,
add the NonReentrant
mixin to your action class:
class LoadText extends AppAction with NonReentrant {
...
}
In other words, a dispatched action will be aborted in case an action of the same runtime-type is still running from a previous dispatch.
Compatibility:
-
The
NonReentrant
mixin can safely be combined withRetry
,CheckInternet
,UnlimitedRetryCheckInternet
,AbortWhenNoInternet
andNoDialog
. -
It should not be combined with other mixins that override
abortDispatch
. -
It should not be combined with
Throttle
.
Retry
Add the Retry
mixin to your actions, to retry them a few times with exponential backoff, if they fail.
class LoadText extends AppAction with Retry, UnlimitedRetries {
...
}
In more detail: The action's reduce
method will be retried in case this method throws an error.
Note, if the before
method throws an error, the retry will not happen.
Keep in mind that all actions using the Retry
mixin will become asynchronous,
even if the original action was synchronous.
You can override the following parameters:
-
initialDelay
: The delay before the first retry attempt. Default is350
milliseconds. -
multiplier
: The factor by which the delay increases for each subsequent retry. Default is2
, which means the default delays are: 350 millis, 700 millis, and 1.4 seg. -
maxRetries
: The maximum number of retries before giving up. Default is3
, meaning it will try a total of 4 times. -
maxDelay
: The maximum delay between retries to avoid excessively long wait times. Default is5
seconds.
Note the retry delays only start after the reducer finishes executing. For example, if the reducer takes 1 second to fail, and the retry delay is 350 millis, the first retry will happen 1.35 seconds after the first reducer started.
When the action finally fails (maxRetries
was reached),
the last error will be rethrown, and the previous ones will be ignored.
If you want to retry unlimited times, you can add the UnlimitedRetries
mixin,
which is the same as setting maxRetries
to -1
:
class MyAction extends AppAction with Retry, UnlimitedRetries { ... }
Notes:
-
If you do
await dispatchAndWait(action)
and the action usesUnlimitedRetries
, it may never finish if it keeps failing. So, be careful when using it. -
If you want to fail an action when there is no internet, but keep trying unlimited times until the internet is back, use the
UnlimitedRetryCheckInternet
mixin instead ofRetry
.
Compatibility:
-
The
Retry
minin should not be combined withCheckInternet
,AbortWhenNoInternet
orUnlimitedRetryCheckInternet
. -
The
Retry
mixin should not be combined with other mixins that overridewrapReduce
. -
For most actions that use
Retry
, consider also addingNonReentrant
, to avoid multiple instances of the same action running at the same time:class MyAction extends AppAction with Retry, NonReentrant { ... }
Throttle
Add the Throttle
mixin to ensure the action will be dispatched at most once in a specified throttle period.
In other words, it prevents the action from running too frequently.
If an action is dispatched multiple times within a throttle period, it will only execute the first time, and the others will be aborted. After the throttle period has passed, the action will be allowed to execute again, which will reset the throttle period.
If you use the action to load information, the throttle period may be considered as the time the loaded information is "fresh". After the throttle period, the information is considered "stale" and the action will be allowed to load the information again.
For example, if you are using a StatefulWidget
that needs to load some information, you can dispatch the loading
action when widget is created, and specify a throttle period so that it doesn't load the information again too often.
Or if you are using a StoreConnector
, you can use the onInit
parameter:
class MyScreenConnector extends StatelessWidget {
Widget build(BuildContext context) => StoreConnector<AppState, _Vm>(
vm: () => _Factory(),
onInit: _onInit, // Here!
builder: (context, vm) {
return MyScreenConnector(
information: vm.information,
...
),
);
void _onInit(Store<AppState> store) {
store.dispatch(LoadAction());
}
}
and then:
class LoadAction extends AppAction with Throttle {
final int throttle = 5000;
Future<AppState?> reduce() async {
var information = await loadInformation();
return state.copy(information: information);
}
}
The throttle
is given in milliseconds, and the default is`1000 milliseconds (1 second).
You can override this default:
class MyAction extends AppAction with Throttle {
final int throttle = 500; // Here!
...
}
Advanced throttle usage
The throttle is, by default, based on the action runtimeType
.
This means it will throttle an action if another action of the same runtimeType was previously dispatched
within the throttle period. In other words, the runtimeType is the "lock". If you want to throttle based on a
different lock, you can override the lockBuilder
method.
For example, here we throttle two different actions based on the same lock:
class MyAction1 extends AppAction with Throttle {
Object? lockBuilder() => 'myLock';
...
}
class MyAction2 extends AppAction with Throttle {
Object? lockBuilder() => 'myLock';
...
}
Another example is to throttle based on some field of the action:
class MyAction extends AppAction with Throttle {
final String lock;
MyAction(this.lock);
Object? lockBuilder() => lock;
...
}
Compatibility:
- The
Throttle
mixin should not be combined withNonReentrant
or orUnlimitedRetryCheckInternet
. - It should not be combined with other mixins that override
abortDispatch
.
Debounce
Debouncing delays the execution of a function until after a certain period of inactivity. Each time the debounced function is called, the period of inactivity (or wait time) is reset.
The function will only execute after it stops being called for the duration of the wait time. Debouncing is useful in situations where you want to ensure that a function is not called too frequently and only runs after some “quiet time.”
For example, it’s commonly used for handling input validation in text fields, where you might not want to validate the input every time the user presses a key, but rather after they've stopped typing for a certain amount of time. For example:
class SearchText extends AppAction with Debounce {
final String searchTerm;
SearchText(this.searchTerm);
Future<AppState> reduce() async {
var response = await http.get(
Uri.parse('https://example.com/?q=' + encoded(searchTerm))
);
return state.copy(searchResult: response.body);
}
}
The debounce
value is given in milliseconds, and the default is 333 milliseconds (1/3 of a second).
You can override this default:
class SearchText extends AppAction with Debounce {
final int debounce = 1000; // Here!
...
}
Advanced debounce usage
The debounce is, by default, based on the action runtimeType
. This means it will reset the debounce period
when another action of the same runtimeType was is dispatched within the debounce period. In other words,
the runtimeType is the "lock". If you want to debounce based on a different lock, you can override
the lockBuilder
method. For example, here we debounce two different actions based on the same lock:
class MyAction1 extends AppAction with Debounce {
Object? lockBuilder() => 'myLock';
...
}
class MyAction2 extends AppAction with Debounce {
Object? lockBuilder() => 'myLock';
...
}
Another example is to debounce based on some field of the action:
class MyAction extends AppAction with Debounce {
final String lock;
MyAction(this.lock);
Object? lockBuilder() => lock;
...
}
Compatibility:
- The
Debounce
mixin should not be combined withRetry
or orUnlimitedRetryCheckInternet
. - It should not be combined with other mixins that override
wrapReduce
.
OptimisticUpdate
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.
The OptimisticUpdate
mixin is available, but it's still experimental. You can use it, but test it well.
Let's use a "Todo" app as an example. We want to save a new Todo to a TodoList.
This code saves the Todo, then reloads the TotoList from the cloud:
class SaveTodo extends AppAction {
final Todo newTodo;
SaveTodo(this.newTodo);
Future<AppState> reduce() async {
try {
// Saves the new Todo to the cloud.
await saveTodo(newTodo);
}
finally {
// Loads the complete TodoList from the cloud.
var reloadedTodoList = await loadTodoList();
return state.copy(todoList: reloadedTodoList);
}
}
}
The problem with the above code is that it make take a second to update the todoList in the screen, while we save then load, which is not a good user experience.
The solution is optimistically updating the TodoList before saving the new Todo to the cloud:
class SaveTodo extends AppAction {
final Todo newTodo;
SaveTodo(this.newTodo);
Future<AppState> reduce() async {
// Updates the TodoList optimistically.
dispatch(UpdateStateAction((state) => state.copy(todoList: state.todoList.add(newTodo))));
try {
// Saves the new Todo to the cloud.
await saveTodo(newTodo);
}
finally {
// Loads the complete TodoList from the cloud.
var reloadedTodoList = await loadTodoList();
return state.copy(todoList: reloadedTodoList);
}
}
}
That's better. But if the saving fails, the users still have to wait for the reload until they see the reverted state. We can further improve this:
class SaveTodo extends AppAction {
final Todo newTodo;
SaveTodo(this.newTodo);
Future<AppState> reduce() async {
// Updates the TodoList optimistically.
var newTodoList = state.todoList.add(newTodo);
dispatch(UpdateStateAction((state) => state.copy(todoList: newTodoList)));
try {
// Saves the new Todo to the cloud.
await saveTodo(newTodo);
}
catch (e) {
// If the state still contains our optimistic update, we rollback.
// If the state now contains something else, we DO NOT rollback.
if (state.todoList == newTodoList) {
return state.copy(todoList: initialState.todoList); // Rollback.
}
}
finally {
// Loads the complete TodoList from the cloud.
var reloadedTodoList = await loadTodoList();
dispatch(UpdateStateAction((state) => state.copy(todoList: reloadedTodoList)));
}
}
}
Now the user sees the rollback immediately after the saving fails.
Note: If you are using a realtime database or Websockets to receive real-time updates from the
server, you may not need the finally block above, as long as the newTodoList
above can be
told apart from the current state.todoList
. This can be a problem if the state in question
is a primitive (boolean, number etc) or string.
The OptimisticUpdate
mixin helps you implement the above code for you, when you provide the following:
-
newValue
: Is the new value, that you want to see saved and applied to the state. For example, if you want to add a new Todo to the todoList, you should return the new todoList with the new Todo added. You can access the fields of the action, and the state, and return the new value:Object? newValue() => state.todoList.add(newTodo);
-
getValueFromState
: Is a function that extract the value from the given state. Example:Object? getValueFromState(state) => state.todoList.add(newTodo);
-
applyState
: Is a function that applies the given value to the given state. Example:St applyState(state) => state.copy(todoList: newTodoList);
-
saveValue
: Is a function that saves the value to the cloud. Example:void saveValue(newTodoList) => saveTodo(todo);
-
reloadValue
: Is a function that reloads the value from the cloud. If you want to skip this step, simply don't provide this method. Example:Object? reloadValue() => loadTodoList();