It's understandable why so many newcomers to React+Redux have difficulty wrapping their heads around asynchronous actions. React is simply a view layer. Redux is simply a state management layer. But it takes much more to build a typical app.
These poor developers are given the corner pieces of a puzzle and the rest of the pieces are scattered about on the ground. There's no reference picture to see what the puzzle is supposed to look like -- everyone you ask would show you a different picture anyways.
Even developers experienced in React+Flux have difficulty picking up Redux because now they're told not to put asynchronous calls in their components. Well then, where?
The Good News...
The good news is that Redux gives you a lot of flexibility.
Pretty much the only steadfast rule is that you musn't put asynchronous code in your reducer.
Look... if you really want to put asynchronous code in your reducer, go ahead. The Redux police aren't going to come lock you up. It's your code, after all! But, by doing so, you're simultaneously suffering all the overhead of Redux and sacrificing many of the benefits. And you'll just confuse anyone else who looks at your code. It's better to learn the right way.
The Bad News...
The bad news is that Redux gives you a lot of flexibility.
Redux doesn't care about who, where, when, what, why, and how data comes. Redux is designed to give you a predictable state machine. There is one central store and exactly one entry point into the store.
Asynchronous Operations
First of all, let's define asynchronous operations.
We're talking about any situation where our code has to wait for something else before it can continue. For example, when we're logging in a user, we send the user's credentials to the server and have to wait for a response to approve or reject the user.
We might have some additional business logic like this:
- if login fails, show an error
- if login succeeds, wait 2 seconds and then show a system message
There are lots of libraries that can help you solve this problem. Unfortunately, discovery is a problem in JavaScript niches like React+Redux.
What Are My Options?
As of the time of writing, the most popular third-party Redux side effects libraries according to GitHub stars are:
Name | Watchers | Stars | Forks | Issues | % Closed |
---|---|---|---|---|---|
redux-saga | 150 | 6469 | 464 | 487 | 92% |
redux-thunk | 73 | 4333 | 201 | 85 | 65% |
redux-observable | 74 | 2258 | 126 | 84 | 69% |
redux-promise | 25 | 1364 | 82 | 24 | 29% |
redux-loop | 43 | 1021 | 54 | 66 | 91% |
redux-ship | 10 | 571 | 8 | 3 | 33% |
redux-logic | 28 | 487 | 21 | 31 | 65% |
redux-effects | 5 | 438 | 8 | 6 | 50% |
redux-cycles | 12 | 268 | 4 | 11 | 82% |
redux-side-effects | 8 | 154 | 7 | 13 | 62% |
Kudos to redux-loop and redux-saga for having a close-rate of 90%+
(Note: of the 41 open redux-saga issues, only 5 are tagged with the bug label)
Apples to Apples
Let's look at how our login functionality could be implemented in each of these libraries.
In all cases, you can assume we've got the following action types:
export const ActionType = { LOGIN_REQUEST: 'LOGIN_REQUEST', LOGIN_SUCCESS: 'LOGIN_SUCCESS', LOGIN_FAILURE: 'LOGIN_FAILURE', SHOW_MESSAGE: 'SHOW_MESSAGE',};
In most cases you can assume we've got the following basic action creators (they will have slight variations in some of the examples). We accept a little more boilerplate here where it doesn't matter to reduce the boilerplate in the bulk of our code where it does matter.
export const loginRequest = (username, password) => ({ type: ActionType.LOGIN_REQUEST, payload: { username, password },});export const loginSuccess = user => ({ type: ActionType.LOGIN_SUCCESS, payload: user,});export const loginFailure = err => ({ type: ActionType.LOGIN_FAILURE, payload: err, error: true,});export const showMessage = msg => ({ type: ActionType.SHOW_MESSAGE, payload: msg,});
You can also assume we've got a function that performs the actual POST request to our API and returns a Promise. (All the examples will also work with minor modifications if you prefer the Node.js callback-style functions).
function postLogin(username, password) { return new Promise((resolve, reject) => { // ...XHR, fetch, axios, whatever });}
Redux-Thunk
I'm going to start with redux-thunk because that's where the official documentation starts and that's where a lot of people start. It's also the easiest to understand.
With redux-thunk, all the logic is wrapped up neatly in a function called a thunk. This function takes a reference to the dispatch function and you execute it by dispatching the thunk the same way you would a simple action.
export const loginThunk = (username, password) => dispatch => { // Note: no need to pass in username and password to // the loginRequest action creator because they're already // in scope and not used in our store. dispatch(loginRequest()); postLogin(username, password).then(({ user, msg }) => dispatch(loginSuccess(user)); setTimeout(() => { dispatch(showMessage(msg)); }, 2000); }, err => { dispatch(loginFailure(err)); });};
To execute the login sequence we dispatch our thunk:
dispatch(loginThunk(username, password));
Pros
- Very easy to understand
- Uses familiar flow control constructs
- Logic is all in one place
Cons
- Unit testing is more complicated
- you need to mock dispatch and rewire postLogin
- takes longer when your thunk contains delays
- asynchronous tests
- There is no clean/easy/etc way to cancel an in-progress thunk
- No longer dispatching plain action objects
Redux-Promise
Redux-Promise allows you to dispatch JavaScript Promises to your store.
We can either dispatch a promise directly or in the payload property.
// Redundant... could just say loginRequest = postLoginexport const loginRequest = (username, password) => postLogin(username, password);;export const loginRequest = (username, password) => ({ type: ActionType.LOGIN_REQUEST, payload: postLogin(username, password),});
Regardless of which action creator style we choose, however, redux-promise does not allow us to implement our simple login functionality.
In one case the error is silently dropped and in both cases the success path has no way to chain together the rest of our operations.
Pros
- Easy to understand
- Can use promises
Cons
- Only works for trivial cases
- Can't chain operations together
- No way to cancel a promise
- No longer dispatching plain action objects
Redux-Saga
Redux-Saga puts all the asynchronous logic in generator functions called sagas:
function* loginSaga() { while (true) { // Sleep until a login request action happens const action = yield take(LOGIN_REQUEST); const { username, password } = action.payload; try { // Login. The actual call is carried out by the middleware const result = yield call(postLogin, username, password); const { user, msg } = result; yield put(loginSuccess(user)); // Wait a bit and show the message yield call(delay, 2000); yield put(showMessage(msg)); } catch (err) { yield put(loginFailure(err)); } }}
To run our code, we just need to register the saga with the middleware and we're done.
sagaMiddleware.run(loginSaga);
Pros
- Easy to understand
- Easy to test
- Excellent documentation
- Uses familiar flow control constructs
- Logic is all in one place
- Built-in support for cancellation
- Supports very complex operations
Cons
- Unit testing requires intimate knowledge of the implementation of the saga
- Requires generator support (TypeScript 2.3, Babel, Node with --harmony, etc)
- Debugging is difficult
Redux-Logic
Redux-Logic is like a super-charged thunk from redux-thunk but is invoked from middleware similar to how a saga is called in redux-saga.
const loginLogic = createLogic({ // Only run this when we see a LOGIN_REQUEST type: Actions.LOGIN_REQUEST, // This function will run when the action is dispatched. process({ getState, action }, dispatch, done) { const { username, password } = action.payload; postLogin(username, password).then(({ user, msg }) => { dispatch(loginSucceeded(user)); setTimeout(() => { dispatch(showMessage(msg)); }, 2000); }, err => { dispatch(loginFailure(err)); }) .then(done); }});const logic = [ loginLogic, // ...];const logicMiddleware = createLogicMiddleware(logic);const store = createStore( rootReducer, applyMiddleware( logicMiddleware ));
This little code example doesn't do justice to all the capabilities of redux-logic. It has other lifecycle hooks for intercepting actions for validation, transformation, etc.
Pros
- Logic is all in one place
- Supports cancellation
- Can pre-process actions
- Doesn't use generators
- Very good documentation
Cons
- like thunks, difficult to test
- Maybe trying to do too much? Swiss Army Knife of middleware. But perhaps that is a pro for some...
Redux-Loop
Redux-Loop borrows from Elm the premise that the reducer should be responsible for follow-up effects. In other words, given a current state and an action, redux-loop would have your reducer return the next state and side effects from the state transition.
Pretend this is what our reducer normally looks like:
export const reducer = (state = {}, action) => { switch (action.type) { case ActionType.LOGIN_REQUEST: return { pending: true }; case ActionType.LOGIN_SUCCESS: return { pending: false, user: action.payload }; case ActionType.LOGIN_FAILURE: return { pending: false, err: action.payload }; case ActionType.SHOW_MESSAGE: return { ...state, msg: action.payload }; } return state;};
This is what it looks like with redux loop, note we also have to modify an action creator and add some helper functions:
// Notice we have to pass in the msg so we don't lose it!export const loginSuccess = ({ user, msg }) => ({ type: ActionType.LOGIN_SUCCESS, payload: { user, msg },});// Helper function to login and resolve/reject to a new actionexport const loginPromise = (username, password) => { return postLogin(username, password).then( loginSuccess, loginFailure );};// Helper function to show a message after a delayexport const delayMessagePromise = (msg, delay) => { return new Promise(resolve => { setTimeout(() => { resolve(showMessage(msg)); }, delay); });};export const reducer = (state = {}, action) => { switch (action.type) { case ActionType.LOGIN_REQUEST: const { username, password } = action.payload; return loop( { pending: true }, Effect.promise(loginPromise, username, password)) ); case ActionType.LOGIN_SUCCESS: const { user, msg } = action.payload; return loop( { pending: false, user }, Effect.promise(delayMessagePromise, msg, 2000) ); case ActionType.LOGIN_FAILURE: return { pending: false, err: action.payload }; case ActionType.SHOW_MESSAGE: return { ...state, msg: action.payload }; } return state;};
We don't need to do anything special to execute our asynchronous operations. Just dispatch the request action and off it goes.
dispatch(loginRequest(username, password));
Pros
- Logic is closer to the reducers
- Easy to test
Cons
- Makes reducers more complicated
- Violation of single responsibility principle
- Reducers are now responsible for two things
- Two types of boilerplate interleaved instead of just one
- Very difficult to follow the logic for a non-trivial sequence
- Doesn't appear to have a way to cancel a loop
- Logic is scattered in many places
Redux-Side-Effects
Redux-Side-Effects is similar to redux-loop in the sense that it believes the reducer should be responsible for describing side effects of an action. However, unlike redux-loop, it uses generator functions as reducers to yield side effects.
import { createStore } from 'redux';import { createEffectCapableStore, sideEffect} from 'redux-side-effects';// Almost like a thunk, but will be returned by the reducerexport const loginEffect = (dispatch, { username, password }) => postLogin(username, password).then(result => dispatch(loginSuccess(result)), err => dispatch(loginFailure(err)) );export const showMessageEffect = (dispatch, msg) => { setTimeout(() => { dispatch(showMessage(msg)); }, 2000);};const storeFactory = createEffectCapableStore(createStore);const store = storeFactory(function*(state = {}, action) { switch (action.type) { case ActionType.LOGIN_REQUEST: yield sideEffect(loginEffect, action.payload); return { pending: true }; case ActionType.LOGIN_SUCCESS: const { user, msg } = action.payload; yield sideEffect(showMessageEffect, msg); return { pending: false, user }; case ActionType.LOGIN_FAILURE: return { pending: false, err: action.payload }; case ActionType.SHOW_MESSAGE: return { ...state, msg: action.payload }; default: return state; }});
Okay, I admit this is actually pretty cool. I could see myself using this on a small/medium project with basic async operations. But I think this will not scale well with a large app and complex operations.
Pros
- Cleaner/simpler implementation than redux-loop
- Elegant solution
- Easy to test
Cons
- Like redux-loop, logic is spread out over many places
- No easy way to cancel an operation.
- Could store cancellation request in state and then check it in many places (Ugh)
- Requires generator support (or transpiler)
Redux-Ship
Redux-Ship aims to have "composable, testable, and typable side effects for Redux." It's twice as verbose as basic Redux because it introduces new concepts, Effect and Commit, as well as new functions to select and run effects.
// Effect creator... looks a lot like an Action Creator, no?export const loginEffect = (username, password) => ({ type: 'login', username, password,});export const delayEffect = milliseconds => ({ type: 'delay', milliseconds,});// This is where the asynchronous operations are definedexport function* control(action) { switch (action.type) { case ActionType.LOGIN_REQUEST: { const { username, password } = action.payload; try { const { user, msg } = yield* Ship.call( loginEffect(username, password) ); yield* Ship.commit(loginSuccess(user)); yield* Ship.call(delayEffect(2000)); yield* Ship.commit(showMessage(msg)); } catch (err) { yield* Ship.commit(loginFailure(err)); } return; } }}// This function is responsible for performing the side effectsexport const effectRunner = effect => { switch (effect.type) { case 'login': const { username, password } = effect; return postLogin(username, password); case 'delay': return new Promise(resolve => { setTimeout(resolve, effect.milliseconds); }); }}export const store = createStore( applyMiddleware( Ship.middleware(effectRunner, control) ));
Overall this is very similar to redux-saga but explicitly puts side effects in the core code rather than letting the middleware take care of them.
Pros
- Type-safe effects via yield*
- Uses familiar flow control constructs
- Logic is all in one place
- Easy to test
Cons
- Over-engineered? Too much abstraction?
- More verbose than redux-saga and fewer features
- Doesn't leverage middleware to execute side effects (have to write code instead)
- Effect runner and control would quickly get unmanageable in a non-trivial app
- But you can break them into sub functions and compose them
Redux-Observable
Redux-Observable is radically different from the others because it uses a Reactive-style of programming. It uses the term epic to describe a sequence of asynchronous operations. Everything is a stream. The dollar sign $
is used by convention to indicate a variable that holds a stream.
const loginRequestEpic = action$ => action$.ofType(LOGIN_REQUEST) .mergeMap(({ payload: { username, password } }) => Observable.from(postLogin(username, password)) .map(loginSuccess) .catch(loginFailure) );const loginSuccessEpic = action$ => action$.ofType(LOGIN_SUCCESS) .delay(2000) .mergeMap(({ payload: { msg } }) => showMessage(msg) );const rootEpic = combineEpics( loginRequestEpic, loginSuccessEpic);const epicMiddleware = createEpicMiddleware(rootEpic);const createStoreWithMiddleware = applyMiddleware( epicMiddleware)(createStore);const store = createStoreWithMiddleware(initialState);
Pros
- Built-in support for cancellation
- Supports very complex operations
- Large Reactive community
Cons
- Difficult to understand unless you're already familiar with Reactive
- Grows very complicated with complex operations
- Debugging is difficult
- Testing is difficult
Redux-Cycles
Redux-Cycles, like redux-observable, uses a Reactive style of programming. It attempts to go one step further in making pure functions by eliminating side-effects from the epics. It refers to these pure epics as cycles.
export function loginRequestCycle(sources) { const credentials$ = sources.ACTION .filter(action => action.type === LOGIN_REQUEST) .map(action => action.payload); // Note: not using our postLogin here. // Not sure how it fits in with redux-cycles const request$ = credentials$ .map(({ username, password }) => ({ url: `/api/login`, category: 'login', // TODO: indicate POST request // TODO: send credentials in request body })); const response$ = sources.HTTP .select('login') .flatten(); // TODO: how to handle errors? const action$ = response$ .map(loginSuccess); return { ACTION: action$, HTTP: request$, };}export function loginSuccessCycle(sources) { const action$ = sources.ACTION .filter(action => action.type === LOGIN_SUCCESS) .map(action => action.payload.msg) .map(msg => sources.Time.delay(2000) .mapTo(showMessage(msg)) ) .flatten(); return { ACTION: action$, };}const rootCycle = combineCycles( loginRequestCycle, loginSuccessCycle);
Sorry that my example is incomplete. The documentation was a little lacking. I think one is expected to know Reactive methods before using this library.
Pros
- Type-checker friendliness
- Declarative side effects
- Support for cancellation
Cons
- Steep learning curve
- Difficult to debug
- More verbose than redux-observable
Redux-Effects
Redux-Effects has a novel method of chaining operations by nesting actions with success and failure paths like a decision tree.
export const login = (username, password) => ({ type: 'EFFECT_COMPOSE', payload: { ...loginRequest(username, password), meta: { steps: [ [ // Success case first ({ user, msg }) => { type: 'EFFECT_COMPOSE', payload: { ...loginSuccess(user), meta: { steps: [ // TODO: how to delay // TODO: showMessage(msg) ] } } }, // Failure case second loginFailure ] ] } }});
I can see this quickly becoming a nightmare. We could manage a little better with a helper method for composition.
But it's unclear to me how we'd model delays and I also don't see a way to cancel a tree of effects because there is no access to the store (although this could be handled via middleware).
Pros
- Uses simple, familiar constructs
Cons
- Documentation needs more introductory examples
- Trees of complex effects will be difficult to manage
- Write your own middleware to handle side effects
Summary Comparison
redux-promise and redux-effects were disqualified for not being able to support the basic requirements.
redux-saga | redux-thunk | redux-observable | redux-loop | redux-ship | redux-logic | redux-cycles | redux-side-effects | |
---|---|---|---|---|---|---|---|---|
Works in ES5 | 1 | X | X | X | 1 | X | X | 1 |
Logic in one place | X | X | X | X | X | X | ||
Easy to test | X | X | X | X | ||||
Supports cancellation | X | X | X | X | ||||
Imperative code | X | X | X | X | ||||
Reactive code | X | X | ||||||
Advanced features | X | X | X | X | ||||
Scales with complexity | X | X |
1. Must be transpiled down to ES5 via Babel, TypeScript 2.3, etc.
Conclusion
So what is the right way to do asynchronous operations in Redux?
There really is no right answer for everyone. It's largely a matter of preference.
Beginners should continue to consider redux-thunk first because, as small as Redux is, there's more than enough basics to learn.
Developers who are more comfortable with an imperative, top-down style of programming will do better with redux-saga or redux-thunk. If you like the simplicity of thunks but feel dirty using them, give redux-logic a shot.
Developers who like the Reactive style of programming will feel more comfortable with redux-observable or redux-cycles.
Redux-saga is certainly winning the popularity contest and it (along with redux-logic) scales much better with complexity compared to the other contenders.
Which will you choose?
Still not sure? You might also enjoy...
- Redux-Thunk vs. Redux-Saga
- Redux Hero Part 4: Every Hero Needs a Villain (a Fun Introduction to redux-saga.js)
Tweet