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 in

selector — it takes the current state and returns whether the user is logged in openAuth action creator

action creator LOGIN_SUCCESS event

event AUTH_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 or AUTH_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 of ACTION_NAME to be dispatched. Returns the action object

— wait for an action of to be dispatched. Returns the action object put(action) — dispatch an action

— dispatch an 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 saga

, which triggers the saga BUY_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.

References