Javascript Power Tools Part III: Real-world redux-saga Patterns


In the past two articles, we’ve talked a lot about redux-saga in the abstract, without much concern for real-world applications. Now that we’re equipped with new knowledge, we’re ready to jump in and start putting the pieces back together.

First, we’ll take a look at a pattern for structuring behavior in single-page applications using redux-saga and redux-little-router, and then we’ll build a saga that implements the business logic for a basic form.

Pairing redux-saga with redux-little-router

I really love redux-little-router, and I’m not just saying that because it’s a Formidable project. When you’re already reading from the Redux store to access state and dispatching Redux actions to modify state, it feels very elegant to interact with the browser location the same way. However, when used in conjunction with redux-saga, we gain an additional benefit: the ability to trigger behaviors in response to browser location changes. Why is this beneficial?

Well, first off, it separates the business logic associated with route changes from the view lifecycle. When I’m building a React application, I typically prefer to keep my React components as stateless and declarative as possible, and shoehorning business logic into React components makes that difficult at best.

Secondly, in a large project, it typically behooves us to avoid running too many concurrent sagas at once because it can quickly become difficult to determine the ramifications of dispatching a particular action. If we’re monitoring for route changes from within redux-saga, we can ensure that the only sagas running at any given time are those relevant to the current route.

With this approach, each route becomes a ‘mini-application’, which starts up when the user navigates to it and shuts down when they navigate away. Let’s take a look at how we could implement this.

First, we’ll start out with a rootSaga which will be the entry point for our entire application.

export default function* rootSaga() {
  console.log("Starting up the root saga!");
}

Next, we’ll put together a configureStore function which attaches both redux-little-router and redux-saga to our Redux store.

const REDUCERS = {
  todos: (state = {}, action) => state,
  debug: (state, action) => action,
};

const ROUTES = {
  '/'               : {},
  '/todos'          : {},
  '/todos/new'      : {},
  '/todos/:id'      : {},
  '/todos/:id/edit' : {},
};

export const configureStore = (initialState = {}) => {
  const {
    reducer     : routerReducer,
    enhancer    : routerEnhancer,
    middleware  : routerMiddleware,
  } = routerForBrowser({ routes: ROUTES });

  const sagaMiddleware = createSagaMiddleware();

  const store = createStore(
    combineReducers({
      ...REDUCERS,
      router: routerReducer,
    }),
    initialState,
    compose(
      routerEnhancer,
      applyMiddleware(
        sagaMiddleware,
        routerMiddleware,
      ),
    )
  );

  sagaMiddleware.run(rootSaga);

  const initialRouterState = store.getState().router;
  store.dispatch(initializeCurrentLocation(initialRouterState));

  return store;
};

The first thing to notice here is the initializeCurrentLocation action we’re dispatching during initialization. Since we’ve already started our rootSaga, we can take that action right away.

export default function* rootSaga() {
  console.log("Starting up the root saga!");

  const action = yield take("ROUTER_LOCATION_CHANGED");
  const location = action.payload;

  console.log("Your current location is:", location);
}

Let’s go a little further, and make our saga take any "ROUTER_LOCATION_CHANGED" action.

export default function* rootSaga() {
  console.log("Starting up the root saga!");

  while (true) {
    const action = yield take('ROUTER_LOCATION_CHANGED');
    const location = action.payload;
    console.log("Your current location is:", location);
  }
}

To help us make our code more functional, redux-saga provides a helper called takeEvery which does something somewhat similar.

export default function* rootSaga() {
  console.log("Starting up the root saga!");

  yield takeEvery('ROUTER_LOCATION_CHANGED', function* (action) {
    const location = action.payload;
    console.log("Your current location is:", location);
  });
}

There’s a problem here, though. Suppose the inner saga was performing some long-running action (emulated by a 10-second delay in the example below). What would happen if the user started navigating around the site quickly?

export default function* rootSaga() {
  console.log("Starting up the root saga!");

  yield takeEvery('ROUTER_LOCATION_CHANGED', function* (action) {
    const location = action.payload;
    yield delay(10000); Wait 10 seconds, for some reason
    console.log("Your current location is:", location);
  });
}

You guessed it. This is Race-condition Central, population: you. Fortunately, redux-saga provides a helper called takeLatest, which ensures that only one saga is running at a time by cancelling the previously running saga when a new action comes in. Let’s make the change, and add some exception handling so we can see this behavior in vivo.

export default function* rootSaga() {
  console.log("Starting up the root saga!");

  yield takeLatest('ROUTER_LOCATION_CHANGED', function* (action) {
    const location = action.payload;
    try {
      yield delay(10000);
      console.log("Your current location is:", location);
    } finally {
      if (yield cancelled()) {
        console.log("Fine, fine! Your location WAS", location);
      }
    }
  });
}

So, we’ve now built a saga with the following properties:

  • Starts a new saga whenever a location change occurs
  • Cancels the saga associated with the previous location change
  • Uses the current location to perform effects

From here, we’ll take advantage of the third bullet point to branch into different sagas based on the current route. First, let’s extract that inline saga into the module scope and give it a name.

