Redux Saga Is Hard Until You Look Under The Hood
Redux Saga has a reputation for being hard to understand and is often seen as overkill.
The Redux team even recommends to use their data fetching and caching library, RTK Query, instead of Redux Saga.
However, when you master Redux Saga, it actually gives a reliable way to break down any problem - no matter how complex - and tame it into deterministic and side effect-free code.
I wanted to write this article about sagas for you because:
- Sagas are still used in many old projects and can help you land more jobs,
- the philosophy of isolating side effects that sagas teach you will change how you think about your code and help you to write cleaner code, and
- I love sagas.
This article is part 2 of a 5-part series on Redux that will elevate your understanding to the highest level, preparing you for any interview question involving Redux and enabling you to contribute to any Redux-based codebase. By the way, the third article in this series also covers how to use RTK Query.
The first part of the series covers Redux on a senior level. In that article, you learn how Redux works by implementing it from scratch, and then how to write the cleanest Redux code possible using function composition. If you feel stuck at any point in this article, read part one first.
Why Redux Saga?
Let's start with the basic question:
“Why do you want to use Redux Saga?”
- Isolate asynchronous effects - Manage your side effects separately from your main application logic. Your components don't need to know that side effects exist.
- Deterministic testing of I/O related logic - When you test code with side effects, you're frequently forced to mock, especially when you’re unsure of what you’re doing. But Redux Saga makes it trivial to write tests for your API calls, database operations and other I/O tasks.
If you want to, you can code along with this tutorial. If not, then skip to "What Is Redux Saga?" below.
This article will show the basic setup of your Redux store very quickly because it's covered in detail in the first article of this series.
Create a new Next.js project and choose "yes" for everything except TypeScript.
The third article in this series explains Redux with TypeScript.
Now install Redux, React Redux, and Ramda.
Create a file in src/features/example/example-reducer.js
to hold your example slice.
Import pipe
and prop
from Ramda, which you'll use for your selectors.
Then export the slice name and create an increment
, an incrementBy
and an init
action creator.
Your initial state should have a count
key which is 0
. Then you define your example reducer, which can handle the increment
and the incrementBy
action.
Then create a selector that returns the example slice and export a selector that returns the count.
Create your rootReducer
in src/redux/root-reducer.js
.
You only have the example
slice, so you'll only need to configure that one slice.
Then create your makeStore
function in src/redux/store.js
to wrap the creation of your Redux store.
You can call your root reducer without arguments to get the initial state.
Now create your store provider in src/redux/store-provider.js
.
Import the makeStore
function and use useRef
so your store only gets created once. Then pass your store to the provider from React Redux.
Also set up the hooks for dispatching actions and grabbing data from the store.
Then import your StoreProvider
in your root layout in src/app/layout.js
and wrap the StoreProvider
around your RootLayout
.
You can now access store values via selectors and manipulate them using dispatch from any child component of the StoreProvider
.
What Is Redux Saga?
Redux Saga is a middleware that uses generators to:
- manage side effects and manipulate actions,
- decouple your code, and
- define complex logic flows, sequences and data processes.
Sagas typically transform impure functions - like database calls or API requests - into pure, deterministic ones by isolating side effects through delaying their computation.
"Decoupling code" means to slice your app into small, independent blocks, so that your app's components depend less on each other. When components are decoupled, you can work on them without the risk of breaking anything else. If components are coupled, changing one might unintentionally break something else.
With Redux Saga you can decouple your code because you can define complex sequences of asynchronous operations in a way that keeps them separate from your UI and state management logic. For example, sagas can replace complex sequences in useEffect
with easy to implement and decoupled logic.
Have a look at the following saga.
Relax if you don't understand this, yet. This article will break everything down for you.
All dispatched actions anywhere in your app run though all sagas, and if the action corresponds to the condition of a saga, the saga triggers. When the init
action is dispatched, this saga starts running.
Then this saga dispatches the put action, grabs the latest count from the store, and fetches a user whose ID is equal to the current count. Finally, it increments the count by the user's name's length.
The logic that handles all of these effects will be shown to you and explained later in the saga middleware chapter.
If you log out what this saga does, you get this output.
First, the init action is dispatched somewhere in your app triggering your saga. And then you see the incrementation of the count by 1 and then the incrementation by 13 because the user with ID 1 is named "Leanne Graham".
If you tried to write this flow without sagas as a React component, you might write something that uses useEffects
and various other hooks. Just to give you an idea, here is what it could look like:
I'll spare you the details, but as you can see, writing this logic as a React component is already more complex and is not providing all of the logic we want and get from sagas.
"I actually want to know the details."
Sure, here you go:
In the first article of this series, you learned how to use hooks to access dispatch
and the store's state.
Now, when the component runs, it dispatches init
and increment
. After incrementing the count, it fetches the user and increments the count by the length of the user's name. You use useRef
to prevent infinite loops since incrementing the count would cause the second useEffect
to run again.
The logic of this React component is actually different from the saga you saw earlier. The saga starts when init
is dispatched. But to replicate that behavior with hooks, you'd need to create a condition in the store's state, which is set to "true" when init
is dispatched, and then react to it inside useEffect
. So you can already see, how implementing complex sequences is easier with sagas than with React components and hooks.
I know this was a lot, but now you have a big-picture overview, so you can learn all the details easier.
What Are Redux Saga's Components?
Redux Saga consists of three components:
- Effects - Effects are action creators that describe future actions.
- Sagas - Sagas are generator functions that manage side effects.
- Middleware - The middleware, which contains effect handlers, manages the execution of the sagas and the corresponding effects.
Effects
Redux Saga uses special kinds of action creators called "effects". Effects are instructions for the handlers in the saga middleware. These effect handlers execute the actual mechanism of the effect.
This article will cover the most important effects. Once you finished reading this article, you'll find it easy to look up and understand the less common effects in the Redux Saga documentation.
You're going to start with the take
effect.
Here is what happens when you import the take
effect, call it, and log it out.
It logs out an object to the console.
As you can see, since all effects are action creators, they include a type
and payload
property.
But there are also two additional properties.
@@redux-saga/IO
: This property shows that the object is a Redux-Saga effect. Redux-Saga middleware recognizes and processes only actions with this property, but ignores any other action. This way the middleware only processes actions it is meant to process.combinator
: This property indicates whether the effect is a combinatorial effect, which is used to run multiple effects in parallel or sequentially. Sincetake
is a simple effect to pause a saga, this property isfalse
.
take
What is special about the take
effect? Or in other words, what is take
used for?
The take
effect is used to pause a saga until a specific action is dispatched to the Redux store. When the specified action type arrives, the saga continues execution.
As you explore the different effects, create them in a file at src/redux/effects.js
.
pattern
is an action type. It's the type of the action that needs to be dispatched to cause the saga to continue. (If a take effect was yielded in your saga, the pattern on its payload tells the effect handlers in the saga middleware what type of action should resume the saga.)
You can leave out the combinator
property in your own actions since implementing that behavior is complex and unnecessary for a deep understanding of sagas. If you're curious, you can explore the Redux Saga source code later.
put
Take a look at another effect called put
.
Like any effect, when you call it and log it out, it also returns an action.
The put
effect describes an action to be dispatched by the effect handlers once the saga iteration triggers the invocation of this effect. (TODO: record this)
The action has a similar shape as the returned object from the take
effect, but the payload contains a channel
and an action
.
Channels in Redux-Saga can be used for more complex communication patterns, but for standard put
effects dispatching actions to the store, this remains undefined
.
The action
property contains the action object that will be dispatched. In your example, it is { type: 'increment' }
.
Add put
to your custom effects.
As mentioned earlier, you can omit the channel
property because it's undefined
anyway for simple put
effects.
Sagas
The term "saga" in Redux Saga comes from a 1987 paper by Hector Garcia-Molina and Kenneth Salem. Their work describes a way to handle long-lived database transactions by breaking them into smaller parts with backup plans if something goes wrong.
Redux Saga adapts this idea to manage complex asynchronous tasks and side effects in app state management using generator functions. The name "saga" reflects the long-running, intricate processes this middleware handles, much like the complex stories found in historical sagas.
If you're unfamiliar with generators, read "JavaScript Generators Explained, But On A Senior-Level". That article will give you all the foundational knowledge you need to understand sagas.
It's time to use your custom effects in a saga generator in src/features/example/example-sagas.js
.
Import the increment
action creator and your custom effects.
Then, create an init
action creator that you will use to trigger your saga. This action only needs a type property to start the saga once it is dispatched for the first time anywhere in your app.
In your saga, use the take
effect to wait for the init
action to be dispatched, then use the put
effect to dispatch the increment
action.
Now, create a generator object using your saga. Then call .next()
three times. When you call .next()
for the second time, pass the init
action creator to the .next()
method, which simulates what the saga middleware would do behind the scenes.
If you run this code, you see the take
effect with the "init" pattern. Next, you see the put
effect with the increment action. And finally, the generator is done.
As you can see, there is no magic here. The saga is just a generator.
Saga Middleware
The saga "magic" really happens in the middleware. The middleware is responsible for correctly handling the effects and passing them to all your sagas.
Create createSagaMiddleware
function in src/redux/saga-middleware.js
.
Import your two custom effects.
Next, define and export a new function called createSagaMiddleware
.
It defines an array of sagas in its closure.
Then it defines the middleware with the typical three parameters: store
, next
and action
. Call the next
middleware with the action
to create a result
. It is later returned as the final output after all effects have been processed to keep the middleware chain going.
Now, it iterates through all the sagas.
For each saga, it generates a new iterator. Then, it sets up effectHandled
, which it initializes to false
.
Now it defines the handleEffect
function. The effect
argument is the value of the generator objects that your sagas yield
.
If the effect is undefined
or lacks the @@redux-saga/IO
key, it sets effectHandled
to true
and returns early, so the saga ignores the effect.
Next, it creates a switch
statement to handle specific effects.
It starts with a handler for the take
effect. If a take
effect was yield
ed in your saga, that means it has a pattern
on its payload which tells you the type of action that should resume the saga. If the current action matches this type, the handler returns it. If not, it marks the effect as handled and return.
For the put
effect, the handler simply dispatches the action from the effect's payload using store.dispatch
.
For any unhandled effect type, the middleware marks the effect as handled, too.
Next, it defines a processSaga
function. While the effect is NOT handled, it calls the .next()
method on the current saga iterator, passing it the last value. The first .next()
is always called without an argument because last value starts as undefined
. If the saga is done
, it stops the while
loop using the break
keyword. Then it computes the latest value by using the handleEffect
function. This lastValue
gets inserted as the argument into the next .next()
call. Later, you will see that handleEffect
can work with promises, so processSaga
needs to await
it. If an error happens, it catches it and passes it into the saga using the .throw()
method.
Finally, the middleware returns the result as you would with any middleware.
Define a .run()
method on your saga middleware. This is the mechanism you use to push new sagas into the array. Finally, createSagaMiddleware
returns the created middleware.
This implementation of the saga middleware is simplified for learning purposes. For example, take
effects can only be used as the first effect in a saga because each dispatch because calls all saga iterators from scratch through the forEach
loop. With the real implementation from the Redux Saga package, you can use take
anywhere in the saga. However, this version gives you a solid foundational understanding.
Now modify your makeStore
function to use your createSagaMiddleware
function.
Import your exampleSaga
and your createSagaMiddleware
function.
Then create a logger
middleware. You're going to use it to show the behavior of the saga.
Use your createSagaMiddleware
function inside of your makeStore
function to create your middleware. Then, pass both middleware to Redux's createStore
function. Call the .run()
method and pass in your exampleSaga
.
Now you need to dispatch the init
action in your store provider.
You only dispatch init
from inside your StoreProvider
in this tutorial to start your saga. In the real world, you probably wouldn't do this.
If you now run your app and visit http://localhost:3000/
, you'll see increment
action dispatched after your init
action.
This means your exampleSaga
works. Your take
catches the init
action, and then your saga keeps running and dispatches the increment
action using the put
effect.
select
You've learned how to "catch" actions with take
and how you can dispatch actions with put
in your saga.
What if you want to grab some state using a selector to use it in your saga?
That's where the select
effect comes in.
If you log it out, it also returns an action.
The select
effect is used for retrieving data from the Redux store. Its payload contains a selector
function and optional args
.
The args
are additional arguments passed to the selector, if needed. This can be useful if you have a selector that selects a specific user by id.
Add select
to your collection of custom select
effects.
Next, import your selectCount
selector and the select
effect into your example saga. Use them to grab the current count from your store within the saga.
If you’re coding along, try implementing the handling of the select
effect in your saga-middleware
on your own.
...
Are you done? Here is the solution.
To support the select
effect, switch on its type and then call store.getState()
to pass it the current state and spread in the arguments attached to the select
effect as the second argument.
When you now run your code, you should see the current count logged out in your browser console.
call
How do you isolate side effects with Redux Saga? You do that using the call
effect.
When using Redux saga, similar to how you do it with actions and selectors, you use objects that represent future computations rather than directly triggering computation with I/O. call
never actually calls a function. Instead, it returns an object with a reference to a function and its arguments, and the saga middleware calls it for you.
Log out call
again to inspect it.
At this point, the output should look familiar to you.
The fn
property contains the function that is the first argument of the call
effect, and args is the arguments passed to the function. context
contains the this
context in which the function will be executed, which is null
when the function does NOT depend on it.
You can use call
in two ways. If the first argument is an array, the first element is the context, and the second is the function. If the first argument is a function, the context is null
.
Now, use call
in your example saga located in src/features/example/example-sagas.js
.
To have something to isolate, create a fetchUser
function that retrieves a user from a placeholder API using their id
.
Then, use call
to fetch the user whose ID is equal to the current count. Lastly, dispatch an incrementBy
action to increase the count by the length of the user's name.
call
is a pure function that takes in a function to call and the arguments to call the function with. It then delays the calling of the function. The invocation of the function is done by the saga middleware at some later point in time. This is how call transforms an impure function, like an API call, or a database call into a pure function by isolating the side effect.
By the way, this saga is nonsense. You're going see real use-cases in article 3, 4, and 5 of this series. The purpose of this example saga is to help you understand how Redux Saga and its effects work.
Next, modify your createSagaMiddleware
function to handle the call
effect in src/redux/saga-middleware.js
. If you'd like, you can try doing it again as an exercise, but be warned - this one is a bit tougher.
In the effect handler for call
, destructure the function, it's arguments and the context from effect's payload.
Then call the function using the apply
method. This allows the function to be invoked with the specified context
and arguments
. Remember, if the function does NOT rely on a specific context, context
will be null
.
If the executed function returns a promise (i.e., it's an asynchronous operation), await this promise and then return the resolved value. Otherwise return the result directly.
If an error occurs during the execution of the function (either synchronously or within the promise), catch the error. Then use sagaIterator.throw(error)
to pass the error back to the saga, allowing the saga to handle it appropriately (e.g., in a try/catch block).
As mentioned above, when you defined processSaga
you await handleEffect
because through call
it can handle promises.
Now run your app again.
Your browser console should now show the incrementBy
action being dispatched with a payload of 13, because the user with ID one is named "Leanne Graham."
Redux Saga offers many more effects. You can explore them in the documentation, and be sure to read the next article in this series, where you're going to use more effects and dive into real-world examples.
Redux Saga Package
It's time to replace your custom code with the actual functions from Redux Saga.
Install Redux Saga.
Next, replace your custom createSagaMiddleware
function in your store
with the one provided by Redux Saga.
Now you can replace the custom effects in your example saga with the actual effects from Redux Saga.
And when you run your app again, you still get the same output in the console of your browser.
In the beginning of this article, you learned that Redux Saga is great for:
- isolating asynchronous effects, and
- deterministic testing of I/O related logic.
So far, you've only seen the first point in this article. You're going to learn how to use sagas for deterministic testing in the fourth article of this series, where you build a real-world app with Redux.
But before that, you need to learn how to set up Redux for production, which is what the third article covers. So, go read that one next.