The problem: tracking state mutations
I really like Flux but in my experience, it has one flaw: sometimes you want to trigger a side-effect when some part of the state mutates. In Flux, everything has to be triggered by an action so you need to listen to all the actions that may mutate that state.
For example, in redux-saga, if you want to send an analytics event when state.currentPage
changes you need to know all the actions that modify state.currentPage
:
function* currentPageChanged() {
yield takeEvery([
"HOME_PAGE",
"ABOUT_PAGE",
"DOCS_PAGE",
"POST_PAGE",
"LIST_OF_POSTS_PAGE",
...
], sendAnalyticsEvent)
}
Sometimes you can save the day consolidating all the actions in a single one: CHANGE_PAGE
, but sometimes you just can't, the actions are too different or doesn't make sense to join them.
This pattern quickly leads to code that is difficult to maintain and prone to bugs: in this case, developers need to remember to change the analytics saga each time they add/remove a new route. This may seem not so important at first, but when the team is big or the app is complex, figuring out why things are broken is not trivial.
In the end, developers are forced to maintain a list of actions that modify some part of state when all they really want is to listen that state mutation. This is a bad developer experience and leads to boilerplate code and bugs.
if (state.currentPage !== previousState.currentPage)
sendAnalyticsEvent()
There's a workaround in the redux/redux-saga and although it is in theory forbiden, it works surprisingly well:
let previousCurrentPage
function* currentPageChanged() {
while(true)
yield take("*")
const state = yield select()
if (previousCurrentPage !== state.currentPage) {
previousCurrentPage = state.currentPage
sendAnalyticsEvent()
}
}
}
In Mobx/MobxStateTree it is simpler because you have reactions
and autoruns
precisely for that:
reaction(() => state.currentPage, sendAnalyticsEvent)
Proposal for Overmind
Right now in Overmind you can use addMutationListener
in the onInitialize
action, but I don't think it's a good API for this. I've been taking a look at the operators and I think there's a more elegant way to solve this.
So here is my proposal:
The waitForMutation
operator
This operator stops the pipe until the state mutates. This is similar to Mobx's reaction
.
pipe(
waitForMutation(({ state }) => state.currentPage),
sendAnalyticsEvent
)
The waitUntilTrue
operator
This operator stops the pipe until the function returns true. This is similar to Mobx's when
.
pipe(
waitUntilTrue(({ state }) => state.currentPage !== '/'),
sendAnalyticsEvent
)
Implementation idea
I've been taking a look the Create custom operators guide and these operators could be created with a new createTrackOperator
where context.state
uses a getTrackStateTree
. I guess it would look like something like this:
export function waitForMutation(operation) {
return createOperator(
'waitForMutation',
operation.name,
(err, context, value, next) => {
if (err) next(err, value)
else {
const tree = context.getTrackStateTree()
operation({ ...context, state: tree.state }, value)
tree.track(() => next(null, value))
}
}
)
}
Other useful operators
The startOver
operator
When reached, this operator starts again the pipe from the top.
pipe(
waitForMutation(({ state }) => state.currentPage),
sendAnalyticsEvent,
startOver
)
The race
operator
This has nothing to do with the issue topic but I thought it'd be cool to have a race
operator with the combination of startOver
for timetous and things like that :)
If parallel
is like Promise.all
, race
is like Promise.race
:
pipe(
race({
succeed: fetchSomething,
failed: wait(5000)
}),
when(_, ({ succeed }) => !!succeed, {
true: doSomethingElse,
false: startOver // <- try it again
})
)
By the way @christianalfoni, I saw you also used this type of reactions
in your codesandbox refactoring:
https://github.com/cerebral/codesandbox-client/blob/sandbox/packages/app/src/app/pages/Sandbox/Editor/Content/index.js#L36
So I hope we are in the same boat here :)