Wrapping the reducer
You may wrap an action reducer to allow for some pre- or post-processing.
This is a complex power feature that you may not need to learn. If you do, use it with caution.
Actions allow you to define a wrapReduce()
function,
that gets a reference to the action reducer as a parameter.
If you override wrapReduce()
it's up to you to call reduce()
and
return a result.
In wrapReduce()
you may run some code before and after the reducer runs,
and then change its result, or even prevent the reducer from running.
Example
Imagine you have a chat application, where you can use the SendMsg
action
to send messages of type Msg
.
Each message has an id
, as well as a status
field that can be:
queued
: message was created in the clientsent
: message was sent to the serverreceived
: message was received by the recipient user
The action uses the service service.sendMessage()
to send the queued message,
and then updates the message status to sent
:
class SendMsg extends AppAction {
final Msg msg;
SendMsg(this.msg);
Future<AppState> reduce() async {
await service.sendMessage(msg);
return state.setMsg(msg.id, msg.copy(status: 'sent'));
}
}
This mostly works, but there is a race condition.
The application is separately using websockets to listen to message updates from the server.
When the sent message is received by the recipient user, the websocket will let the
application know the message is now received
.
If the message status is updated to received
by the websocket before service.sendMessage(msg)
returns, the message status will be overwritten back to sent
when the action completes.
One way to fix this, is checking if the message status is already received
before updating
it to sent
. In this case, you abort the reducer.
This can be done in the reducer itself, by returning null
to abort and avoid modifying the state:
class SendMsg extends AppAction {
final Msg msg;
SendMsg(this.msg);
Future<AppState> reduce() async {
await service.sendMessage(msg);
var currentMsg = state.getMsgById(msg.id);
return (currentMsg.status === 'received')
? null;
: state.setMsg(msg.id, msg.copy(status: 'sent'));
}
Another option is using wrapReduce()
to wrap the reducer:
class SendMsg extends AppAction {
final Msg msg;
SendMsg(this.msg);
Reducer<St> wrapReduce(Reducer<St> reduce) => () async {
// Get the message object before the reducer runs.
var previousMsg = state.getMsgById(msg.id);
AppState? newState = await reduce();
// Get the current message object, after the reducer runs.
var currentMsg = state.getMsgById(msg.id);
// Only update the state if the message object hasn't changed.
return identical(previousMsg, currentMsg)
? newState
: null;
}
Future<AppState> reduce() async {
await service.sendMessage(msg);
return state.setMsg(msg.id, msg.copy(status: 'sent'));
}
}
Creating a Mixin
You may also create a mixin to make it easier to add this behavior to multiple actions:
mixin AbortIfStateChanged on AppAction {
abstract AppState getObservedState();
Reducer<St> wrapReduce(Reducer<St> reduce) => () async {
var previousObservedState = getObservedState();
AppState? newState = await reduce();
var currentObservedState = getObservedState();
return identical(previousObservedState, currentObservedState)
? newState
: null;
}
}
Which allows you to write with AbortIfStateChanged
:
class SendMsg extends AppAction with AbortIfStateChanged {
final Msg msg;
SendMsg(this.msg);
Future<AppState> reduce() async {
await service.sendMessage(msg);
return state.setMsg(msg.id, msg.copy(status: 'sent'));
}
}