You need to enable JavaScript to run this app.

How to Write DRY Redux Code

Summary:

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.

Category
React
Time to read
10 min.
Published at
2020-12
How to Write DRY 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.

0. Demo & Source Code

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.

1. Installing libraries

First, install needed packages:

yarn add redux react-redux redux-saga next-redux-wrapper redux-devtools-extension

2. Folder structure

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.

3. Middlewares

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();

4. Walkthrough root files

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 redux

5. Creating modules/workers

With 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 state
  • SUCCESS: 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 state
  • FAILURE: 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 state
  • FULFILL: After all of the processes got finished, this action type will get fired and we will reset the loading and loaded state of our state

Soon 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;
  }
}

6. Usage

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,
};

Conclusion

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.

Favourite Books
BooksPoems
Favourite Songs
PlaylistsArtists
Favourite Shows
AnimationsSeriesAnime