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:

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:

  1. when COMPLETE_PURCHASE is dispatched
  2. check if the user isAuthenticated. If they are, skip to step 5a
  3. if not, dispatch OPEN_AUTH_MODAL
  4. wait for either LOGIN_SUCCESS or AUTH_CANCELED
  5. 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:

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:

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:

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.

References

Think your friends would dig this article, too?

Google+

Want to level up your React skills?

Sign up below and I'll send you articles just like this about React straight to your inbox every week or so.

No spam, promise. I hate it as much as you do!
If you need a mobile app built for your business or your idea, there's a chance I could help you with that.
Leave your email here and I will get back to you shortly.