bene : studio is a global consultancy, helping startups, enterprises and HealthTech companies to have better product
Redux-Saga To The Rescue
Pardon the interruption, we have an important message!
We are looking to expand our team with talented developers. Check out our open positions and apply.
In this blog post, we are checking back to our 25. September Redux Saga Workshop. See code for the post: https://github.com/benestudio/react-redux-saga-workshop
If you have ever used Redux, then you know how cumbersome it can be sometimes to come up with the perfect flow of actions, dealing with errors from the backend, shaping your store with reducers, all while maintaining a clean code that will not make you sick a month later.
ESPECIALLY, WHEN YOU ARE WORKING ON A LARGER PROJECT.
But there is this wonderful thing, called Redux-Saga, and it makes your coding life so much easier. It’s just sitting in the middle (surprise — it’s a redux middleware), acting as a separate thread. The only responsibility it has is to make side effects. You may wonder: what kind of side effects does it make, and why don’t we handle them in the reducers? Well, these are the type of things that are not allowed in reducers, like:
- Async operations
- Impure function calls
- Throwing errors
BEFORE WE JUMP IN:
The way I am introducing the world of Sagas, is through a simple React application. To understand the “magic”, I assume you are familiar with React-Redux apps, as well as ES6 features, because the focus is going to be on generator functions. If you need to refresh your knowledge, you can check this guide: [https://davidwalsh.name/es6-generators]
LET’S SEE WHAT WE’VE GOT HERE
So, there is this super-basic app with Redux, in which you can see Posts from users. It does the following:
- Fetches some data from an API
- Shows a list of the fetched array
- At any time, you can refresh and filter the items by name.
BUT WHERE IS THE SAGA?
I’m going to show you, how to connect to your app. See code for the post: https://github.com/benestudio/react-redux-saga-workshop. First, install it as a dependency.
npm install –save redux-saga
Let’s say on a Refresh button click — to refresh the data — we dispatch an action in the main component.
// App.js class App extends React.Component { … onRefreshButtonClicked() { const { dispatch } = this.props dispatch({ type: ‘POSTS_FETCH_REQUESTED’ }) } … }
As I said, Redux-Saga is a middleware, so to attach to our store, we have to supply it to the createStore function, where you configure the store.
// store.js import { createStore, applyMiddleware } from ‘redux’ import createSagaMiddleware from ‘redux-saga’ import reducer from ‘./reducers’ import mySaga from ‘./sagas’ // create the saga middleware const sagaMiddleware = createSagaMiddleware() // mount it on the Store const store = createStore( reducer, applyMiddleware(sagaMiddleware) ) // then run the saga sagaMiddleware.run(mySaga)
As you can see, create a sagaMiddleware, feed it to store with applyMiddleware and finally call run with your main Saga. And now comes the interesting part:
// sagas.js import { call, put, take } from ‘redux-saga/effects’ import api from ‘./api’ export function* fetchPostsSaga() { const posts = yield call(api.fetchPosts) yield put({ type: ‘POSTS_FETCH_SUCCESS’, posts }) } export default function* rootSaga() { while(true) { yield take(‘POSTS_FETCH_REQUESTED’) yield call(fetchPostsSaga) } }
Wait! That’s an infinite loop, you can’t run this code, without crashing! Well, these are generator functions, so it’s perfectly fine. In fact, it’s such a common pattern to do something indefinitely many time, that we get a helper function: takeEvery. Let’s see what it does:
import { takeEvery } from ‘redux-saga/effects’ export default function* rootSaga() { yield takeEvery(‘POSTS_FETCH_REQUESTED’, fetchPostsSaga) }
It now “watches” every action that it matches, takes them and calls the provided function. Hence the name, takeEvery. Okay, that’s really helpful, but what are those methods, call, put and take from redux-saga doing anyway? Honestly, nothing. Those are called Effect creators. They basically create objects — the Effects — which are yield-ed to the Saga middleware. These Effects inform the middleware about what to execute, e.g.: calling the API. They look like this:
{ “@@redux-saga/IO”: true, “CALL”: { “args”: null, “Fn”: fetchPostsApi } }
It’s a very clean approach and on the other hand it makes testing a lot easier.
HOW ABOUT WE GO A LITTLE MORE COMPLEX?
So, we still haven’t done anything with the filter feature. How should we implement it? How to write readable code, make it simple and satisfy the user at the same time? Of course, more Redux-Saga. It would be a really cool feature, to update the screen with the filtered result, ONLY after the user stops typing. This way the list is not changing every time a key is pressed. It’s called debouncing. There is another helper function, that we can use for this case: takeLatest As the name might suggest, it takes the last one from a consecutive list of matching actions. The key is, that only the last one triggers the method, we provide. Let’s suppose, a FILTER_CHANGED action fires, when the user pressed a key. Now the rootSaga looks like this:
import { takeEvery, takeLatest } from ‘redux-saga/effects’ export default function* rootSaga() { yield takeEvery(‘POSTS_FETCH_REQUESTED’, fetchPostsSaga) yield takeLatest(‘FILTER_CHANGED’, filterPostsSaga) }
By the way, you can yield more than one “watcher” in rootSaga. They are doing their stuff parallel. Another thing to remember is that takeEvery and takeLatest automatically calls the function with the captured action as their parameter. Cool, huh? And what am I doing in filterPostsSaga? Let me show you.
export function* filterPostsSaga({ name }) { yield call(delay, 500); const searchName = name.toLowerCase().trim(); const posts = yield select(state => state.posts); const filteredPost = posts.filter((post) => { return post.name.toLowerCase().includes(searchName) }); yield put({ type: ‘FILTERED_POSTS’, filteredPosts }); }
First of all, delay is another helper function, which — what a surprise — delays the whole generator function. But why not simply call it explicitly? One word: TESTING After that, we come across another Effect creator: select. All it does is returning a specific part of the store. Once we have that, we can filter the posts by name. The name param, that comes from the destructured action argument. Finally, we have to update the store with the changes. Calling put solves this, by dispatching the appropriate action. Was it complex? If not, it’s okay. Otherwise…
Brace yourselves! For the final feature, we would like to delete the items one by one. If you have paid attention so far, then you know what steps we need to take.
- Add a watcher to rootSaga, in which
- Call API, then
- Update store
Sounds easy, right? Let’s give it a twist. How about before deleting the item, the user can cancel their decision in a given time range? But how to make it happen? It’s like two actions racing with each other and the winner can determine how to shape the store. Correct. Here comes another helper, race. But let’s not rush forward too quickly. First, the watcher.
import { takeEvery, takeLatest } from ‘redux-saga/effects’ export default function* rootSaga() { yield takeEvery(‘POSTS_FETCH_REQUESTED’, fetchPostsSaga) yield takeLatest(‘FILTER_CHANGED’, filterPostsSaga) yield takeEvery(‘DELETE_POST_REQUEST’, removePost) // <– }
We use takeEvery because for every click on a delete button we want to remove one item. Now here comes the tricky part.
export function* removePost({ id }) { yield race({ response: call(performDelete, id), cancelDeleting: take(‘DELETE_POST_CANCELLED’), }); } export function* performDelete(id) { yield call(delay, 10000) // wait for 10 secs yield call(api.deletePost, id) yield put({ type: POST_DELETED, id }) // remove from store by id }
With the help of race in removePost, whichever case happens first (response or cancelDeleting), it wins. Not to mention, it automatically cancels the other(s), so we don’t have to worry about them. In performDelete the following happens (if not cancelled):
- Wait for 10 sec
- Call API
- Update store
Okay, that’s a pretty impressive flow of actions that we made. But it’s not perfect, yet. If we hit the delete button and still want to remove that item, we have to wait until the timer goes off. Waiting for 30 or even 10 seconds is not really user friendly. So the idea is, that after clicking on the delete button, we can choose from two options. Either cancel the waiting and the removal, keeping the item. Or hitting another delete button confirming that we really do want to get rid of it. Yes, it sounds complicated, but with another technique anything is possible.
INTRODUCING CHANNELS
There are 2 different types of channels: action or event channel. Both of them act like a buffer. The difference is what they hold in themselves. The basic methodology is that incoming actions/events will be queued until somebody takes them out of the channel. In our case we are going to use an eventChannel, which holds external events. By external it means not Redux events. With the help this helper we are creating a channel, in which we are subscribed to a setInterval countdown.
// sagas.js import { eventChannel, END } from ‘redux-saga const countdown = seconds => eventChannel((emitter) => { const iv = setInterval(() => { seconds -= 1; if (seconds > 0) { emitter(seconds); } else { emitter(END); } }, 1000); return () => { clearInterval(iv); }; });
Let’s go over the details what it is exactly doing.
- Countdown is our function, given a parameter in seconds, returns a channel.
- Inside the eventChannel we see an emitter, that is called the subscribe function. With this, we can emit values to be stored in the queue.
- In the subscribe function a setInterval is created with 1-second interval, which is emitting out the remaining seconds.
- If we reach zero (timer is off) a special END value is emitted, it signals the end of the channel, no value will be coming.
- The subscribe function must return an unsubscribe function, which is upon invoked closes the channel.
But how do I take something out of an event channel? You just said it, take. Here’s a saga using the timer we created.
export function* countdownSaga() { const chan = yield call(countdown, 10); try { while (true) { const remainingSeconds = yield take(chan); yield put({ type: ‘COUNTDOWN_SECONDS’, remainingSeconds }); } } finally { chan.close(); } }
First, create the channel by calling it with call and 10 seconds as a param. Then in an infinite loop, we are take-ing out the subsequent values of the remaining seconds. Don’t forget to update the store with put. When that END signal is emitted, we end up in the finally section, where we can close our channel by invoking the unsubscribe function. Remember the performDelete function that we earlier made? Let’s modify it a little.
export function* performDelete(id) { yield race({ wait: call(countdownSaga), instantDelete: take(DELETE_POST_CONFIRMED), }); yield call(deletePost, id); // actual api call const posts = yield select(state => state.posts); const remainingPosts = posts.filter(post => post.id !== id); yield put({ type: ‘DELETE_POST_SUCCESS’, remainingPosts }); }
We are race-ing two actions: either the countdown goes off or we hit the delete button. In either way, the next thing is to call API then update store. With this advanced flow of actions we achieved something, that would be difficult to make. But in every program error can and will happen, how to handle them properly?
ERROR HANDLING
Errors can happen for multiple reasons. A rejected API call, for example, is really common. Let’s see what we can do to handle it properly Suppose we have an action like this:
export function fetchPostsApi() { return axios.get(‘http://localhost:3001/posts’) .then(({ data }) => data); }
As you probably guessed, it can throw an error. So we have to catch it! In our saga code, we can do the following:
export function* fetchPostSaga() { try { const posts = yield call(fetchPostsApi); yield put(postsReceived(posts)); } catch (error) { yield put(postsFailed(error)); } }
When the error is cought, we can dispatch the proper action. This way we can show an error message or do some other work in the reducer.
HOW TO TEST SUCH AN ACTION FLOW?
Remember what I said so many times? The Effect Creators will help us with testing. To show you some test cases, I’m going to use Jest. If you’re not familiar with it, check out this: https://jestjs.io/docs/en/getting-started.html
Let’s see a simple test:
describe(‘fetching posts’, () => { const generator = cloneableGenerator(fetchPostSaga)(); test(‘fetching posts successfully’, () => { const clone = generator.clone(); const posts = {}; expect(clone.next().value).toEqual(call(fetchPostsApi)); expect(clone.next(posts).value) .toEqual(put(postsReceived(posts))); expect(clone.next().done).toBe(true); }); });
In the describe block we create a generator that can be used multiple times from the fetchPostSaga. Now in the test section an actual clone of that generator will be used, this way every use it will be a fresh start. Remember that a saga is basically a generator? So we can call next() on it. And this is why we used the Effect creators, the generators next value must be a CALL object. The next value should be an empty object, that’s what put will give back. Finally, when a generator is finished, it’s done property will be true. It’s so easy to test the flow of actions.
CONCLUSION
So, Redux-Saga is really versatile, there’s probably nothing that you cannot do with it. However, if your application is not so complex, I don’t recommend using it. If you have more questions, check this out: https://redux-saga.js.org/docs/introduction/BeginnerTutorial.html