Non-blocking calls
In the previous section, we saw how the take
Effect allows us to better describe a non-trivial flow in a central place.
Revisiting the login flow example:
function* loginFlow() {
while (true) {
yield take('LOGIN')
// ... perform the login logic
yield take('LOGOUT')
// ... perform the logout logic
}
}
Let's complete the example and implement the actual login/logout logic. Suppose we have an API which permits us to authorize the user on a remote server. If the authorization is successful, the server will return an authorization token which will be stored by our application using DOM storage (assume our API provides another service for DOM storage).
When the user logs out, we'll delete the authorization token stored previously.
First try
So far we have all Effects needed to implement the above flow. We can wait for specific actions in the store using the take
Effect. We can make asynchronous calls using the call
Effect. Finally, we can dispatch actions to the store using the put
Effect.
Let's give it a try:
Note: the code below has a subtle issue. Make sure to read the section until the end.
import { take, call, put } from 'redux-saga/effects'
import Api from '...'
function* authorize(user, password) {
try {
const token = yield call(Api.authorize, user, password)
yield put({type: 'LOGIN_SUCCESS', token})
return token
} catch(error) {
yield put({type: 'LOGIN_ERROR', error})
}
}
function* loginFlow() {
while (true) {
const {user, password} = yield take('LOGIN_REQUEST')
const token = yield call(authorize, user, password)
if (token) {
yield call(Api.storeItem, {token})
yield take('LOGOUT')
yield call(Api.clearItem, 'token')
}
}
}
First, we created a separate Generator authorize
which will perform the actual API call and notify the Store upon success.
The loginFlow
implements its entire flow inside a while (true)
loop, which means once we reach the last step in the flow (LOGOUT
) we start a new iteration by waiting for a new LOGIN_REQUEST
action.
loginFlow
first waits for a LOGIN_REQUEST
action. Then, it retrieves the credentials in the action payload (user
and password
) and makes a call
to the authorize
task.
As you noted, call
isn't only for invoking functions returning Promises. We can also use it to invoke other Generator functions. In the above example, loginFlow
will wait for authorize until it terminates and returns (i.e. after performing the api call, dispatching the action and then returning the token to loginFlow
).
If the API call succeeds, authorize
will dispatch a LOGIN_SUCCESS
action then return the fetched token. If it results in an error, it'll dispatch a LOGIN_ERROR
action.
If the call to authorize
is successful, loginFlow
will store the returned token in the DOM storage and wait for a LOGOUT
action. When the user logs out, we remove the stored token and wait for a new user login.
If the authorize
failed, it'll return undefined
, which will cause loginFlow
to skip the previous process and wait for a new LOGIN_REQUEST
action.
Observe how the entire logic is stored in one place. A new developer reading our code doesn't have to travel between various places to understand the control flow. It's like reading a synchronous algorithm: steps are laid out in their natural order. And we have functions which call other functions and wait for their results.
But there is still a subtle issue with the above approach
Suppose that when the loginFlow
is waiting for the following call to resolve:
function* loginFlow() {
while (true) {
// ...
try {
const token = yield call(authorize, user, password)
// ...
}
// ...
}
}
The user clicks on the Logout
button causing a LOGOUT
action to be dispatched.
The following example illustrates the hypothetical sequence of the events:
UI loginFlow
--------------------------------------------------------
LOGIN_REQUEST...................call authorize.......... waiting to resolve
........................................................
........................................................
LOGOUT.................................................. missed!
........................................................
................................authorize returned...... dispatch a `LOGIN_SUCCESS`!!
........................................................
When loginFlow
is blocked on the authorize
call, an eventual LOGOUT
occurring in between the call and the response will be missed, because loginFlow
hasn't yet performed the yield take('LOGOUT')
.
The problem with the above code is that call
is a blocking Effect. i.e. the Generator can't perform/handle anything else until the call terminates. But in our case we do not only want loginFlow
to execute the authorization call, but also watch for an eventual LOGOUT
action that may occur in the middle of this call. That's because LOGOUT
is concurrent to the authorize
call.
So what's needed is some way to start authorize
without blocking so loginFlow
can continue and watch for an eventual/concurrent LOGOUT
action.
To express non-blocking calls, the library provides another Effect: fork
. When we fork a task, the task is started in the background and the caller can continue its flow without waiting for the forked task to terminate.
So in order for loginFlow
to not miss a concurrent LOGOUT
, we must not call
the authorize
task, instead we have to fork
it.
import { fork, call, take, put } from 'redux-saga/effects'
function* loginFlow() {
while (true) {
...
try {
// non-blocking call, what's the returned value here ?
const ?? = yield fork(authorize, user, password)
...
}
...
}
}
The issue now is since our authorize
action is started in the background, we can't get the token
result (because we'd have to wait for it). So we need to move the token storage operation into the authorize
task.
import { fork, call, take, put } from 'redux-saga/effects'
import Api from '...'
function* authorize(user, password) {
try {
const token = yield call(Api.authorize, user, password)
yield put({type: 'LOGIN_SUCCESS', token})
yield call(Api.storeItem, {token})
} catch(error) {
yield put({type: 'LOGIN_ERROR', error})
}
}
function* loginFlow() {
while (true) {
const {user, password} = yield take('LOGIN_REQUEST')
yield fork(authorize, user, password)
yield take(['LOGOUT', 'LOGIN_ERROR'])
yield call(Api.clearItem, 'token')
}
}
We're also doing yield take(['LOGOUT', 'LOGIN_ERROR'])
. It means we are watching for 2 concurrent actions:
If the
authorize
task succeeds before the user logs out, it'll dispatch aLOGIN_SUCCESS
action, then terminate. OurloginFlow
saga will then wait only for a futureLOGOUT
action (becauseLOGIN_ERROR
will never happen).If the
authorize
fails before the user logs out, it will dispatch aLOGIN_ERROR
action, then terminate. SologinFlow
will take theLOGIN_ERROR
before theLOGOUT
then it will enter in a anotherwhile
iteration and will wait for the nextLOGIN_REQUEST
action.If the user logs out before the
authorize
terminates, thenloginFlow
will take aLOGOUT
action and also wait for the nextLOGIN_REQUEST
.
Note the call for Api.clearItem
is supposed to be idempotent. It'll have no effect if no token was stored by the authorize
call. loginFlow
makes sure no token will be in the storage before waiting for the next login.
But we're not yet done. If we take a LOGOUT
in the middle of an API call, we have to cancel the authorize
process, otherwise we'll have 2 concurrent tasks evolving in parallel: The authorize
task will continue running and upon a successful (resp. failed) result, will dispatch a LOGIN_SUCCESS
(resp. a LOGIN_ERROR
) action leading to an inconsistent state.
In order to cancel a forked task, we use a dedicated Effect cancel
import { take, put, call, fork, cancel } from 'redux-saga/effects'
// ...
function* loginFlow() {
while (true) {
const {user, password} = yield take('LOGIN_REQUEST')
// fork return a Task object
const task = yield fork(authorize, user, password)
const action = yield take(['LOGOUT', 'LOGIN_ERROR'])
if (action.type === 'LOGOUT')
yield cancel(task)
yield call(Api.clearItem, 'token')
}
}
yield fork
results in a Task Object. We assign the returned object into a local constant task
. Later if we take a LOGOUT
action, we pass that task to the cancel
Effect. If the task is still running, it'll be aborted. If the task has already completed then nothing will happen and the cancellation will result in a no-op. And finally, if the task completed with an error, then we do nothing, because we know the task already completed.
We are almost done (concurrency is not that easy; you have to take it seriously).
Suppose that when we receive a LOGIN_REQUEST
action, our reducer sets some isLoginPending
flag to true so it can display some message or spinner in the UI. If we get a LOGOUT
in the middle of an API call and abort the task by killing it (i.e. the task is stopped right away), then we may end up again with an inconsistent state. We'll still have isLoginPending
set to true and our reducer will be waiting for an outcome action (LOGIN_SUCCESS
or LOGIN_ERROR
).
Fortunately, the cancel
Effect won't brutally kill our authorize
task. Instead, it'll give it a chance to perform its cleanup logic. The cancelled task can handle any cancellation logic (as well as any other type of completion) in its finally
block. Since a finally block execute on any type of completion (normal return, error, or forced cancellation), there is an Effect cancelled
which you can use if you want handle cancellation in a special way:
import { take, call, put, cancelled } from 'redux-saga/effects'
import Api from '...'
function* authorize(user, password) {
try {
const token = yield call(Api.authorize, user, password)
yield put({type: 'LOGIN_SUCCESS', token})
yield call(Api.storeItem, {token})
return token
} catch(error) {
yield put({type: 'LOGIN_ERROR', error})
} finally {
if (yield cancelled()) {
// ... put special cancellation handling code here
}
}
}
You may have noticed that we haven't done anything about clearing our isLoginPending
state. For that, there are at least two possible solutions:
- dispatch a dedicated action
RESET_LOGIN_PENDING
- make the reducer clear the
isLoginPending
on aLOGOUT
action