it's been some time since I'm thinking on Async/Side effects models in Elm architecture. I'd really like to write some article on this to complete the first one. I know it was already discussed in #13 but i'd like to present some other alternatives here for discussion. i´ll motivate the need for alternative models and will illustrate with the standard Counter example. Sorry for the long post.
Currently the standard Elm solution works by turning the signature of update
from this
update : (state, action) -> state
to this
update : (state, action) -> (state, [Future Action]) // using Reactive streams update : (state, action) -> (state, Stream Action) // using callback with Node CPS style update : (state, action, dispatch -> () ) -> state
Whatever solution we choose, it allows us to express side effects and fire asynchronous actions. However, there is a thing i dislike here : in the first effect-less version we had a nice pure function (state, action) -> state
. It's predictable and can be tested with ease. In contrast, it's not that easy in the effect-full version : we now have to mock the function environment to test the effect-full reactions.
IMO it'd be preferable to keep the update
pure and clean of any side effect. Its only purpose should be : calculate a new state giving an existing one and a given action.
So i present here 2 alternatives side effect models for discussion :
1- The first model is taken directly from redux (which was inspired by Elm). Like in Elm architecture, redux has pure functions called reducers with the same signature as update
. A pretty standard way to fire asynchronous actions in redux is through thunks : i.e. callback function of the form dispatch -> ()
.
I'll illustrate with the Counter example and an asynchronous increment action
// Synchronous : () -> Action function increment() { return { type: 'increment' } } // Asynchronous : () -> ( dispatch -> () ) function incrementAsync() { return dispatch => { setTimeout( () => dispatch( increment() ), 1000) } } const view = ({state, dispatch}) => <div> <button on-click={[dispatch, increment()]}>+</button> <div>{state}</div> <button on-click={[dispatch, incrementAsync()]}>+ (Async)</button> </div>; const init = () => 0; const update = (state, action) => type === 'increment' ? state + 1 : state;
The idea is : instead of firing a normal action, the component fires an action dispatcher (the thunk). Now our main dispatcher has to be enhanced to know about thunks
function dispatch(action) { if(typeof action === 'function') action(dispatch); else { state = Counter.update(action); updateUI(); } }
The main benefit of the above approach is to keep the update
function clean of any effect (this is also a golden rule in redux : a reducer must not do any side effect like api calls...). Another benefit is that you can fire multiples actions from the thunk which is useful to manage a flow of multiple actions.
2- the second alternative was inspired by this interesting discussion (also in the redux site, yeah!), the idea is to separate Effect creation and execution in the same way we separate Action creation and update, the update
signature will look quite the same as in the current Elm solution
update : (state, action) -> (state, Effect)
However, the Effect
above is no longer a Future or a Promise or whatever, it's a just a data object much like the actions that describes the intent. To run the actual effect we'll provide the component with another method execute
which will run the real side effect. So back to the Counter example
// an union type to describe "effect-full" value const UpdateResult = Type({ Pure: [T], WithEffects: [T, T] }); const Action = Type({ Increment : [], IncrementLater : [] }) const Effect = Type({ IncrementAsync : [] }); const view = ({state, dispatch}) => <div> <button on-click={[dispatch, Action.Increment()]}>+</button> <div>{state}</div> <button on-click={[dispatch, Action.IncrementLater()]}>+ (Async)</button> </div>; // pure and withEffects are 2 helper factories const init = () => pure(0); const update = (state, action) => Action.case({ Increment : () => pure(state + 1), IncrementLater : () => withEffects(state, Effect.IncrementAsync()) }, action); const execute = (state, effect, dispatch) => Effect.case({ IncrementAsync: () => { setTimeout(() => dispatch(Action.Increment()), 1000) } }, effect); export default { view, init, update, Action, execute, Effect };
So the component have now 2 additional properties : Effect
which like Action
documents side effects carried by the component and execute
, which like update
, run the actual effect. This may seem more boilerplate but has the advantage of keeping the update
function pure : we can now test its return value by just checking the returned state and eventually the side effect data. Another advantage may be that Effects are now documented explicitly in the component.
The main dispatcher will look something like
function updateStatePure(newState) { state = newState; updateUI(); } export function dispatch(action) { const updateResult = App.update(state, action); UpdateResult.case({ Pure: v => updateStatePure(v), WithEffects: (v, effect) => { updateStatePure(v); Counter.execute(effect, dispatch); } }) }
Here is an example of the above approach with nested components.
What do you think of those 2 models ? the redux solution seems more simple to me as it doesn't add more boilerplate to the current model. The 2nd solution has the advantage to be more explicit and clearly documents the Effects carried by a component.
I'd be also nice to hear the opinion of @evancz or also @gaearon the creator of redux, knowing his Elm background
RetroSearch is an open source project built by @garambo | Open a GitHub Issue
Search and Browse the WWW like it's 1997 | Search results from DuckDuckGo
HTML:
3.2
| Encoding:
UTF-8
| Version:
0.7.4