Chapter 6. Handling complex side effects

This chapter covers

  • Looking again at redux-thunk
  • Introducing generators
  • Managing complex asynchronous operations with sagas

Eventually, you’re going to want to handle more complex series of events in response to a user interaction. Using what you’ve learned so far, how would you handle logging in a user? The requirements will vary from app to app, of course, but let’s consider what could be involved. Your application may need to verify login credentials, issue an authorization token, fetch user data, handle retries upon failure, and redirect upon success. What tools are at your disposal?

Up to now, we’ve explored thunks by way of the redux-thunk package as a way to handle side effects and asynchronous operations. Thunks have a friendly learning curve and are powerful enough to handle just about any use case you can throw at them. However, they aren’t the only game in town. In this chapter, we’ll revisit thunks, then introduce another paradigm for handling side effect complexity: Redux Sagas. By the end of the chapter, you’ll have at least one more tool in your state-management toolbelt.

6.1. What are side effects?

Redux handles data with as much purity as possible, but for most applications you can’t avoid side effects. Side effects are required to interact with the world outside of your client application. Side effects, in this context, are any interactions with the world beyond your Redux application. Most examples can be summarized as interactions with a server or local storage. For example, you might store an authorization token in a browser’s sessionStorage, fetching data from a remote server, or recording an analytics event.

Where do you handle side effects? You know that reducers must be pure functions to get the full benefits of Redux, and components ought to dispatch action creators. That leaves action creators and middleware as options (figure 6.1).

Figure 6.1. Within the Redux architecture, only action creators and middleware should handle side effects.

Conveniently, you already have experience using both action creators and middleware to handle side effects. In the last chapter, you were exposed to an API middleware that handled AJAX requests. In chapter 4, you handled simple side effects within action creators by leveraging redux-thunk.

We should clarify: when asked where you want to handle side effects, the answer isn’t restricted to either action creators or middleware. “Both” is an entirely reasonable answer! Remember, the redux-thunk package provides middleware to handle functions returned by action creators—an example of both action creators and middleware working together to manage side effects.

Over the next couple of sections, we’ll compare and contrast thunks with Redux Sagas, another pattern that leverages middleware. We’ll start with thunks.

6.2. Revisiting thunks

You’ve had great exposure to thunks already, having used them to interact with a remote server to fetch a list of tasks or create a new one. To review, the Redux store and its reducers know what to do with actions. Actions describe events—opening a modal, for example, can be the result of a clicked button dispatching an action of type OPEN_MODAL.

Things get more complicated when the contents of that modal need to be populated by an AJAX call. You could choose, for example, to dispatch an action only after the AJAX call returns, or dispatch several actions along the way to indicate loading progress. Again, the store deals in actions and is unequipped to handle functions or promises, so it’s up to you to make sure that whatever reaches the store is an action.

When you need to perform asynchronous activities, redux-thunk makes it possible for an action creator to return a function in lieu of an action. Within the fetchTasks action creator, an anonymous function (thunk) is returned. The thunk middleware provides the dispatch and getState arguments, so the body of the function can view the contents of the current store and dispatch new actions to indicate loading, success, or failure states. View the following listing for a recap.

Listing 6.1. src/actions/index.js
export function fetchTasks() {
  return (dispatch, getState) => {         1
    dispatch(fetchTasksRequest());         2
    ...
    dispatch(fetchTasksSuccess());         3
    ...
  }
}

  • 1 The action creator returns a function, also known as a thunk.
  • 2 Within the thunk, more action creators can be dispatched.
  • 3 Based on the results of a side effect, more dispatching may occur.

6.2.1. Strengths

Thunks have much going for them. They’re dead simple, ubiquitous in documentation, and powerful enough to be the only side effect management tool you’ll ever need.

Simple

At the time of writing, the source code for redux-thunk is 14 lines—11 if you don’t count line breaks. Installing and using the library is intuitive and newcomer-friendly. Additionally, you’ll find excellent documentation in the GitHub repository and example usage in most Redux tutorials in the wild.

Robust

Although you’ll learn about a few other side-effect management tools in this chapter, you can get the job done with thunks alone. They may be arbitrarily complex. Within a thunk, you’re free to dispatch other action creators, make and respond to AJAX requests, interact with local storage, and so on.

Gentle middleware introduction

This is a tangential point, but worth mentioning. For most developers, redux-thunk is their first introduction to Redux middleware. Middleware tends to be one of the least accessible pieces of the Redux puzzle, and implementing redux-thunk is about as gentle a middleware introduction as you can get. That education is a net gain for the community and for you the developer, because it helps demystify part of the architecture.

6.2.2. Weaknesses

Any tool has tradeoffs, of course. The simplicity of thunks makes them something of a double-edged sword. Thunks are easy to write, but you’re on your own to write advanced functionality.

Verbosity

Stuffing complex logic or multiple asynchronous events into a thunk can result in a function that’s difficult to read or maintain. You won’t find magic behind the scenes or accompanying utility functions to aid with that, so it’s up to you to manage.

Testing

Testing is one of the clearest weaknesses of thunks. The pain generally comes from needing to import, create, and populate a mock store before you can make assertions about actions being dispatched. On top of that, you’ll likely need to mock any HTTP requests.

6.3. Introducing sagas