function* navigationSaga(action) {
  const location = action.payload;
  console.log("Your current location is:", location);
}

export default function* rootSaga() {
  console.log("Starting up the root saga!");
  yield takeLatest('ROUTER_LOCATION_CHANGED', navigationSaga);
}

Now, let’s create some stubs for the different behavior we want to perform at each route.

function* navigationSaga(action) {
  const location = action.payload;
  switch (location.route) {
    case '/': {
      break;
    }
    case '/todos': {
      break;
    }
    case '/todos/:id': {
      break;
    }
    case '/todos/new': {
      break;
    }
    case '/todos/:id/edit': {
      break;
    }
    default: {
      break;
    }
  }
}

export default function* rootSaga() {
  console.log("Starting up the root saga!");
  yield takeLatest('ROUTER_LOCATION_CHANGED', navigationSaga);
}

This seems like it could get really messy before long. And what if someone forgets a break statement? Let’s keep refactoring.

const SAGA_FOR_ROUTE = {
  '/'               : function* homeSaga() {},
  '/todos'          : function* listTodosSaga() {},
  '/todos/:id'      : function* showTodoSaga() {},
  '/todos/new'      : function* newTodoSaga() {},
  '/todos/:id/edit' : function* editTodoSaga() {},
};

function* navigationSaga(action) {
  const location = action.payload;
  const saga = SAGA_FOR_ROUTE[location.route];

  if (saga) {
    yield call(saga, location);
  }
}

export default function* rootSaga() {
  yield [
    takeLatest("ROUTER_LOCATION_CHANGED", navigationSaga),
  ];
}

Wonderful! In addition to being less verbose, redux-saga can log our sagas by name if they are cancelled due to a ROUTER_LOCATION_CHANGED (in development mode, anyways).

Now, each route-specific saga can react to being started and stopped completely on its own. For instance, if we were to implement editTodoSaga, it would perhaps look something like this.

function* editTodoSaga(location) {
  const { id } = location.params;

  try {
    // Some behaviors which happen once when the user navigates to this route
    yield put(startedEditingTodo(id));
    yield call(ensureTodoExistsLocally, id);

    // Start up some long-running behaviors tied to this saga's lifetime.
    yield all([
      fork(takeEvery, "BLAH", handleBlahSaga),
      fork(someOtherLongRunningSaga),
    ]);

  } finally {
    // Whether we finished naturally or got cancelled by our parent because the
    // route changed, clean up after ourselves before exiting for good.
    yield put(finishedEditingTodo(id));
  }
}

‘Create Todo’ form

Along those lines, let’s take a look at a “mini-application” we could implement. Specifically, we’ll write an implementation for the newTodoSaga associated with the /todos/new route above.

We’ll assume that somewhere in the view layer there’s a form the user’s filling out and eventually submitting - we want to implement just the business logic for it. Let’s write a quick outline of that.

  • Wait for the user to submit the form
  • Perform client-side validation
    • If client-side validation fails, show an error message and start over
  • Send the form data to the server
    • If successful, show the user their new to-do
    • If not successful, show an error message and start over

Sounds easy enough. Let’s start out with an empty saga.

function* newTodoSaga() {
}

First things first: we need to wait for the user to submit a form. We’ll assume that the view layer will dispatch an action called SUBMIT_TODO_FORM with the form data as payload.

function* newTodoSaga() {
  const action = yield take('SUBMIT_TODO_FORM');
  const formData = action.payload;
}

Next, let’s write a quick validation function…

const validateForm = (formData) => {
  if (formData.name === '') {
    throw new Error('"name" field must not be blank!');
  }
}

…and invoke it like so.

function* newTodoSaga() {
  const action = yield take('SUBMIT_TODO_FORM');
  const formData = action.payload;
  yield call(validateForm, formData);
}

At this point, the user can submit their form for validation, but there are two problems: if their input fails validation, we’d like to tell them why, and naturally, we’d like to allow them to resubmit the form.

Let’s handle these issues separately.

First, we need to allow the user to retry form submission. The easiest way to do this is with a loop which will not exit until their input passes validation.

function* newTodoSaga() {

  while (true) {
    const action = yield take('SUBMIT_TODO_FORM');
    const formData = action.payload;

    try {
      yield call(validateForm, formData);
      break;
    } catch (err) {
      continue;
    }
  }
}

Note that the continue statement is not strictly necessary, but I consider it good form in this circumstance. It explicitly indicates the expected flow of this procedure, rather than expecting the reader to notice that the loop will start over. We’ll see why this is important in a moment.

We’ve handled both the valid and invalid cases, so now let’s assume that we have a showErrorNotification action creator which dispatches some action indicating that the application should show an error notification.

