Lazy registration with Redux and Sagas
30 Oct 2016
You know Redux, enough to make a nice little app. The simple stuff is easy — you can change state in the reducers, you can even make AJAX requests with your eyes tied.
Say you are working on a betting app. A day comes, and the design team gives you a new case:
We should let anonymous users buy tickets (make bets). When a purchase is completed, try to authenticate the user if they are not logged in yet. If they log in successfully, make a request to the server to buy one. Otherwise, wait for another purchase completion.
(By the way, this is a technique called Gradual Engagement or "lazy registration," and it's very common in web apps to smoothen the UX.)
Anyhow. Your brain is melting. "How can I possibly turn that into Redux actions and reducers?" you keep asking yourself.
This?
With thunks, it could be something like this:
function completePurchase() {
return function (dispatch, getState) {
if (isAuthenticated(getState()) {
dispatch(buy());
} else {
dispatch(openAuth({ onSuccess: completePurchase }));
}
}
}
function authReducer(state, action) {
// we now have to manage the callback...
switch (action.type) {
case 'OPEN_AUTH':
return { ...state, isAuthOpen: true, onSuccess: action.onSuccess };
case 'LOGIN_SUCCESS':
return { ...state, currentUser: action.user, isAuthOpen: false, onSuccess: null };
case 'AUTH_CANCELED':
return { ...state, isAuthOpen: false, onSuccess: null };
default: return state;
}
}
function loginSuccessful() {
return function (dispatch, getState) {
const afterAction = getState().auth.onSuccess;
dispatch({ type: 'LOGIN_SUCCESS' });
dispatch(afterAction());
}
}
That's a mess... the logic is all over the place! We now have to change the auth module so that it knows about the "callback", and never forgets to clear it.
Having action creators that smart is probably not a good idea, either. Neither is storing functions in Redux.
Uhh!
Redux-saga
That's where redux-saga steps in. Redux-saga is a library that allows to express and operate the async flows, much like the one we just discussed, in a super-easy manner.
At the heard of it is the concept of a saga — a small function that codifies the complex flow. Sagas help you solve that with easy-to-read code that looks pretty synchronous. A saga can look like this:
function* welcomeSaga() {
// when this action happens
yield take(LOGIN_SUCCESS);
// dispatch this action
yield put(showWelcomePopup());
}
Sagas are almost like regular functions... except they have a little *
star before their name and they use a new keyword, yield
.
In other words, they are generator functions.
Aside: Authentication
The authentication process is complicated: there's social auth, validation errors, server errors, password reset and so on...
Luckily, that's all abstracted away by our auth module. It gives us the following four pieces:
isAuthenticated
selector — it takes the current state and returns whether the user is logged inopenAuth
action creatorLOGIN_SUCCESS
eventAUTH_CANCELED
event
These are just enough! We trust that those work properly and don't have to think about how authentication works under the hood.
The flow, in words
To allow for lazy registration, we need to force authentication.
We will create another action creator, BUY_TICKET
, that will be responsible for the AJAX request.
Now that we have the auth API figured out, we can try to model our lazy registration use-case in words:
- when
COMPLETE_PURCHASE
is dispatched - check if the user
isAuthenticated
. If they are, skip to step 5a - if not, dispatch
OPEN_AUTH_MODAL
- wait for either
LOGIN_SUCCESS
orAUTH_CANCELED
- Then:
a. if LOGIN_SUCCESS
was dispatched, make a remote request with BUY_TICKET
b. if AUTH_CANCELED
was dispatched, start over from step 1
If this flow reads anything but synchronous to you, it's because it is. Note how there are three points at which we just sit there and wait for something to happen...
We can't just "wait" in action creators and such, and we don't want to scatter that flow across with thunks.
So how do sagas help us in this very case?
Here go some sagas
The three most widely used saga commands are:
take(ACTION_NAME)
— wait for an action ofACTION_NAME
to be dispatched. Returns the action objectput(action)
— dispatch anaction
select(selector)
— apply a selector to the current state. A selector is simply a function that takes in whole Redux state and returns something
We can easily express our flow using these. Observe:
function* purchaseFlow() {
// when the button is clicked
yield take(COMPLETE_PURCHASE);
// check if the user is authenticated already
const isAuthed = yield select(isAuthenticated);
if (!isAuthed) {
// dispatch an action to open the sign up modal
yield put(openAuthModal());
// wait for either LOGIN_SUCCESS or AUTH_CANCELED
const result = yield take([LOGIN_SUCCESS, AUTH_CANCELED]);
if (result.type === AUTH_CANCELED) {
return;
}
}
// if either already authenticated, or just signed up
// dispatch an action that will actually make the request
yield put(buy());
}
Whoa, that does read well!
Compare that with our original English statement of the flow:
When a purchase is completed, try to authenticate the user if they are not logged in yet. If they log in successfully, make a request to the server. Otherwise, wait for another purchase completion.
It's pretty close to the code above if you ask me.
There are just a couple of issues with it:
1. Sagas are only executed once
So if we want to check auth and make a request every time the button is clicked, we would need to loop in the saga.
function* purchaseFlow() {
// loop infinitely
while (true) {
// every time the button is clicked
yield take(COMPLETE_PURCHASE);
// check if the user is authenticated already
const isAuthed = yield select(isAuthenticated);
if (!isAuthed) {
// dispatch an action to open the sign up modal
yield put(openAuthModal());
// wait for either LOGIN_SUCCESS or AUTH_CANCELED
const result = yield take([LOGIN_SUCCESS, AUTH_CANCELED]);
if (result.type === AUTH_CANCELED) {
// if canceled, start from step one
break;
}
}
// if either already authenticated, or just signed up
// dispatch an action that will actually make the request
yield put(buy());
}
}
That case is so common, in fact, that redux-saga provides a helper function to do that, takeEvery
:
function* purchaseFlow() {
// every time the button is clicked
yield takeEvery(COMPLETE_PURCHASE, function* () {
// check if the user is authenticated already
const isAuthed = yield select(isAuthenticated);
if (!isAuthed) {
// dispatch an action to open the sign up modal
yield put(openAuthModal());
// wait for either LOGIN_SUCCESS or AUTH_CANCELED
const result = yield take([LOGIN_SUCCESS, AUTH_CANCELED]);
if (result.type === AUTH_CANCELED) {
// if canceled, start from step one
return;
}
}
// if either already authenticated, or just signed up
// dispatch an action that will actually make the request
yield put(buy());
});
}
We could even go a step further and extract the inner function:
function* purchaseFlow() {
// check if the user is authenticated already
const isAuthed = yield select(isAuthenticated);
if (!isAuthed) {
// dispatch an action to open the sign up modal
yield put(openAuthModal());
// wait for either LOGIN_SUCCESS or AUTH_CANCELED
const result = yield take([LOGIN_SUCCESS, AUTH_CANCELED]);
if (result.type === AUTH_CANCELED) {
// if canceled, start from step one
return;
}
}
// if either already authenticated, or just signed up
// dispatch an action that will actually make the request
yield put(buy());
}
// watch for every purchase completion, and start the purchaseFlow
function* watchPurchaseFlow() {
yield takeEvery(COMPLETE_PURCHASE, purchaseFlow);
}
2. Our purchase saga seems to know too much about the authentication process
Wouldn't it be nice if we could extract the auth logic into another saga and call it? Well, we can!
function* authFlow() {
// check if the user is authenticated already
const isAuthed = yield select(isAuthenticated);
if (isAuthed) {
return true;
} else {
// dispatch an action to open the modal
yield put(openAuthModal());
// wait for either LOGIN_SUCCESS or AUTH_CANCELED
const result = yield take([LOGIN_SUCCESS, AUTH_CANCELED]);
if (result.type === LOGIN_SUCCESS) {
return true;
} else {
return false;
}
}
}
function* purchaseFlow() {
// execute the auth flow
// `call` is the command to call other sagas
const hasAuthed = yield call(authFlow);
if (hasAuthed) {
// dispatch an action that will actually make the request
yield put(buyTicket());
}
}
function* watchPurchaseFlow() {
yield takeEvery(COMPLETE_PURCHASE, purchaseFlow);
}
Isn't this pretty?
You could argue it's a bit more code than the thunk version, but the point is: it is easier to follow. The flow is just a few related functions and not a spaghetti of logic in all the action creators.
Alternatives
The non-saga alternative would involve either:
- a custom Redux middleware that would essentially re-implement what redux saga does; or
- with thunks: spreading this logic all over the place... scattered into many reducers and action creators. And that's only for this case. With evolving requirements, spreading the logic in this way would be a nightmare to read, write, debug, and maintain.
In contrast, with redux-saga you will write a separate, small, focused function for every distinct business requirement, without mashing everything together.
Outro
We now have a place to put all the logic that orchestrates several distinct pieces of our application (in this case: authentication and purchases), that fits neither action creators, nor reducers.
The resulting saga reads pretty close to the original flow description:
When a purchase is completed, try to authenticate the user if they are not logged in yet. If they log in successfully, make a request to the server. Otherwise, wait for another purchase completion.
We now have two purchase-related actions:
COMPLETE_PURCHASE
, which triggers the sagaBUY_TICKET
, which is fired to make a server request. As mentioned, it can be implemented in any way; our flow isn't concerned with that.
As we have explored, sagas are not some "hard concept" or something available to the elites. They also aren't some distant thing that can only be applied to differential equations.
Instead, sagas are a flexible, practical tool to make possible business requirements of varying complexity. You can be improving your user experience with them as I've just shown, but you can also use them for managing other side-effects, like analytics, and many more.