The name is telling. Sagas are built to handle the hairy and winding story of your data. Using an ES2015 feature, generators, the redux-saga package offers a powerful way to write and reason about complex asynchronous behavior. With a little new syntax, sagas can make asynchronous code as readable as synchronous code.

This chapter won’t be an exhaustive look at redux-saga and all its use cases or features. The goal is to get you familiar enough with the basics to know whether your next feature could benefit from using a saga. The answer won’t always be “yes.”

A classic example of a good use case is a user login workflow. Logging in a user may require multiple calls to a remote server to validate credentials, issue or validate an authentication token, and return user data. It’s certainly possible to handle all this with thunks, but this realm is where sagas really shine.

6.3.1. Strengths

As we’ve alluded, sagas aren’t the answer to every problem. Let’s explore what they’re good for.

Handling complexity and long-running processes

Sagas helps you think about asynchronous code in a synchronous fashion. Instead of manually handling chains of promises and the spaghetti code that accompanies them, you can use an alternative control flow that results in cleaner code. Particularly challenging side effects to handle are long-running processes. A simple example where you’ll run into these types of problems is a stopwatch application. Its implementation with redux-saga is trivial.

Testing

Sagas don’t perform or resolve side effects; they merely return descriptions of how to handle them. The execution is left to middleware under the hood. Because of this, it’s straightforward to test a saga. Instead of requiring a mock store, you can test that a saga returns the correct side effect description. We won’t walk through a saga test in this chapter, but there are examples in the official documentation at https://redux-saga.js.org/docs/advanced/Testing.html.

6.3.2. Weaknesses

With great power comes great responsibility. Let’s look at the tradeoffs involved with using redux-saga.

Learning curve

When bringing a newly hired Redux developer onto your team, it’s safe to assume they’re proficient with thunks. The same cannot be said for sagas, however. A common cost for using redux-saga is the time it takes to bring an unfamiliar developer or team of developers up to speed with using it. The use of generators and an unfamiliar paradigm can make for a steep learning curve.

Heavy-handed

Put simply, redux-saga may be overkill for simple applications. As a rule of thumb, we prefer to introduce a saga only when enough pain points are experienced while using a thunk. Keep in mind that there are costs associated with including another package—developer onboarding and the additional file size, in particular.

Generators are well-supported by most modern browsers, but that hasn’t always been the case. If your application needs to support older browser versions, consider the impact of including a generator polyfill, if required.

6.4. What are generators?

This book assumes you’re comfortable with the best-known ECMAScript 2015 syntax, but generators don’t fall into that category. Generators enable powerful functionality, but the syntax is foreign, and their use cases are still being discovered. For many React developers, redux-saga is their first introduction to generator functions.

Put simply, generators are functions that can be paused and resumed. The Mozilla Developer Network describes generators as “functions which can be exited and later re-entered. Their context (variable bindings) will be saved across re-entrances.” You may find it useful to think of them as background processes or subprograms.

6.4.1. Generator syntax

Generators look like any other function, except they’re declared with an asterisk following the function keyword, as in the following example:

function* exampleGenerator() { ... }

Note that when declaring a generator, the asterisk may come at any point between the function keyword and the function name. Each of the following are functionally the same:

function* exampleGenerator() { ... }
function *exampleGenerator() { ... }
function*exampleGenerator() { ... }
function * exampleGenerator() { ... }

This book standardizes on the first example, because it appears to be more popular in the wild and is the style preference chosen in the redux-saga documentation. Although you won’t write any in this chapter, know that generators can also be anonymous:

function* () { ... }
function *() { ... }
function*() { ... }
function * () { ... }

Generators can yield results. The yield keyword can be used to return a value from the generator function. See the following listing for an example.

Listing 6.2. Basic generator example
function* exampleGenerator() {          1
   yield 42;                            2
   return 'fin';
}

  • 1 The generator function is denoted with an asterisk.
  • 2 The yield keyword provides a return value from the generator function.

What do you suppose might happen if you execute the function, exampleGenerator()? Go ahead and try it out in your terminal. Assuming you have Node.js installed, start the Node.js REPL by entering node into your terminal window, then write the function in listing 6.2 and execute it. Not what you expected, was it? The terminal output appears to be an empty object.

6.4.2. Iterators

What the generator returns is called an iterator. Iterators are objects, but they’re not empty. They keep track of where they are in a sequence and can return the next value in the sequence. Iterators have a next function that can be used to execute code within the generator up until the next yield, as shown in this example:

exampleGenerator();        //=> {}
exampleGenerator().next(); //=> { value: 42, done: false }

Notice the output of the next function. The result is an object with two keys, value and done. value contains the yielded content, 42. The done key has a value of false, indicating that the generator has more data to provide if called again. At this point, the generator function is effectively paused and waiting to be called on again to resume executing after the yield statement. Let’s keep going:

exampleGenerator();        //=> {}
exampleGenerator().next(); //=> { value: 42, done: false }
exampleGenerator().next(); //=> { value: 42, done: false }
exampleGenerator().next(); //=> { value: 42, done: false }

Wait, what happened? Shouldn’t value have been the string fin and done returned true? Don’t let this one bite you. Each time exampleGenerator is executed, a new iterator is returned. You’ll need to store the iterator in a variable, then call next on the stored iterator. See the following listing for an example.

