Understanding redux-saga: From action creators to sagas
As any redux developer could tell you, the hardest part of building an app are asynchronous calls — how do you handle network requests, timeouts, and other callbacks without complicating the redux actions and reducers?
To manage this complexity, I’ll describe a few different approaches for handling asynchronicity in your app, ranging from simple approaches like redux-thunk, to more full-featured libraries like redux-saga.
We are going to use React and Redux, so this post assumes you have at least a passing familiarity with how they work.
Calling an API is a common requirement in many apps. Imagine we need to show a random picture of a dog when we click a button:
There is nothing wrong with this approach. All things being equal, we should go for the simplest approach.
However, using Redux alone won’t give us much flexibility. At its core, Redux is only a state container that supports synchronous data flows.
Every time an action, a plain object that describes what happened, is sent to the store, a reducer is called and the state is updated immediately.
But in an asynchronous flow, you have to wait for the response first, and then, if there’s no error, update the state. And what if your application has a complex logic/workflow?
Redux use middlewares to solve this problem. A middleware is a piece of code that is executed after an action is dispatched but before reaching the reducer.
Many middlewares can be arranged into a chain of execution to process the action in different ways. However, the middleware has to interpret anything you pass to it, and it must make sure to dispatch a plain object (an action) at the end of the chain.
For asynchronous operations, Redux offers the redux-thunk middleware.
Redux-thunk is the standard way of performing asynchronous operations in Redux.
For our purposes, a thunk represents a function that is not called immediately, only when needed. Take the example from redux-thunk’s documentation:
The value 3 is assigned immediately to x.
However, when we have something like the following statement:
The sum operation is not executed immediately, only when you call foo(). This makes foo a thunk.
Redux-thunk allows an action creator to dispatch a function in addition to a plain object, converting the action creator into a thunk.
This is how our previous example looks using redux-thunk:
At first look, this might not seem too different than the previous approach.
The advantage of using redux-thunk is that the component doesn’t know that it is executing an asynchronous action.
Since the middleware automatically passes the dispatch function to the function that the action creator returns, for the component, there will be no difference between asking to perform a synchronous and an asynchronous action (and they don’t have to care anyway).
By using a middleware, we have added a layer of indirection that gives us more flexibility.
Since redux-thunk gives to the dispatched function the dispatch and getState methods from the store as parameters, you can also dispatch other actions and read the state to implement more complex business logic and workflows.
Another benefit is that if there’s something that is too complex to be expressed with thunks, without changing the component, we can use another middleware library to have more control.
Let’s see how to replace redux-thunk with a library that can give us more control, redux-saga.
Redux-saga is a library that aims to make side effects easier and better by working with sagas.
Sagas are a design pattern that comes from the distributed transactions world, where a saga manages processes that need to be executed in a transactional way, keeping the state of the execution and compensating failed processes.
In the context of Redux, a saga is implemented as a middleware (we can’t use a reducer because this must be a pure function) to coordinate and trigger asynchronous actions (side-effects).
Redux-saga does this with the help of ES6 generators:
Generators are functions that can be paused and resumed, instead of executing all the statements of the function in one pass.
When you invoke a generator function, it will return an iterator object. With each call of the iterator’s next() method, the generator’s body will be executed until the next yield statement and then pause:
This can make asynchronous code easy to write and understand. For example, instead of doing this:
With generators, we could do this:
Back to redux-saga, we generally have a saga whose job is to watch for dispatched actions:
To coordinate the logic we want to implement inside the saga, we can use a helper function like takeEvery to spawn a new saga to perform an operation:
If there are multiple requests, takeEvery will start multiple instances of the worker saga. In other words, it handles concurrency for you.
Notice that the watcher saga is another layer of indirection that gives more flexibility to implement complex logic (but may be unnecessary for simple apps).
Now, we could implement the fetchDogAsync() function with something like this (assuming we had access to the dispatch method):
But redux-saga allows us to yield an object that declares our intention to perform an operation rather than yielding the result of executing the operation itself. In other words, the above example is implemented in redux-saga in this way:
Instead of invoking the asynchronous request directly, the method call will return only a plain object describing the operation so redux-saga can take care of the invocation and returns the result to the generator.
The same thing happens with the put method. Instead of dispatching an action inside the generator, put returns an object with instructions for the middleware to dispatch the action.
Those returned objects are called Effects. Here’s an example of the effect returned by the call method:
Declarative programming is a programming style that attempts to minimize or eliminate side effects by describing what the program must accomplish instead of describing how to accomplish it.
The benefit this brings, and which most people talked about, is that a function that returns a simple object is easier to test than a function that directly makes an asynchronous call. To run the test, you don’t need to use the real API, fake it, or mock it.
For testing, you just iterate over the generator function, asserting for equality on the values yielded:
However, another added benefit is the ability to easily compose many effects into a complex workflow.
Back to our trivia example, this is the complete implementation in redux-saga:
When you click the button, this is what happens:
- The action FETCHED_DOG is dispatched
- The watcher saga (watchFetchDog) takes the dispatched action and calls the worker saga (fetchDogAsync)
- The action to show the loading indicator is dispatched
- The API call is executed
- An action to update the state is dispatched (success or fail)
If you believe some layers of indirection and a little bit of additional work is worth it, redux-saga can give you more control to handle side-effects in a functional way.
This post has shown how to implement an asynchronous operation in Redux with action creators, thunks, and sagas, going from the simplest approach to the most complex.
Redux doesn’t prescribe a solution for handling side-effects. When deciding which approach to take, you have to consider the complexity of your application. My recommendation is starting with the simplest solution.
There are other alternatives to redux-saga that are worth trying. Two of the most popular options are redux-observable (based on RxJS) and redux-logic (also based on RxJS observables but with the freedom to write your logic in other styles).
Plug: LogRocket, a DVR for web apps
LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.