The lack of support for asynchronous operations in redux core has spawned a whole ecosystems for managing side-effects [1] in Redux.
This post argues that the redux-loop library (1.5 k ★ as of this writing) is a much better solution for this job than other more popular alternatives like redux-sagas (11.8 k ★) and redux-thunk (7.8 k ★).
As has always been prevalent in frontend ecosystem, popularity does not necessarily translate to better suitability.
What is redux-loop ?
Redux loop essentially exposes a store enhancer.
Normally our reducers would take in the current state and action and return an updated state.
For example:
1 2 3 4 5 6 7 8 9 10 11 |
const reducer = (state = [], action) => { switch (action.type) { case 'ADD_TODO': // Derive new state from current state return [ ...state, { label: action.label, isDone: false } ]; // ... Other Cases } }; |
Redux encourages reducers to be pure functions and strongly discourages side-effects from being run from within reducers to ensure predictability.
When redux-loop’s enhancer is installed, our reducer could also return a loop.
While it is a somewhat confusing name, a loop, in this context, is a combination of two things:
- An updated state (which is what you would have normally returned), and
- A command (A plain object which describes an asynchronous operation to be executed).
A command describes what asynchronous operation is to be run after this dispatch cycle. For example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
const reducer = (state = [], action) => { switch (action.type) { case 'TODO/CREATE': // 1. Derive new state from updated State const nextState = [ ...state, { label: action.label, isDone: false } ]; // 2. Describe what is to be done next through a command: const nextOperation = Cmd.run( createTask, // An async function which takes the arguments below and returns a promise { args: [{label: action.label}], // Arguments to saveTask successActionCreator: (task) => ({ // Action which will be dispatched when the operation is successful type: "TODO/CREATE/SUCCESS", payload: task }), failActionCreator: (error) => ({ // Action which will be dispatched when the operation is complete type: "TODO/CREATE/FAILURE", payload: error }) }); // 3. Combine both in a loop: return loop(nextState, nextOperation); // ... Other Cases } }; |
While it may not be obvious looking at the example above, our reducer is still a pure function.
The Command that we return as a part of the loop in just an object describing an operation. Even though the API is called Cmd.run
, it does not actually run the asynchronous operation from within the reducer. Redux-loop executes the operation (described by the cmd) once the dispatch cycle is over.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
s DOM Component Store redux-loop User -----------------------> | | | | | | | | Click on "Add Todo" | | | | +----------->|| | | | onClick || | | | +------------------------> | | | | disatch({ | | | | type: "TODO/CREATE", | | | | ... | reducer | | | }) | | | | +--------------> + | | | | | | | | | | | | | | | | | | ||<--------------+--------------> | | Apply | render || nextState next | createTask | diff ||<------------------------+| Operation | (async operation) | || | +-----------> + | <-----------+ | | promise | | | | | | <---------+ | | | | dispatch({ | | | | | type: "TODO/CREATE/SUCCESS", | <-----------+ | | | ... | fulfill | | | }) | | | ||<-------------------------------+ | | || | | | || reducer | | | +|------------> + | | | | | | | | | | | | | | <-----------+ + | | | | nextState | | | render | | | Apply ||<------------------------+ | | diff || | | | <-----------+ | | | | | | |
Interested readers are encouraged to explore the redux-loop documentation for advanced aspects. The rest of the post below compares redux-loop with prevalent alternatives.
A comparision with the alternatives
If you are writing a redux application today, it is likely that to manage asynchronous operations you are using either redux-thunks or redux-sagas, which are the most popular solutions in this space.
I believe redux-loop is a better alternative to both of them.
Redux-thunk encourages handling your asynchronous operations in action creators. The action creators can return thunks which can dispatch other actions when the operation has completed or when it fails.
Redux Thunk middleware allows you to write action creators that return a function instead of an action. The thunk can be used to delay the dispatch of an action, or to dispatch only if a certain condition is met. The inner function receives the store methods
dispatch
andgetState
as parameters.
redux-sagas allows us to define long running generators in the application which can intercept arbitrary actions and perform asynchronous actions based on them.
The mental model is that a saga is like a separate thread in your application that’s solely responsible for side effects.
redux-saga
is a redux middleware, which means this thread can be started, paused and cancelled from the main application with normal redux actions, it has access to the full redux application state and it can dispatch redux actions as well.
Coherent mental model
With redux-loop, when you are exploring your application codebase (or trying to debug a behavior) and trying to deduce what will happen due to an action dispatch, the starting point of investigation is always the reducer irrespective of whether the action corresponds to a synchronous operation or asynchronous.
As the authors have explained this in the README:
With
redux-loop
, the reducer doesn’t just decide what happens now due to a particular action, it decides what happens next. All of the behavior of your application can be traced through one place, and that behavior can be easily broken apart and composed back together.
This is not the case with either thunks or sagas.
In case of thunks, to investigate the impact of an action, along with all reducers which handle the action, we also need to go to each action creator which may have dispatched this action and investigate the thunks which they return.
In case of sagas, along with all reducers, we need to investigate each saga which may have intercepted the action.
In this regard it feels like a natural extension of the Redux API as opposed to a solution bolted on top of redux pretending that asynchronous operations are fundamentally different from synchronous operations.
Dispatch is decoupled from handling of operation
With redux-thunks, we don’t have a way to intercept actions when we don’t have control over the dispatch site.
Being able to intercept actions without modifying the dispatch site is handy in multiple scenarios. Let us say we want to show a loader when any asynchronous operation is in progress.
We can implement this using redux-loop without all the action creators being aware of this. A single reducer can intercept all actions suffixed with _PENDING
and _COMPLETE
and use them to update the progress bar state in redux.
Redux-sagas is capable of such interception, but debugging sagas can be quite difficult owning to the fact that sagas can spawn other sagas during their lifetime, so investigation of the impact of an action requires knowledge of what all sagas were running at the point of dispatch.
Time variant local state outside redux
This is essentially an extension of the above argument. Because sagas are long running, they make it very easy (and even encourage) to have local implicit state within the sagas which is not in the redux store and can not be investigated through redux-dev-tools. Based on this implicit time variant state encapsulated within local scopes of long running generators, sagas can do different things when same action is dispatched and in large complex applications this can be difficult to reason about.
I believe it is much better to encode all these transient states of various asynchronous operation, which are in progress, within the redux store so that the store remains the primary source of truth in the application. It is much easier to reason about the behavior of application when the outcome of an action is directly deducible from store state and action.
Can the same approach be adopted while using sagas ? Sure. But for a vast majority of use cases, redux-saga’s flexibility – especially the ability to arbitrarily fork and join sagas – is an undesirable footgun which can make post-mortem debugging a whole lot more complex.
While it is likely that tooling efforts like redux-saga-devtools will help alleviate many of the debugging headaches associated with sagas, but I still prefer to treat the redux store as the singular state machine driving the application.
[1] I explicitly avoid the usage of the term side-effect because it seems to imply that these operations are something of lesser importance where as, in reality, for most applications, these side effects are the reasons these applications exist.
In frontend application asynchrony is so prevalent that management of asynchronous flow of control is often the single most important thing in the application.