Listing 6.3. Iteration with a generator example
const iterator = exampleGenerator();                        1
iterator.next(); // { value: 42, done: false }
iterator.next(); // { value: 'fin', done: true }            2
iterator.next(); // { value: undefined, done: true }        3

  • 1 Stores the iterator created by the generator function
  • 2 Having reached the return statement, done flips to true.
  • 3 Continues to call next; returns an undefined value

Up to now, you’ve seen yield and return statements used in a generator. You’ve learned that yield will return a value with done set to false. return does the same, with done set to true. A third option exists: throw. You won’t use it in this chapter, but throw can be used to break out of a generator function in the case of an error. You can find more details about the throw method at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator/throw.

6.4.3. Looping with generators

We’re willing to bet that the last infinite loop you wrote was an accident. It happens to the best of us. In the case of generators however, infinite loops are a viable usage pattern. The next listing is an example of an intentional infinite loop.

Listing 6.4. Infinite loop example
function* optimisticMagicEightBall() {
   while (true) {                            1
      yield 'Yup, definitely.';
   }
}

  • 1 Creates an infinite loop with a while (true) block

Now, you can answer an unlimited number of questions with your generator function, optimisticMagicEightBall. Each call of the next function on the iterator will return the affirmative answer, then pause, waiting for the loop to begin again.

It’s also possible to compose generators, or in other words, use generators within generators. In this case (listing 6.5), looping through the count function produces the numbers one through five in order as the count function becomes blocked until middleNumbers resolves. The yield* syntax is used to delegate out to another generator.

Listing 6.5. Composing generators example
function* count() {
   yield 1;
   yield 2;
   yield* middleNumbers();          1
   yield 5;
}

function* middleNumbers() {
   yield 3;
   yield 4;
}

  • 1 The middleNumbers generator completes before moving on to 5.

Although these examples are contrived, be sure that they make sense before moving on. The redux-saga library makes heavy use of both.

6.4.4. Why generators?

Although capable, JavaScript has something of a reputation for its difficulty in managing series of asynchronous events. You may have heard the term “callback hell” used to describe a group of deeply nested, chained asynchronous functions regularly found in JavaScript codebases. Generators were created to offer an alternative control flow for asynchronous operations.

Generators are broadly applicable tools, and again, those applications are still being uncovered. Complex asynchronous operations and long-running processes are two of the most commonly cited opportunities to gain readability and maintainability by introducing a generator.

Generators are also a platform to build still more powerful or more developer-friendly tools. The popular ES7 feature, async/await, leverages generators to create another highly approachable way to handle complex asynchronous events. The doors are open for more intricate, domain-specific libraries to be built, such as redux-saga.

6.5. Implementing sagas

With an understanding of the fundamentals of generators, you’re ready to step into sagas. As an exercise, try refactoring one of Parsnip’s thunks into a saga: the fetchTasks action creator. Visually, the final result will look identical to existing functionality, but under the hood, you’re using an entirely new paradigm. To be clear, the net gain from this refactor won’t be much: easier testing in exchange for code complexity. The value is in the academic exercise.

The first step is to install the package. Within your Parsnip app, add redux-saga:

npm install redux-saga

In the following sections, we’ll walk through configuring an application to use the redux-saga middleware, then you’ll write your first saga. You’ll discover that sagas and thunks are both a means to reach the same end, and for each feature you build, you may choose whichever tool fits the use case best.

6.5.1. Connecting saga middleware to the store

Sagas operate as middleware, and middleware gets registered in the store at the time of the store’s creation. As a reminder, Redux’s createStore function takes up to three arguments: reducers, initial state, and enhancers. In the last chapter, you learned that middleware gets applied in the last argument as a store enhancement. Here again, you’ll register redux-saga as middleware. A final warning: this chapter uses the code from chapter 4 as a starting point. You’ll need to roll back the changes or check out of the branch made in chapter 5 if you’re coding along.

In listing 6.6, you import and use the createSagaMiddleware factory function. Redux’s applyMiddleware function takes a list of arguments, so you can add the saga middleware right alongside the thunk middleware. Remember, the order you list middleware determines the order an action will pass through them.

Listing 6.6. src/index.js
import createSagaMiddleware from 'redux-saga';                    1
import rootSaga from './sagas';                                   2

const sagaMiddleware = createSagaMiddleware();                    3

const store = createStore(
   reducer,
   composeWithDevTools(applyMiddleware(thunk, sagaMiddleware))    4
);

sagaMiddleware.run(rootSaga);                                     5

  • 1 createSagaMiddleware is the default export of redux-saga.
  • 2 redux-saga needs to know which saga (or sagas) to run.
  • 3 createSagaMiddleware is a factory function, used to create sagaMiddleware.
  • 4 Adds the saga middleware to the list in applyMiddleware
  • 5 Finally, initiate the saga with the run method on the sagaMiddleware instance.

Again, there’s no reason you can’t use both thunks and sagas in the same application, and indeed, many applications do.

It may be helpful to think of sagas as subprograms, and the run function on the last line of the listing is required for the subprogram to begin watching for actions. Once the saga middleware is configured, you can run the top-level, or root, saga. In the next section, you’ll get a chance to put a generator into production by writing a root saga.

6.5.2. Introducing the root saga

The code in the previous listing won’t do anything for you until you write the root saga you imported. Let’s go only far enough to tell that we’ve configured redux-saga properly. Create a new file within the src directory and name it sagas.js. Within that file, write a rootSaga generator function and have it log a message to the console, as shown in the following listing.

