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:
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.
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
Let's give it a try:
Note: the code below has a subtle issue. Make sure to read the section until the end.
First, we created a separate Generator
authorize which will perform the actual API call and notify the Store upon success.
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
loginFlow first waits for a
LOGIN_REQUEST action. Then, it retrieves the credentials in the action payload (
password) and makes a
call to the
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
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
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.
authorize failed, it'll return
undefined, which will cause
loginFlow to skip the previous process and wait for a new
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.
Suppose that when the
loginFlow is waiting for the following call to resolve:
The user clicks on the
Logout button causing a
LOGOUT action to be dispatched.
The following example illustrates the hypothetical sequence of the events:
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
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
So what's needed is some way to start
authorize without blocking so
loginFlow can continue and watch for an eventual/concurrent
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
authorize task, instead we have to
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
We're also doing
yield take(['LOGOUT', 'LOGIN_ERROR']). It means we are watching for 2 concurrent actions:
authorizetask succeeds before the user logs out, it'll dispatch a
LOGIN_SUCCESSaction, then terminate. Our
loginFlowsaga will then wait only for a future
LOGIN_ERRORwill never happen).
authorizefails before the user logs out, it will dispatch a
LOGIN_ERRORaction, then terminate. So
loginFlowwill take the
LOGOUTthen it will enter in a another
whileiteration and will wait for the next
If the user logs out before the
loginFlowwill take a
LOGOUTaction and also wait for the next
Note the call for
Api.clearItem is supposed to be idempotent. It'll have no effect if no token was stored by the
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
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 (
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:
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
- make the reducer clear the