Simple way to split data handling logic in complex React Redux app

store

Official Redux documentation says that you can have only a single store in your React/Redux application and it's important to understand that your entire application really only has one single reducer function: the function that you've passed into createStore as the first argument. For complex and meaningful applications, putting all your data handling logic into a single reducer function is quickly becomes “insupportable”. In this hint I want to offer simple way to split data handling logic in complex React/Redux apps.

This method was formed and improved over several years of development, was tested both for function and class React Components. It will be useful when it is impossible to strictly divide the reducer into parts so that each part is used by only one component and each component uses only one reducer part. it was quite difficult for me to formulate the previous sentence and you may find it difficult to understand it. So, I will depict it schematically.

Interaction with store in complex apps

As you can see in the diagram, some reducer fields can be used by both components (fieldE, fieldG), and some fields are used by only one component. Also, if a Component2 uses one field of some reducer (e. g. Reducer1 – fieldA), this does not mean that Component1 should ‘react’ on changes in all fields (fieldB & fieldB) of this reducer. In this context ‘react’ === ’rerender’.

Key points of proposed method of splitting data handling :

Closer to practice

I tried to make your life easier. That’s why I have already created project for demonstration all key points described above. You can just get a sample project from this github link https://github.com/AlonaRadchenko/module-store-app. Using this application as example, we will analyze in detail the principles of splitting data handling.

The subject of our demo project is a products list with the ability to add products from the list to the cart and ability to remove items from cart. This is how the interface of our application looks like:

Application interface

As you can see this application quite simple. Now we know what our test application should do and can go to its general structure.

Projec structure

Using combineReducers for splitting root reducer

It's good programming practice to take pieces of code that are very long or do many different things, and break them into smaller pieces that are easier to understand. Code of reducer function is not an exception. To split reducer we will use combineReducers. The combineReducers helper function turns an object whose values are different reducing functions into a single reducing function you can pass to createStore. The resulting reducer calls every child reducer, and gathers their results into a single state object. The state produced by combineReducers() namespaces the states of each reducer under their keys as passed to combineReducers().

For our example project I create to subreducers cartReducer.js and productsReducer.js in 'store/reducers' directory.

cartReducer.js:

const initState = {
  productsIds: [],
  countOfEachItemById: {},
}
const productsReducer = (state = initState, action) => {
  const handlers = {
    'ADD_PRODUCT_TO_CART': () => {
      const newState = JSON.parse(JSON.stringify(state));
      const id = action.productId;
      if (state.productsIds.includes(id)) {
        newState.countOfEachItemById[id] += 1;
      } else {
        newState.countOfEachItemById[id] = 1;
        newState.productsIds.push(id);
      }
      return newState;
    },
    'REMOVE_PRODUCT_FROM_CART': () => {
      const newState = JSON.parse(JSON.stringify(state));
      const id = action.productId;
      delete newState.countOfEachItemById[id];
      newState.productsIds = newState.productsIds.filter(el => el !== id);
      return newState;
    }
  }
  if (!handlers[action.type]) {
    return state;
  }
  return handlers[action.type]();
};
export default productsReducer;

productsReducer.js:

const initState = {
  productsArr: [],
  discountsArr: [],
}
const productsReducer = (state = initState, action) => {
  const handlers = {
    'SET_PRODUCTS': () => {
      return {
        ...state,
        productsArr: action.products,
      }
    }
  }
  if (!handlers[action.type]) {
    return state;
  }
  return handlers[action.type]();
};
export default productsReducer;

We 'combine' this reduserc in 'store/reducers/index.js':

import { combineReducers } from 'redux';
import productsReducer from './productsReducer';
import cartReducer from './cartReducer';
const reducers = combineReducers({
  products: productsReducer,
  cart: cartReducer,
})
export default reducers;

Now pass reducers to createStore and enjoy your life)

Using actionCreators and bindActionCreators

