Combining Redux Sagas For More Scalable Stores
So we always hear how important is to keep your app modular and separate the concerns throw modules so your frontend can be properly layered. With that in mind, probably the most important thing is how you handle state management and asynchronicity in your app.
Redux Sagas is one way to go, throughout my career I’ve mostly worked with thunks (redux-thunk) but I can definitely see advantages of sagas and cleaner separation of concerns. So let’s create a small app and focus on the store layer, and see how can we get a scalable modular store with ease.
Folder structure (this is a personal preference if you feel like going with different structure, feel free to do it)

Ok lets now focus on the way that we are gonna manage the state in our app, so we are not gonna complicate too much, we have a simple use case here, we need to fetch and show some list of users, and some filtering throw that list, and also fetch some images and create some carousel.
Split the store
For our use case, we have two separate parts one regarding the user table and one regarding the image carousel, of course even if you have 40 (auth, errors, form …. ) the logic stays the same.
We need to separate our actions, reducers, sagas by use case, and then combine it at the create store moment, so our folder structure is looking something like this.

So let’s create some types in the types
directory.
…/types/usersTypes.js
export const GET_USERS = 'GET_USERS';
export const SET_USERS = 'SET_USERS';
export const SET_FILTER_QUERY = 'SET_FILTER_QUERY';
…/types/imagesTypes.js
export const GET_IMAGES = 'GET_IMAGES';
export const SET_IMAGES = 'SET_IMAGES';
Now that we got that out of the way let’s create action
…/actions/usersActions.js
import { GET_USERS, SET_FILTER_QUERY, SET_USERS } from "../types";
export const getUsers = () => ({ type: GET_USERS });
export const setUsers = users => ({
type: SET_USERS,
payload: users
});
export const setFilterQuery = query => ({
type: SET_FILTER_QUERY,
payload: query
});
…/actions/imagesActions.js
import { GET_IMAGES, SET_IMAGES } from "../types";
export const getImages = () => ({ type: GET_IMAGES });
export const setImages = images => ({
type: SET_IMAGES,
payload: images
});
Now that we have types and actions we can create reducers so we can actualy get state changes as a result of the actions
…/reducers/usersReducer.js
import { SET_FILTER_QUERY, SET_USERS } from "../types";
const initialState = {
users: [],
filterQuery: ""
};
export const userReducer = (state = initialState, action) => {
switch (action.type) {
case SET_USERS:
return {
...state,
...{ users: action.payload }
};
case SET_FILTER_QUERY:
return {
...state,
...{ filterQuery: action.payload }
};
default:
return state;
}
};
…/reducers/imagesReducer.js
import { SET_IMAGES } from "../types";
const initalState = {
images: []
};
export const imagesReducer = (state = initalState, action) => {
switch (action.type) {
case SET_IMAGES:
return {
...state,
...{ images: action.payload }
};
default:
return state;
}
};
So we are all set, let’s create and combine sagas
…/sagas/usersSaga.js
import { put, takeLatest } from 'redux-saga/effects';import {setUsers} from "../actions";
import {GET_USERS} from "../types";import {apiUrl} from "../../config";
const usersEndpoint = '...'
function* fetchUsers() {
const users = yield fetch(`${apiUrl}/${usersEndpoint}`).then(res => res.json());
yield put(setUsers(users.result))
}
function* getUsersWatcher() {
yield takeLatest(GET_USERS, fetchUsers)
}export const userWatchers = [
getUsersWatcher
]
…/sagas/imagesSaga.js
import { put, takeLatest } from 'redux-saga/effects';import {setImages} from "../actions";
import {GET_IMAGES} from "../types";import {apiUrl} from "../../config";
const imagesEndPoint = '...'
function* fetchImages() {
const images = yield fetch(`${apiUrl}/${imagesEndPoint}`).then(res => res.json());
yield put(setImages(images.result))
}
function* getImagesWatcher() {
yield takeLatest(GET_IMAGES, fetchImages)
}export const imagesWatcher = [
getImagesWatcher
]
So we wrote a lot of code so let’s stop here and make a small retrospective.
Since our application has some asynchronicity, we need to introduce a layer that will handle this, that is why have we introduce the sagas in the first place.
At this point, we really have two async calls fetchUsers
and fetchImages
and that is the reason why have we created watchers on GET_USERS
and GET_IMAGES
and also as you probably noticed we do not reduce those two actions, we only observe these actions with watchers and reduce actual actions that are a result of the async calls.
But as you can also see SET_FILTER_QUERY
is not going throw saga since it does not require any async call, there for after the dispatch action is reduced right away.
Now that we have that out of the way we need to combine these watchers and create that rootSaga that will go into store middleware.
…/sagas/index.js
import { all } from 'redux-saga/effects';
import { combineWatchers } from 'redux-saga-combine-watchers';import { userWatchers } from "./usersSaga";
import { imagesWatchers } from "./imagesSaga";
export function* rootSaga() {
yield all(combineWatchers(userWatchers, imagesWatchers))
}
Now it’s time to actualy create the store.
…/store.js
import { createStore, applyMiddleware, combineReducers } from 'redux'
import createSagaMiddleware from 'redux-saga'import {imagesReducer, userReducer} from "./reducers";
import {rootSaga} from "./sagas";
const sagaMiddleware = createSagaMiddleware()
export const store = createStore(
combineReducers({userReducer, imagesReducer}),
applyMiddleware(sagaMiddleware)
)
sagaMiddleware.run(rootSaga);
We need to provide a store into our App.
src/index.js
...ReactDOM.render(
<Provider store={store}><App /></Provider>,
document.getElementById('root')
);...
and let’s now dispatch some actions from our components
…/Users.jsx
import React, { useEffect } from "react";
import { connect } from "react-redux";
import { Table } from '...';import { getUsers, setFilterQuery } from ".../store/actions";import UserTableRow from "...";
const mapDispatchToProps = {
getUsers,
setFilterQuery
};const mapStateToProps = state => ({
users: state.userReducer.users,
filterQuery: state.userReducer.filterQuery
});const Users = ({ getUsers, users, filterQuery, setFilterQuery }) => {
const filterUsers = user =>
user.last_name.toLowerCase().includes(filterQuery.toLowerCase());
const onFilterChange = ({ target: { value } }) =>
setFilterQuery(value); useEffect(() => {
getUsers();
}, []);
return (
<Table>
<thead>
<tr>
<th>id</th>
<th>First Name</th>
<th>
Last Name
<FormControl onChange={onFilterChange} />
</th>
<th>Email</th>
<th>Phone</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{users.filter(filterUsers).map(user => (
<UserTableRow user={user} key={user.id} />
))}
</tbody>
</Table>
);
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(Users);
For Carousel the logic is the same.
As you can see as far as the component is aware there are no async calls setFilterQuery
and getUsers
are the same in the eyes of the component, the magic is happening in our saga layer and this was our goal, we have successfully abstracted asynchronicity from our component layer.