This blog provides a concise guide to writing clean and maintainable Redux code in a React project with Redux-Saga for async middleware. I emphasize the importance of organizing Redux files within a specific folder structure and highlight the role of middleware in handling various tasks. I introduce the ReduxRoutines method to avoid code repetition and showcase the usage of nextReduxWrapper to access Redux data in Next.js files. The blog recommends using Redux-Saga for handling side effects and suggests exploring Redux Toolkit as an alternative for writing cleaner and more understandable Redux code.
Complexity and other downsides of redux as mentioned here are the reasons that there are so many projects with bad redux structure and hard to maintain codes. In this blog, I want to show you the way I like to implement projects with redux.
Before choosing redux for state management, please consider reading the Why Do You Need to Rethink About Using Redux blog first. If you still want to use redux for your next project, keep on reading the rest of this blog.
Here I work on a next.js project, if you have a react project you can skip next.js parts, and I use redux-saga as an async middleware to handle side effects.
I've created a github repository for sharing this boilerplate with you, you can access it here: Source
And there is a demo here deployed with vercel.
First, install needed packages:
yarn add redux react-redux redux-saga next-redux-wrapper redux-devtools-extension
I like to have a redux folder to put all of the redux mess inside of it, and inside of that another two folders, middleware
for all my middlewares and modules
for all saga workers.
Middlewares in redux play a huge role, we can use them for logging, error handling, reporting, and ...
The only required middleware here is saga middleware, we need to create a middleware/saga.js
file like this:
import createSagaMiddleware from "redux-saga";
const sagaMiddleware = createSagaMiddleware();
In the root of the redux folder, we have 5 files.
handleSagaError
This is file is for handling different errors. It contains a generator function:
import { put, delay } from "redux-saga/effects";
export default function* handleSagaError(error, status = "failure") {
if (error.type === "serverError") {
yield put(alert("layout.alert.error.server"));
yield delay(6000);
return;
}
if (error.type === "internet") {
yield put(alert("layout.alert.error.connection"));
yield delay(6000);
return;
}
if (error.type >= 500) {
yield put(alert("layout.alert.error.server"));
yield delay(6000);
}
// ...
}
// some function for showing notifications
function alert() {}
index.js
:
This file is for combining and exporting modules so outer modules won't have to now our folder structure to import :
import { END } from "redux-saga";
export * from "./modules/rehydrate";
export * from "./modules/language";
// export * from "./modules/...";
const SAGA_END = END;
const APP_HYDRATION = "APP_HYDRATION";
reducers.js
This file is responsible for bringing all of our reducers together, ready to combine with combineReducer.
import { xxxKey, xxxReducer } from "./modules/xxx";
const reducers = {
[xxxKey]: xxxReducer,
};
sagas.js
This file is responsible for creating our sagas
Here we don't have many sagas so I only have a root saga, but as the number of your sagas increases, you need to separate them into multiple sagas.
import { all, takeEvery } from "redux-saga/effects";
import {
APP_HYDRATION,
//
REHYDRATE,
rehydrateWorker,
//
languageActionTypes,
changeLanguageWorker,
//
xxxActionTypes,
xxxWorker,
} from "./";
export default function* rootSaga() {
yield all([
takeEvery(APP_HYDRATION, rehydrateWorker),
takeEvery(languageActionTypes.CHANGE_LANGUAGE, changeLanguageWorker),
takeEvery(xxxActionTypes.TRIGGER, xxxWorker),
takeEvery(artistActionTypes.TRIGGER, artistWorker),
]);
}
store.js
And most important file is store.js which is for configuring our store.
import { applyMiddleware, createStore, combineReducers } from "redux";
import createSagaMiddleware from "redux-saga";
import { createWrapper, HYDRATE } from "next-redux-wrapper";
import { APP_HYDRATION } from "@redux";
import { rootSaga } from "./sagas";
import { reducers } from "./reducers";
const bindMiddleware = (middleware) => {
if (process.env.NODE_ENV !== "production") {
const { composeWithDevTools } = require("redux-devtools-extension");
return composeWithDevTools({ trace: true })(applyMiddleware(...middleware));
}
return applyMiddleware(...middleware);
};
const rootReducer = (state, action) =>
action.type === HYDRATE
? { ...state, ...action.payload }
: combineReducers(reducers)(state, action);
const makeStore = (context) => {
const sagaMiddleware = createSagaMiddleware();
const store = createStore(rootReducer, bindMiddleware([sagaMiddleware]));
store.sagaTask = sagaMiddleware.run(rootSaga);
if (process.browser) store.dispatch({ type: APP_HYDRATION });
return store;
};
const nextReduxWrapper = createWrapper(makeStore, {
debug: process.env.NODE_ENV === "development",
});
export { makeStore, nextReduxWrapper };
All store.js
files are pretty much the same, here we are doing some different things:
rootReducer
function separates HYDRATE
action from others and combines reducers.bindMiddleware
handles setting up redux-devtools in development mode.makeStore
function makes store! it creates a store, runs the rootSaga and if we are on the client-side it dispatches the APP_HYDRATION
action.nextReduxWrapper
is a wrapper we'll need in _app.js
and all files that their data fetching method needs to access reduxWith redux-saga, every worker is a generator function, We will create our actions and workers in the same file and every reducer has 5 different types of action:
TRIGGER
: We will deal with this action in components.REQUEST
: After triggering the TRIGGER action type, REQUEST will get dispatched and it's a sign for us to trigger loading and loaded properties of our stateSUCCESS
: After triggering the REQUEST action type, if everything went well, the SUCCESS action type will get fired and we will set the data property of our stateFAILURE
: After triggering the REQUEST action type, if anything went wrong, the FAILURE action type will get fired and we will set the error property of our stateFULFILL
: After all of the processes got finished, this action type will get fired and we will reset the loading and loaded state of our stateSoon in redux, you will find out so much of your code is just repeating yourself. so we need a way to prevent it. We need a method that takes store names and generates all actions types and required reducers for us.
I created that method and called it ReduxRoutines
like this:
const REDUX_ACTION_TYPES = [
"TRIGGER",
"REQUEST",
"SUCCESS",
"FAILURE",
"FULFILL",
"RESET",
];
import { REDUX_ACTION_TYPES } from "@constants/redux";
const initialState = {
timestamp: null, // timestamp of the last action that was dispatched
data: null,
error: null,
loading: false,
loaded: false, // has data been fulfilled successfully?
triggerPayload: null, // the payload that reducer got triggered with
};
export default function ReduxRoutines(key) {
const sagaRoutines = createSagaRoutines(key);
this.actions = sagaRoutines.actions;
this.actionTypes = sagaRoutines.actionTypes;
this.reducer = (state, action) =>
createReducer(state, action, this.actionTypes);
}
function createSagaRoutines(key) {
return REDUX_ACTION_TYPES.reduce(
(acc, cv) => {
// actionTypes = { TRIGGER: `${key}/TRIGGER`, REQUEST: `${key}/REQUEST`, ... }
acc.actionTypes[cv] = `${key}/${cv}`;
// actions = { trigger: (payload = {}) => ({ type: actionTypes.TRIGGER, payload }), ... }
acc.actions[cv.toLowerCase()] = (payload = {}) => ({
type: acc.actionTypes[cv],
payload,
});
return acc;
},
{ actions: {}, actionTypes: {} }
);
}
function createReducer(state = initialState, action, actionTypes) {
const timestamp = new Date().getTime();
switch (action.type) {
case actionTypes.TRIGGER:
return {
...state,
timestamp,
triggerPayload: action.payload || null,
loading: true,
};
case actionTypes.REQUEST:
return {
...state,
timestamp,
};
case actionTypes.SUCCESS:
return {
...state,
timestamp,
data: action.payload,
error: null,
};
case actionTypes.FAILURE:
return {
...state,
timestamp,
// data: null,
error: action.payload,
};
case actionTypes.FULFILL:
return {
...state,
timestamp,
loading: false,
loaded: true,
};
case actionTypes.RESET:
return initialState;
default:
return state;
}
}
In _app.js
file, you need to wrap the App component in nextReduxWrapper.withRedux
like this:
export default nextReduxWrapper.withRedux(WrappedApp);
And wherever in files inside pages directory you wanted to use data fetching methods of next.js, you need to import nextReduxWrapper
and use it like this:
import { nextReduxWrapper } from "@redux";
export default function () {
// ...
}
const getStaticProps = nextReduxWrapper.getStaticProps((store) => () => {});
Finally, you need to create a worker for every slice of your store. In redux-saga a worker is a generator function like this:
import { call, put } from "redux-saga/effects";
import ReduxRoutines from "@models/ReduxRoutines";
import Http from "@utils/Http";
import __get from "lodash-es/get";
import handleSagaError from "@redux/handleSagaError";
import endpoints from "@constants/endpoints";
const key = "home";
const { actions, actionTypes, reducer } = new ReduxRoutines(key);
export default function* homeWorker(action) {
const { type, payload } = action;
try {
yield put(actions.request());
const response = yield call(Http.get, { url: endpoints[key] });
const result = response?.result?.explore?.items || [];
yield put(actions.success(result));
action?.payload?.resolve?.(result);
} catch (error) {
yield put(actions.failure(error));
yield handleSagaError(error);
action?.payload?.reject?.(error);
} finally {
yield put(actions.fulfill());
}
}
export {
actions as homeActions,
actionTypes as homeActionTypes,
reducer as homeReducer,
key as homeKey,
};
As I mentioned, there is another blog here that describes why do you need to be precise about using redux in your projects, but if you figured out that using redux will be appropriate for your development process, this is the best way I know to implement redux.
There are some other tools like redux toolkit to help us write more understandable and clean redux code that you can use if you prefer.