Listing 6.7. src/sagas.js
export default function* rootSaga() {                1
   console.log('rootSaga reporting for duty');
}

  • 1 Denotes generators with the asterisk following the function keyword.

If everything went smoothly, you should see your message logged to the console after restarting the server. (If you have trouble with the json-server, revisit the setup instructions in the appendix.) So far so good! Let’s pause here to explore what you’ve got and where you’re headed.

After configuring the store to use saga middleware, you instructed the middleware to run the root saga. Because it’s simpler to keep track of a single entry point, the root saga’s role will be to coordinate all other sagas used in the application. When that root saga is implemented, you’ll expect it to kick off sagas to run in the background, watching for and reacting to actions of specific types. As mentioned, you can and will use thunks at the same time, so each saga will listen for and respond to only specific actions. See figure 6.2 for a visual representation.

Figure 6.2. Sagas will respond to actions of a specific type. If another action type or thunk is received, it will pass through the saga middleware unchanged.

You know that the order in which middleware is provided to the applyMiddleware function is the order that actions pass through them. Because you listed sagaMiddleware first, all dispatched values will pass through it before the thunk middleware. In general, a saga will react to an action, handling one or more side effects, and eventually return another action to be processed by the reducers.

Ready for the big reveal? The first saga you’ll write will be a replacement for the thunk that handles tasks fetching. The first thing you’ll need to do is let the root saga know about this new saga.

Pop quiz

What are the three generator-specific methods, and which is used to return a value from a generator without declaring it done? The three methods are return, throw, and yield. yield is the function that can return a value but provide a done value of false.

You’re going to want to have our root saga yield each of the application’s sagas. Could you use return? Sure, but only on the last line of the saga. The redux-saga documentation and examples choose to use only yield, so that’s the pattern you’ll stick with.

In the next listing, you’ll see the root saga yield to another saga that will eventually watch for FETCH_TASKS actions. Sagas such as watchFetchTasks are sometimes called watchers, because they infinitely wait and watch for particular actions. A common convention for spinning up multiple watchers is to fork them. You’ll learn what that means in a minute. You only have one watcher to write so far, but for the sake of example, you’ll add a second in the following listing to demonstrate the conventions.

Listing 6.8. src/sagas.js
import { fork } from 'redux-saga/effects';        1

export function* rootSaga() {
   yield fork(watchFetchTasks);                   2
   yield fork(watchSomethingElse);                2
}

function* watchFetchTasks() {                     3
   console.log('watching!');
}

function* watchSomethingElse() {
   console.log('watching something else!');
}

  • 1 Imports fork from the redux-saga/effects package
  • 2 Forking each watcher allows rootSaga to move on to the next one.
  • 3 Each watcher is also a generator.

What’s fork doing here? When rootSaga executes, it’s going to pause at every yield statement until the side effect is completed. The fork method, however, allows rootSaga to move onto the next yield without a resolution. Each of these forks are said to be non-blocking. This implementation makes sense, because you want to kick off all the watchers at initialization, not only the first in the list.

6.5.3. Saga effects

In listing 6.8, you’ll notice that you imported fork not from redux-saga, but from redux-saga/effects. fork is one of many methods made available to help you manage what are referred to as effects. One common misconception for newcomers is that the logic you write within a saga needs to do the processing of your side effect, such as performing an AJAX request. That’s not the case! Instead, the saga’s role is to return a description of the logic needed in the form of an object. Figure 6.3 introduces the call method to illustrate this relationship.

Figure 6.3. Sagas return effects, which are instructions for the saga middleware to perform.

The call method used here is analogous to JavaScript’s call function. You’ll use it again shortly to specify the AJAX request to fetch the tasks. Once you use the redux-saga-specific methods to generate an effect, the saga middleware will process and perform the required side effects out of view. You’ll learn more about these methods in the implementation of the task fetching next.

6.5.4. Responding to and dispatching actions

Watchers reacting only when the right action comes along has generated a great deal of discussion. The method you’re looking for is also among the group of helpers imported from redux-saga/effects: take. The take command is used to wake up and engage a saga when a particular action type arrives. Unlike fork, it is a blocking call, meaning that the infinite loop will halt while it waits for another action of type FETCH_TASKS_STARTED to come along. Listing 6.9 shows the basic usage.

Only after a FETCH_TASKS_STARTED action is dispatched and take is called will the started! log appear in your console. Notice that you’ve introduced an infinite loop into the saga to facilitate this feature. This technique shouldn’t be alarming to you after reading the introduction to generators earlier in the chapter.

Listing 6.9. src/sagas.js
import { fork, take } from 'redux-saga/effects';      1
...
function* watchFetchTasks() {
   while (true) {                                     2
      yield take('FETCH_TASKS_STARTED');              3
      console.log('started!');
   }
}

  • 1 Imports take from the effects package
  • 2 Watchers use infinite loops to process actions as often as they’re needed.
  • 3 take waits for a given action type before allowing the saga to proceed.

If you’re following along at home, you’ll want to delete or comment out the related thunk feature. All you’ll need to interact with this saga is to dispatch an action of type FETCH_TASKS_STARTED. You can accomplish this by exporting the fetchTasksStarted action and passing it to the dispatch in componentDidMount within the App.js component.