function* newTodoSaga() {
  while (true) {
    const action = yield take('SUBMIT_TODO_FORM');
    const formData = action.payload;

    try {
      yield call(validateForm, formData);
      break;
    } catch (err) {
      yield put(showErrorNotification(err.message);
      continue;
    }
  }
}

By the time we reach the break statement, we know we have valid form data, so let’s send it to an API endpoint. We are going to make an assumption that we have a function called createTodo which somehow does this for us. (I’ve had success with these sorts of thin layers over the Fetch API.)

Because this API call could fail for all kinds of reasons, we need to make sure we handle those cases by using a simple try-catch block, just like we did before. (Notice that we’ve removed the break statement.)

function* newTodoSaga() {
  while (true) {
    const action = yield take('SUBMIT_TODO_FORM');
    const formData = action.payload;

    try {
      yield call(validateForm, formData);
    } catch (err) {
      yield put(showErrorNotification(err.message);
      continue;
    }

    try {
      yield call(createTodoApi, formData);
      break;
    } catch (err) {
      yield put(showErrorNotification(err.message);
      continue;
    }
  }
}

Notice that our earlier continue statement has now become crucial to the correct behavior. If we hadn’t been so conscientious, this would have caused a bug and probably made us feel kinda dumb for missing it.

And yes, I can hear you saying “but this is PROCEDURAL!” to which I say, “business logic is inherently procedural, and expressing it as such makes our intent clearer.” That being said, it is easy to let code like this get out of hand, so it’s important to stay disciplined and keep it focused.

Business logic code should tell a story. Like a good storyteller, it should draw focus to the important details and elide over the minute ones. That is, unless we ask for them.

Looking at this code, we see the story:

  • Wait for a SUBMIT_TODO_FORM action
  • Call validateForm with action.payload.formData
    • If an error occurs, notify the user and start over
    • Otherwise, proceed to the next step
  • POST the form data to the server
    • If an error occurs, notify the user and start over

Looks like we’ve stuck to the business logic pretty well so far. Now then, the only thing left is to show the user their new to-do. Since we’re using redux-little-router, we’ll dispatch a Redux action which routes them to the "show todo" page.

function* newTodoSaga() {
  while (true) {
    const action = yield take('SUBMIT_TODO_FORM');
    const formData = action.payload;

    try {
      yield call(validateForm, formData);
    } catch (err) {
      yield put(showErrorNotification(err.message);
      continue;
    }

    try {
      const todo = yield call(createTodoApi, formData);
      yield routerPush(`/todos/${todo.id}`);
      break;
    } catch (err) {
      yield put(showErrorNotification(err.message);
      continue;
    }
  }
}

This is good enough, but let’s take a short aside to talk about saga structure and separation of responsibilities. This saga’s primary responsibility is validating and submitting form data to the server. We know we want to redirect the user’s browser after all that happens, but with the way things are currently structured, the sequence of events isn’t all that obvious. At a high level, we’re doing this:

  • When the user successfully creates a new todo,
  • Show the user the newly created todo

Let’s separate these two concerns completely.

function* awaitSuccessfulTodoCreation() {
  while (true) {
    const action = yield take('SUBMIT_TODO_FORM');
    const formData = action.payload;

    try {
      yield call(validateForm, formData);
    } catch (err) {
      yield put(showErrorNotification(err.message);
      continue;
    }

    try {
      const todo = yield call(createTodoApi, formData);
      return todo;
    } catch (err) {
      yield put(showErrorNotification(err.message);
      continue;
    }
  }
}

function* newTodoSaga() {
  const todo = yield call(awaitSuccessfulTodoCreation);

  yield put(showSuccessNotification("Congratulations! You created a new todo."));

  yield routerPush(`/todos/${todo.id}`);
}

Now there’s no question as to whether or not todo has been created. We observe that the only ‘escape route’ from awaitSuccessfulTodoCreation is the return statement, which can only happen after a successful todo creation. So if control returns to newTodoSaga, we’re certain that it’s time to show a success message and send the user on their way.

However, also note what happens if the user navigates away from /todos/new during this process: redux-saga will cancel newTodoSaga. Moreover, if newTodoSaga is still blocking on awaitSuccessfulTodoCreation, the cancellation will propagate “downward” to that as well, cleanly exiting the whole shebang without causing any more effects.

Either way, we now have a saga that is responsible for doing one thing only. We can think about this part of the application in isolation, and that goes a long way towards wrangling in the complexity of larger single-page-applications.

Conclusion

This about wraps up our series on redux-saga. I hope you’ve learned a lot and that you’re able to make use of some of these ideas in your own projects! Questions, comments, and suggestions are welcome - I’m available on Twitter at @mhink1103. Let me know what you’d like to see next in the Javascript Power Tools series.

(Also, thanks to all the rad folks at Formidable for their contributions and suggestions- especially Becca Lee, without whom this would all still be a jumbled mess of .md files on my laptop.)


We Are Formidable

Formidable is a Seattle-based consultancy and open source shop, with an emphasis on Node.js and React.js. We deploy a mixture of consulting, staff augmentation, and training to level up teams and solve engineering problems. Whether it’s transitioning walmart.com to React, moving speedtest.net off Flash, or helping a startup build and scale an MVP, we’re ready to help teams of any size.

Interested in hiring or working for us? Get in touch or view our careers page.