Redux has been a go-to state management solution ever since it entered the mainstream. It takes a solid functional programming approach to the Flux Architecture. It uses a reducer and action objects to maintain state.
Although Redux is fantastic for local state, the core library does not provide a way to handle asynchronicity.
Many options exist, and I will compare three of them.
redux-thunk, redux-observable, and redux-saga
Redux Thunk
Redux thunk is the minimum viable async middleware for redux. It achieves its goals using (unsurprisingly) thunks, which are functions returned from functions. When dispatched, they look identical to regular action creators.
// fetchUser could be an action creator
// or a thunk, we don't care
dispatch(fetchUser())
The basic example of fetching a user from an api would look like this:
function fetchUser() {
return async dispatch => {
dispatch(fetchUserStart())
try {
const user = await fetch('/api/user').then(x => x.json())
dispatch(fetchUserSuccess(user))
} catch (error) {
dispatch(fetchUserError(error))
}
}
}
This is great for most scenarios. It feels natural. In my opinion, redux-thunk should be preferred in 90% of cases. When I'm writing a redux application, I tend to keep redux-thunk around even if I adopt another async middleware later.
While redux-thunk is excellent at what it does well, it's pretty terrible where it's lacking. Things like debouncing inputs, canceling current requests, and action sequences are practically impossible to implement in a straight forward manner using thunks.
That's where the other middlewares come in!
Redux Observable
It was while writing redux-observable code that I decided to make this post. RxJS Observables are immensely powerful for async. Redux-observables brings us this power in the form of Epics.
An epic is a mapping from an action to an action. In type terms:
type AppEpic = (
action$: Observable<Action>,
state$: Observable<AppState>,
) => Observable<Action>
This type might look a bit weird, but let's consider how to do the basic async example using observables.
const fetchUserEpic: AppEpic = action$ =>
action$.pipe(
filter(fetchUserStart.match),
switchMap(action =>
fromPromise(fetch('/api/user').then(x => x.json())).pipe(
map(payload => fetchUserFulfilled(payload)),
catchError(error => of(fetchUserError(error))),
),
),
)
This is more complicated than the redux-thunk example, in order to understand what's going on you
need a working knowledge of Observables, what the operators switchMap
, map
, filter
, and catchError
do.
Not to mention needing to know what an operator even is!
Compared to observables, thunks require less knowledge to use. But as discussed before, thunks don't deal well with debouncing inputs, canceling current requests, and action sequences.
Imagine a search box where as the user types, search results are shown.
This has some important constraints:
- we don't want to send a request on every keystroke (that would choke the server)
- we want to cancel in progress requests on a new keystroke because the one in progress is no longer relevant
- we need to make sure that the last request corresponds to the last keystroke, otherwise we might display stale data
Imagine this component:
export function RepoFetcher() {
const dispatch = useDispatch()
const repos = useSelector(x => x.repos)
return (
<>
<input
type="text"
onChange={ev => dispatch(fetchReposInput(ev.currentTarget.value))}
/>
<h3>
{repos.error ? 'Error!' : repos.isLoading ? 'Loading...' : 'Results'}{' '}
</h3>
<ul>
{repos.repos.map(x => (
<li key={x.id}>
<a href={x.html_url}>{x.full_name}</a>
</li>
))}
</ul>
</>
)
}
If we used a thunk to handle the fetchReposInput action, it might look like this:
const fetchReposInput = input => async dispatch => {
dispatch(fetchReposStart())
try {
const repos = await fetch(
'https://api.github.com/search/repositories?per_page=10&q=' +
encodeURIComponent(input),
).then(x => x.json())
dispatch(fetchReposSuccess(repos))
} catch (error) {
dispatch(fetchReposError(error))
}
}
We have broken all 3 of the constraints with this code.
- Every keystroke triggers a request
- The old fetch is still alive when a new keystroke arrives
- There's no correlation between time of keystroke and time of response
The errors these constraint violations cause can be hard to track down as well.
Using observables, we can write code that fits within the constraints fairly easily
const searchRepos = q =>
// utility exported by rxjs/ajax
// it returns an Observable<JSON>
// and uses XMLHttpRequest under the hood
// to facilitate cancellations
ajax.getJSON(
`https://api.github.com/search/repositories?per_page=10&q=${encodeURIComponent(
q,
)}`,
)
const fetchReposEpic = action$ =>
action$.pipe(
filter(fetchReposInput.match),
debounceTime(300),
switchMap(action =>
of(fetchReposStart()).pipe(
concat(
searchRepos(action.payload).pipe(
map(payload => fetchReposSuccess(payload.items)),
catchError(error => of(fetchReposError(error))),
),
),
),
),
)
Let's step through this:
- We get every action that's a fetchReposInput action
- We debounce by 300ms; this means that we don't continue the pipe until 300ms have passed since the last action
- We
switchMap
, this is also known as aflatMap
,chain
, orbind
in other languages, but here we can think of it as going fromObservable<Observable<Action>>
to anObservable<Action>
- In the switchMap we create a new observable that has a
fetchReposStart
action, and concatenate thesearchRepos
request result
This means our Epic will return in order:
fetchReposStart()
fetchReposSuccess(payload.items)
|fetchReposError(error)
switchMap
has properties that help us adhere to our constraints.
When a new input action is dispatched while we're still fetching, it automatically stops subscribing to the current observable and cancels it.
Since we're "just" using RxJS we also gain access to a whole host of operators.
debounceTime
for waitingfilter
to select actionsconcat
to append an observable to anothermap
to transformcatchError
to catch errors
There are literally over a hundred operators like these. So it's very likely There's an operator for that
Thoughts on Redux Observable
I think observables have some benefits over thunks in cases where
- You're doing something more complicated than a set and forget async fetch
- You already know observables, or are willing to put in the effort to learn them, and teach your team.
Redux Saga
Redux saga is the final middleware we'll be looking at here.
Redux saga aims to make side effects more natural to manage, more efficient to execute, better at handling failures, and easy to test.
It does this while also maintaining an imperative syntax.
Let's take a look at how we would implement the observable example using sagas.
function* fetchRepos(action) {
yield delay(300)
yield put(fetchReposStart())
try {
const response = yield call(Api.fetchRepos, action.payload)
yield put(fetchReposSuccess(response.items))
} catch (error) {
yield put(fetchReposError(error))
}
}
function* watchFetchReposInput() {
yield takeLatest(fetchReposInput.match, fetchRepos)
}
If you squint a bit, the fetchRepos
method almost looks familiar. It's the same structure as
the basic async thunk. Swap out put
for dispatch
, remove the yields, and we're there!
There's some added noise, but in return, we get great benefits.
takeLatest
also handles cancelling in progress effects.
A more convoluted example
For a basic async thunk, you do not need sagas. Where sagas shine are action sequences, especially with dependent actions.
Imagine a card game, where before you can play a card, a modal should be displayed where you pick a mana distribution to pay for the card (think two blue, three white).
The sequence of actions would be
Request Play Card
|
Show Mana Modal
/ \
/ \
/ \
Close Mana Modal Pay Mana
|
Play Card
To be clear this example is not something you need sagas for, to illustrate let's look at how we would implement this sequence using observables.
export const payMana = (cost, action$, onPayAction) =>
of(manaModalSlice.actions.showModal(cost)).pipe(
concat(
action$.pipe(
takeUntil(
actions$.pipe(filter(manaModalSlice.actions.closeModal.match)),
),
filter(manaSlice.actions.payMana.match),
mapTo(onPayAction),
),
),
)
export const rootEpic = (action$, state$) =>
action$.pipe(
filter(requestPlayCard.match),
switchMap(action => {
const id = action.payload.id
const card = cardSelectors.selectById(id)(state$.value)
return payMana(
card.manaCost,
action$,
playerSlice.actions.playCard({ id }),
)
}),
)
This is very declarative. A gripe I've had with redux-observable is just how unintuitive it is to create dependent action sequences. It always requires a flatMap, and this amount of code is for an event series with only one branch.
How would we handle this use case with a saga?
function* payMana(cost) {
yield put(manaModalSlice.actions.showModal(cost))
// an infinite while loop is okay, because we know
// that the only way to get out of the modal
// is through the two actions handled in the loop
while (true) {
const action = yield take()
if (manaSlice.actions.payMana.match(action)) {
return true
}
if (manaModalSlice.actions.closeManaModal.match(action)) {
return false
}
}
}
function* playCard(action) {
const id = action.payload.id
const card = yield select(cardSelectors.selectById(id))
const isPayed = yield call(getManaPayment, card.manaCost)
if (isPayed) {
yield put(playerSlice.actions.playCard({ id }))
}
}
function* watchRequestPlayCard() {
yield takeEvery(requestPlayCard.match, playCard)
}
In my opinion, this code is easier to follow than the Observable example. I can read through the code step by step to get an understanding of what's going on.
- For every
requestPlayCard
action we run theplayCard
method. - To play a card we first call
getManaPayment
getManaPayment
dispatches ashowModal
action and then waits for acloseModal
or apayMana
action and returns a corresponding boolean- Back in the
playCard
method, we check whether the mana has been paid and if so we dispatch theplayCard
action
This sort of understanding is harder to come by in the Observable version. I believe this is because in that example we need an understanding of RxJS operators to know what's going on. With sagas, we only require an understanding of JavaScript language primitives.
I believe, redux-saga is to redux-observable what async/await is to Promises. It's a really great syntax for declaring side effects in an imperative way. All the heavy lifting is handled by redux-saga: waiting, dispatching, and so on. We just yield descriptions of what should be done making sagas inherently testable.
Conclusion
The argument for redux-observable is that it's just RxJS, which is honestly fantastic. RxJS has been a godsend for reactive programming and async in general. But when it comes to complex dependent sequences the syntax sagas provide fits better.
I would much rather onboard a newbie to a codebase with sagas than observables. That being said I have a few rules of thumb to guide me towards the correct async middleware for a given scenario.
- For basic asynchronous set/forget calls, use thunks
- To immediately dispatch multiple actions, use thunks
- For everything else there're sagas
After using both sagas and observables, I'll stick with sagas for now.
If you already know RxJS, and need an async middleware, I think redux-observable is fantastically expressive. But I think there're tradeoffs to everything, and with redux-observable I felt more constrained than with sagas. Furthermore, it was less difficult for me to explain to others what was going on in a saga than an observable, which to me, weighs much higher than terse, meaning dense, code.