Now that you’ve got the basics of responding to actions covered, let’s try out the other side of the coin, dispatching new ones. The method you’re looking for this time is put. As an argument, put takes the action you’d like to pass through to the remainder of the middleware and to the reducers. Let’s bring the call method back into the picture and connect the remaining dots to complete the feature.

Listing 6.10. src/sagas.js
import { call, fork, put, take } from 'redux-saga/effects';       1
...
function* watchFetchTasks() {
   while (true) {
      yield take('FETCH_TASKS_STARTED');
      try {
         const { data } = yield call(api.fetchTasks);             2
         yield put({                                              3
            type: 'FETCH_TASKS_SUCCEEDED',
            payload: { tasks: data }
         });
      } catch (e) {
         yield put({
            type: 'FETCH_TASKS_FAILED',
            payload: { error: e.message }
         });
      }
   }
}

  • 1 Imports each of the used helper methods
  • 2 call is a blocking method used to specify the AJAX request.
  • 3 After a successful or unsuccessful request, put is used to dispatch an action.

The whole fetch tasks feature has been replaced by a saga! The saga wakes up when FETCH_TASKS_STARTED is dispatched. It next waits for the middleware to perform the AJAX request, then dispatches a success or failure action with the results.

To verify it works on your machine, be sure to delete or comment out the related thunk, within the action creator fetchTasks, so you don’t have two systems competing to process actions. All you’ll need is a synchronous action creator to dispatch a FETCH_TASKS_STARTED action. See listing 6.11 for an example. Remember though, you already have the know-how to debug that situation yourself. You know that whichever order you provide middleware to applyMiddleware is the order that actions will move through them.

Listing 6.11. src/actions/index.js
...
export function fetchTasks() {
   return { type: 'FETCH_TASKS_STARTED' };         1
}
...

  • 1 The saga middleware handles AJAX requests in response to this action type.

If you compare this complete saga implementation of the fetch tasks feature with the thunk implementation, you’ll notice they’re not all that different. They’re roughly the same amount of code and the logic looks similar. Introducing sagas undeniably added complexity, though. Is it worth the learning curve?

We’ve mentioned, but not demonstrated, that sagas are easier to test than thunks. That’s certainly worth something. It can also be valuable to learn a new programming paradigm. At this point, however, if your answer is still “no,” we wouldn’t blame you. It’s a hard sell to convince your development team to introduce a complex new tool without clearer value.

Fortunately, we’ve barely scratched the surface of what sagas may be useful for. As a quick example, let’s say you wanted to cancel an unfinished, old request whenever a new one came in. In a thunk, this requires extra labor, but redux-saga/effects provides a method, takeLatest, for this purpose. The command takeLatest replaces the use of fork in your root saga, as shown in the following listing.

Listing 6.12. src/sagas.js
import { call, put, takeLatest } from 'redux-saga/effects';
...
export default function* rootSaga() {
   yield takeLatest('FETCH_TASKS_STARTED', fetchTasks);    1
}

function* fetchTasks() {
   try {                                                   2
      const { data } = yield call(api.fetchTasks);
      yield put({
         type: 'FETCH_TASKS_SUCCEEDED',
         payload: { tasks: data },
      });
   } catch (e) {
      yield put({
         type: 'FETCH_TASKS_FAILED',
         payload: { error: e.message },
      });
   }
}
...

  • 1 takeLatest cancels old processes when a new one begins.
  • 2 No more infinite loop is required, because takeLatest continues to listen for the action type.

Behind the scenes, takeLatest is creating a fork with extra functionality. To provide its intended functionality, it will have to listen for every action of type FETCH_TASKS_STARTED. This keeps you from needing to do the same, so you can remove the infinite loop and take function from the watchFetchTasks saga. While you’re at it, watchFetchTasks is no longer doing the watching, so you’ve tweaked the name to suggest that: fetchTasks.

That’s the whole of it. Refresh your browser and you’ll see identical results, but with more resource-conservative code underneath. To get that new feature, you deleted more code than you added. Sounds like a win-win to us.

6.6. Handling long-running processes

In the introduction of generators, we mentioned that handling long-running processes may be ideal use cases. Long-running processes may take many forms, and one of the textbook educational examples is a timer or a stopwatch. You’re going to run with this idea to add an additional feature to Parsnip, but with a little twist. In this section, you’ll add a unique timer to each task, which begins when the task is moved to “In Progress.” By the time you’re done with the feature, it will look something like figure 6.4.

Figure 6.4. Tasks display a timer for how long they’ve been in progress.

6.6.1. Preparing data

You’ll need the server to tell you at least what the timer starting value for each task is. To keep it simple, each task will have a timer key with an integer value, representing the number of seconds it has been in progress. See the following listing for an abbreviated example. The specific numbers you choose aren’t important.

Listing 6.13. db.json
...
{
   "tasks": [
      ...
      {
         "id": 2,
         "title": "Peace on Earth",
         "description": "No big deal.",
         "status": "Unstarted",
         "timer": 0                         1
      }
      ...
   ]
}
...

  • 1 Gives each task a timer key and number value

Be sure to add the timer key to every task record. Remember, this is JSON formatting, so don’t forget your quotes around timer. Number values don’t require them.

6.6.2. Updating the user interface

Next up is to display the new timer data in the tasks. This calls for a small addition to already straightforward React code, so you aren’t going to publish the entire component here. Within the Task component, render the timer property somewhere within the body of the task. Use the following code as an example and style it as you please. The “s” is an abbreviation for seconds:

<div className="task-timer">{props.task.timer}s</div>

At this point, the timer values you entered in the db.json file should be visible in the UI. The parent components of the Task component already make the value available, so no additional configuration is required.

6.6.3. Dispatching an action

Before we get to the saga, you’ll need to dispatch an action with which to get its attention. The goal is to turn the timer on whenever a task is moved into “In Progress.” Mull it over: Where would you want to dispatch a TIMER_STARTED action?

You can piggyback onto existing logic to facilitate this. The action creator editTask already handles moving an action into each status column, so you can dispatch an additional action whenever the destination is the “In Progress” column. See the following listing for an example implementation.

Listing 6.14. src/actions/index.js
...
function progressTimerStart(taskId) {
   return { type: 'TIMER_STARTED', payload: { taskId } };       1
}

export function editTask(id, params = {}) {
   return (dispatch, getState) => {
      const task = getTaskById(getState().tasks.tasks, id);
      const updatedTask = {
         ...task,
         ...params,
      };
      api.editTask(id, updatedTask).then(resp => {
         dispatch(editTaskSucceeded(resp.data));
         if (resp.data.status === 'In Progress') {              2
            dispatch(progressTimerStart(resp.data.id));
         }
      });
   };
}

  • 1 The action that the saga will be listening for
  • 2 Adds an additional dispatch if the task is moved into ‘In Progress’

You need to pass the ID of the task to the action. When you have multiple timers incrementing at once, you need to know exactly which task to increment or pause.

6.6.4. Writing a long-running saga

If everything in this chapter is starting to click for you, you may already have an idea of where you’d like to go with this saga. One strategy is to listen for actions of type TIMER_STARTED, then increment the timer value once per second within an infinite loop. It’s a good place to start!

You can begin by registering a handleProgressTimer saga with the root saga. This is an opportunity to introduce one more alternative to fork. The command takeLatest makes sense when you want to throttle API requests to a remote server. Sometimes you want to let everything through, though. The method you’re looking for is takeEvery.

The implementation of handleProgressTimer will introduce one new method, but it’s self-explanatory: delay. delay is a blocking method, meaning that the saga will pause at its location until the blocking method resolves. You can see a complete implementation in listing 6.15.

You’ll notice that you’re importing delay not from redux-saga/effects, but instead from redux-saga. The delay command doesn’t help produce an effect object, so it doesn’t reside alongside the other effect helpers. You’ll lean on the call method in the following listing to produce the effect and pass in delay as an argument, the way you did when making an API request.

Listing 6.15. src/sagas.js
import { delay } from 'redux-saga';                                       1
import { call, put, takeEvery, takeLatest } from 'redux-saga/effects';    2

export default function* rootSaga() {
   yield takeLatest('FETCH_TASKS_STARTED', fetchTasks);
   yield takeEvery('TIMER_STARTED', handleProgressTimer);                 3
}

function* handleProgressTimer({ payload }) {                              4
   while (true) {                                                         5
      yield call(delay, 1000);                                            6
      yield put({
         type: 'TIMER_INCREMENT',
         payload: { taskId: payload.taskId },                             7
      });
   }
}
...

  • 1 Adds the delay method from redux-saga
  • 2 Adds the takeEvery method to the list of imports
  • 3 Every time ‘TIMER_STARTED’ is dispatched, invoke the handleProgressTimer function.
  • 4 Action properties are available as arguments.
  • 5 The timer runs infinitely while in progress.
  • 6 delay is used to wait one second (1000 ms) between increments.
  • 7 The task ID is passed to the reducer to find the task to increment.

Without extra configuration, the take, takeEvery, and takeLatest methods will pass the action through to the function or saga you provide. handleProgressTimer can access the taskId from the action payload to eventually specify which task to update.

6.6.5. Handling the action in the reducer

Every second, the saga will dispatch a TIMER_INCREMENT action. By now, you know that it’s the reducer’s job to define how the store will update in response to that action. You’ll want to create a case statement for handling the action within the tasks reducer. Much of this code is identical to the code for handling the EDIT_TASK_SUCCEEDED action in the same reducer. The goal is to find the desired task, update its timer value, then return the list of tasks, as shown in the following listing.

Listing 6.16. src/reducers/index.js
...
case 'TIMER_INCREMENT': {                                1
   const nextTasks = state.tasks.map(task => {           2
      if (task.id === action.payload.taskId) {
         return { ...task, timer: task.timer + 1 };      3
      }
      return task;
   });

   return { ...state, tasks: nextTasks };                4
}
...

  • 1 Adds the new action type to the tasks reducer
  • 2 Maps over the existing tasks to create the updated versions
  • 3 If the task ID matches, increment the task’s timer.
  • 4 Returns the updated tasks

That wraps up the implementation! If you try it out on your local machine, you should see the timer begin to tick up on any task that gets moved into the “In Progress” column. Multiple tasks increment uniquely, as you’d expect. Not bad, right?

Don’t celebrate so quickly though; our solution is a short-sighted one. In the exercise for this chapter, you’re asked to implement the ability to stop the timer when a task in progress is moved to “Completed” or back to “Unstarted.” If you attempt to add this functionality to our existing saga, you’ll eventually be left scratching your head as to why the tasks aren’t responding to TIMER_STOPPED actions or breaking out of the increment loop.

6.6.6. Using channels