Well-written Redux apps don't actually write action objects inline when we dispatch them. Instead, we use "action creator" functions. They are very helpful and convenient for working with promises and thunks (you can save API call result directly to store via dispatch). To call dispatch from action creators we should use bindActionCreator. This function turns an object whose values are action creators, into an object with the same keys, but with every action creator wrapped into a dispatch call so they may be invoked directly. In our example application we have two actionCreator modules 'store/actionCreators/productsActions.js' and 'store/actionCreators/cartActions.js'.

productsActions.js:

import { productsList } from '../mockedData'
export const getProducts = () => {
  return function (dispatch) {
    return new Promise((resolve) =>
      setTimeout(() => {
        dispatch({ type:"SET_PRODUCTS", products: productsList });
        resolve();
      }, 2000)
    );
  }
}
export const addItemToCart = (id) => {
  return function (dispatch) {
    dispatch({ type:"ADD_PRODUCT_TO_CART", productId: id });
  }
}

cartActions.js:

export const removeItemFromCart = (id) => {
  return function (dispatch) {
    dispatch({ type: "REMOVE_PRODUCT_FROM_CART", productId: id });
  }
}

And in 'store/actionCreators/index.js' we bind actionCreators:

import {bindActionCreators} from "redux"
import * as productsActions from "./productsActions";
import * as cartActions from "./cartActions";
const actions = (dispatch) => ({
  products: bindActionCreators(productsActions, dispatch),
  cart: bindActionCreators(cartActions, dispatch),
});
export default actions;

Creation and using storeConector function

In this paraph is described the 'greatest magic' for rendering optimization. To avoid unnecessary rerender we map to component's props only necessary fields of necessary subreducers. For this purposes was created storeConnector function. It's located in 'store/index.js' and looks like:

import { connect } from 'react-redux';
import actions from "./actionCreators";
import { connect } from 'react-redux';
function mapDispatchToProps(dispatch) {
  return {actions: actions(dispatch)}
}
export const storeConnector = (comp, data) => {
  const mapStateToProps = (state, ownProps) => {
  let usedFields = Object.keys(data).reduce((accumulator, currentValue)=>{
    if(data[currentValue] !== "all"){
      const partialState = data[currentValue].reduce((acc, curValue) => {
        acc[curValue] = state[currentValue][curValue];
        return acc;
      }, {});
      return {...accumulator, ...partialState}
    } else {
      return {...accumulator, ...state[currentValue]}
    }
  },{} )
  return {...usedFields, ...ownProps}
  }
  return connect(mapStateToProps, mapDispatchToProps)(comp);
}

Looks little 'scary', doesn't it?) But I promise you, nothing is as it seems.

Let's first find out what a connect is. The connect() function connects a React component to a Redux store. It provides its connected component with the pieces of the data it needs from the store, and the functions it can use to dispatch actions to the store. It does not modify the component class passed to it; instead, it returns a new, connected component class that wraps the component you passed in. Heare we pass to connect() two parammeters mapStateToProps and mapDispatchToProps. mapStateToProps function maps only necessary data from store to component props and mapDispatchToProps maps our 'actions' to component and add possibility to call any action from any components via props.

To use storeConnector you should import it to component and 'wrap' component with it. Let's take ProductsList component as an example:

import React from 'react';
import { storeConnector } from '../store';
import Product from './Product';
import styles from './ProductsList.module.css';
const ProductsList = (props) => {
  return (
    <>
      <div className='title'>
        Products List
      </div>
      <div className={styles.wrapedContent}>
        {
          props.productsArr.map((p,i) =>
            <Product key={i} item={p}/>
          )
        }
      </div>
    </>
  );
}
export default storeConnector(ProductsList, { products: ['productsArr'] });

As you can see we pass in storeConnector two arguments: component and dict. Each key of that dict is reducer module name, and value is array that consists from necessary reducer's fields (you also can pass string 'all' instead of array, it will cause mapping of all fields of reducer module to component's props).

#react #redux #bindActionCreators #combineReducers
2
Alona Radchenko profile picture
Oct 27, 2021
by Alona Radchenko
Did it help you?
Yes !
No

Best related