We’ll save you the head-scratching trouble and reveal what’s going on here. takeEvery is starting a new process for each TIMER_STARTED action it receives. Each time a task is moved into “In Progress,” a separate process begins dispatching TIMER_INCREMENT actions. That’s a good thing. You want each task to increment individually. However, if a TIMER_STOPPED action were to come through the handleProgressTimer saga, it too would start a new process, separate from the one busy incrementing the timer. You have no way to stop the incrementing process if you can’t target it specifically. See figure 6.5 for an illustration.

Figure 6.5. The takeEvery method spawns a new saga process for each action.

In the figure, you can see that by the time you move the task into “In Progress” twice, you have two separate loops incrementing the timer every second. That’s clearly not what you had in mind. If you use takeLatest instead of takeEvery, only one task could increment its timer at a time. Ultimately, what you want to achieve is the functionality of takeLatest, with a separate process per task. Simply stated, if you start a process, you need to stop the same process. That’s exactly the functionality you’ll build.

To build this feature, you’re going to leverage another redux-saga utility called a channel. From the official documentation, channels are “objects used to send and receive messages between tasks.” The tasks they reference are what we’ve referred to as processes, to avoid naming confusion with our Parsnip tasks. Essentially, you’ll use channels as a way to give a name to a saga process, so that you can revisit the same channel again. If the language is confusing, the code should help to clarify it.

What you want to accomplish is to create a unique channel for each Parsnip task that starts a timer. If you keep a list of channels, you can then send a TIMER_STOPPED action to the correct channel when the task is moved to “Completed.” You’re going to go off the beaten path a little by creating a helper function to manage these channels.

You’ll name the function takeLatestById, and it will send each action to the correct process. If a process doesn’t already exist, a new one will be created. Listing 6.17 offers one implementation.

You’ll see new code here, but there shouldn’t be anything wildly surprising. takeLatestById is a generic helper function that is used to create rediscoverable processes. The function checks to see if a channel exists for a task, and if not, creates one and adds it to the mapping. After adding to the mapping, the new channel is immediately instantiated and the final line in the listing dispatches the action to the new channel. Conveniently, nothing about the handleProgressTimer function needs to change.

Listing 6.17. src/sagas.js
import { channel, delay } from 'redux-saga';                           1
import { call, put, take, takeLatest } from 'redux-saga/effects';      2
...

export default function* rootSaga() {
   yield takeLatest('FETCH_TASKS_STARTED', fetchTasks);
   yield takeLatestById('TIMER_STARTED', handleProgressTimer);         3
}

function* takeLatestById(actionType, saga) {
   const channelsMap = {};                                             4

   while (true) {
      const action = yield take(actionType);
      const { taskId } = action.payload;

      if (!channelsMap[taskId]) {
         channelsMap[taskId] = channel();                              5
         yield takeLatest(channelsMap[taskId], saga);                  6
      }

      yield put(channelsMap[taskId], action);                          7
   }
}
...

  • 1 Adds channel to the list of imports
  • 2 Adds take back to the list of effect helper imports
  • 3 Have the root saga initiate the helper function.
  • 4 Stores a mapping of created channels
  • 5 If a task doesn’t have a channel, create one.
  • 6 Creates a new process for that task
  • 7 Dispatches an action to the specific process

If you start the application on your machine, you should experience the same functionality with one important difference. Moving a task into “In Progress” twice should produce only one saga process, instead of two. The helper function has set up a takeLatest watcher for sagas being manipulated. The second time the task is moved to “In Progress,” the takeLatest function cancels the first process and starts a new one, never producing more than one increment loop per task. That’s more like it!

6.7. Exercise

Your turn! All the scaffolding is set up for you to write a few lines of code to add the stop timer functionality to Parsnip. Specifically, what you want is for a task’s timer to stop any time it moves from “In Progress” to one of the other columns: “Unstarted” or “Completed.”

Because there’re only a few lines to write doesn’t mean it’s easy, though. Completing this exercise will be a good way to prove to yourself that everything you’ve done so far is beginning to make sense. We’ll give you one hint to get started: functions such as take can be configured to accept and respond to more than one action type at a time. To do so, you can pass it an array of action type strings as the first argument.

6.8. Solution

Did you figure it out? The first step is to figure out when to dispatch the TIMER_STOPPED action. Right after the logic to determine whether to dispatch the TIMER_STARTED action is a reasonable opportunity. See the following listing for an example.

Listing 6.18. src/actions/index.js
...
export function editTask(id, params = {}) {
   ...
   api.editTask(id, updatedTask).then(resp => {
      dispatch(editTaskSucceeded(resp.data));

      if (resp.data.status === 'In Progress') {
         return dispatch(progressTimerStart(resp.data.id));     1
      }

      if (task.status === 'In Progress') {                      2
         return dispatch(progressTimerStop(resp.data.id));
      }
   });
}

function progressTimerStop(taskId) {
   return { type: 'TIMER_STOPPED', payload: { taskId } };       3
}

  • 1 Don’t forget the return keyword on the start dispatch.
  • 2 Stops the timer if the task was “In Progress” prior to updating
  • 3 Return the new TIMER_STOPPED action with the task ID.

The part that may be confusing here is that within the editTask function, task refers to the task before it was updated, and resp.data refers to the updated task returned from the AJAX request. To start the timer, check if the new task is in progress. If so, editTask dispatches another action and finishes there. You never move on to the check if the timer needs to be stopped. If the updated task isn’t in progress, but the original task was, then stop the timer.

Next you need to handle the new TIMER_STOPPED action in the saga. This is going to be even less code than you expected.

Listing 6.19. src/sagas.js
...
export default function* rootSaga() {
   yield takeLatest('FETCH_TASKS_STARTED', fetchTasks);
   yield takeLatestById(['TIMER_STARTED', 'TIMER_STOPPED'], handleProgressTimer);                                   1
}

function* handleProgressTimer({ payload, type }) {           2
   if (type === 'TIMER_STARTED') {                           3
      while (true) {
         ...
      }
   }
}
...

  • 1 Passes both action types in an array to the helper function
  • 2 Adds type to the list of destructured arguments
  • 3 Wraps all the function’s logic in a conditional statement that executes if type is TIMER_STARTED

Seriously, that’s it. Once you begin watching for both actions, the infrastructure you’ve already written will handle the rest. The helper function accepts the array of action types and passes the pattern into its take function. From that point on, start and stop actions will find themselves in the correct process, executing the code within the handleProgressTimer function.

Implementing the stop function doesn’t require any other modifications to the function, because all you need it to do is not execute the increment logic. The TIMER_STOPPED function bypasses the infinite loop and moves on to the reducers, eventually becoming visible in the Redux DevTools.

6.9. Additional side-effect management strategies

Thunks and sagas are the most popular side-effect management tools around, but in the open source world, there’s something for everyone. We’ll discuss a few additional tools, but know that there are more options waiting for you to discover them.

6.9.1. Asynchronous functions with async/await

A feature introduced in ES7, async/await, has quickly found itself in many Redux codebases, often working in tandem with thunks. Using the feature is a natural step to take for those already comfortable with thunks. You’ll notice the control flow feels similar to sagas, and that can be explained by the fact that async/await uses generators under the hood. See the following listing for an example within a thunk.

Listing 6.20. An async/await example
export function fetchTasks() {
   return async dispatch => {                          1
      try {                                            2
         const { data } = await api.fetchTasks();      3
         dispatch(fetchTasksSucceeded(data));
      } catch (e) {
         dispatch(fetchTasksFailed(e));
      }
   }
}

  • 1 Adds the async keyword to the anonymous function
  • 2 One error handling strategy is to use a try/catch block.
  • 3 Uses the await keyword to block the function until the value returns

Why would you choose async/await? It’s simple, powerful, and easy to learn. Why would you choose sagas? You need the functionality provided by the advanced features, the ease of testing is important to you, or maybe you prefer the paradigm.

6.9.2. Handling promises with redux-promise

The redux-promise library is another tool maintained by Redux co-creator, Andrew Clark. The premise is simple: whereas redux-thunk allows action creators to return functions, redux-promise allows action creators to return promises.

Also, like redux-thunk, redux-promise provides middleware that can be applied during the creation of the store. Several functional nuances exist for using promises over thunks, or both in tandem, but choosing is largely a matter of style preference. The package is available on GitHub at https://github.com/acdlite/redux-promise.

6.9.3. redux-loop

This library diverges from what you’ve learned so far about the Redux architecture. Ready for some rule-breaking? Using redux-loop permits side effects within reducers. Stressful, we know.

As you may recall, Redux draws inspiration from several sources, Elm among the most influential. Within the Elm architecture, reducers are powerful enough to handle synchronous and asynchronous state transitions. This is achieved by having the reducers describe not only the state that should update, but also the effects that cause it.

Redux, of course, didn’t inherit this pattern from Elm. Redux can handle only synchronous transitions; all side effects must be resolved (by hand or by middleware) before they reach the reducers. However, the Elm effects pattern is available for use in Redux using redux-loop, a minimal port of the functionality. You can find the package on GitHub at https://github.com/redux-loop/redux-loop.

6.9.4. redux-observable

This package plays a similar role to redux-saga and even has kindred terminology. Instead of sagas, redux-observable has you create epics to handle side effects. Epics are implemented as middleware, like sagas, but instead of using generators to watch for new actions, you can leverage observables—functional reactive programming primitives. In English, epics can be used to transform a stream of actions into another stream of actions.

Using redux-saga and redux-observable are alternatives that accomplish roughly the same outcome. The latter is a popular choice for those already familiar with RxJS and functional reactive programming. One selling point for choosing epics over sagas is that the programming patterns learned can be carried over to other development environments. Rx ports exist in many other programming languages.

You have several options for handling complex side effects. The severity of the complexity will help you determine which tool is the right one for the job, but you’ll probably develop favorites as you find strategies that make the most sense to you and your programming style.

The redux-saga tool is another option for your toolbelt. Certain folks will choose to replace all their thunks with sagas, while others will decide there’s too much magic going on there. Again, you can still accomplish it all by using thunks. In practice, many applications succeed by finding a happy medium.

With redux-saga under your belt, you can confidently say you’ve got a handle on the messiest corners of Redux applications. You can move on to further optimizations elsewhere in the codebase by learning how to use selectors, such as reselect, in the next chapter.

Summary

  • Thunks are sufficient for managing side effects of any size.
  • Introducing sagas may help to tame especially complex side effects.
  • Sagas are built using functions that can be paused and resumed, called generators.
  • Sagas produce effects, descriptions of how to handle side effects.
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.135.197.250