BACK TO ALL POSTS

Code Splitting in Redux

July 7th, 2019 • 10 min read

In these modern days of web development, a large web application often desires to have its app code split up into multiple JS bundles to be loaded on-demand. This will help in increasing the overall performance of the app by reducing the size of the initial JS payload to be fetched. With React application, it is recommended by React Team to use @loadable/component for code splitting React components.

But when the app is getting bigger, just code-splitting UI components is usually not enough. For a large React application, a centralized state management framework is really necessary and Redux is often the go-to option. However, Redux's default way of setup from their documentation does not work well with @loadable/component for code splitting. Thus, a lot of React applications have their components split into multiple bundles while all app state logic is still centralized into the entry bundle. 😔

At Carousell, we care deeply about performance so this matter is one of the top priorities for us to solve. This article will shed light on how we managed to split Redux into separate modules and load them incrementally. 🚀

Data Module

Firstly, for Redux's code structuring, we split Redux app's data state into hundreds of data modules. Each data module is a self-contained component comprising actions, action creators, reducer, selectors and sagas. A module can represent the data state of an entity (product, user, offers, etc) or a feature/flow (sell, like, auth, etc). A module can also represent the data state for a UI component.

Data module for entity user

/**
 * data/user.js
 */
import { call, put, takeLatest } from "redux-saga/effects";

// Action Creators
export function userGetRequest(userId) {
  return {
    type: "USER_GET_REQUEST",
    payload: {
      userId,
    },
  };
}

function userGetPending(userId) {
  return {
    type: "USER_GET_PENDING",
    payload: {
      userId,
    },
  };
}

function userGetSuccess(userId, user) {
  return {
    type: "USER_GET_SUCCESS",
    payload: {
      userId,
      user,
    },
  };
}

function userGetFailure(userId, err) {
  return {
    type: "USER_GET_FAILURE",
    payload: {
      userId,
    },
    err,
  };
}

// Reducers
const initialState = {
  users: {},
  userGetPending: false,
  userGetErr: null,
};

export default function reducer(
  state = initialState,
  action,
) {
  switch (action.type) {
    case "USER_GET_PENDING":
      return {
        ...state,
        userGetPending: true,
        userGetErr: null,
      };
    case "USER_GET_SUCCESS": {
      const {
        payload: { user, userId },
      } = action;
      return {
        ...state,
        userGetPending: false,
        users: { ...state.users, [userId]: user },
      };
    }
    case "USER_GET_FAILURE": {
      return {
        ...state,
        userGetPending: false,
        userGetErr: action.err,
      };
    }
    default:
      return state;
  }
}

// Sagas
export function* executeUserGet(action) {
  const {
    payload: { userId },
  } = action;

  yield put(userGetPending(userId));
  try {
    const response = yield call(fetch, `/user/${userId}/`);
    const body = yield call(response.json);
    yield put(userGetSuccess(userId, body));
  } catch (err) {
    yield put(userGetFailure(userId, err));
  }
}

export function* userSaga() {
  yield takeLatest("USER_GET_REQUEST", executeUserGet);
}

// Selectors
export function selectUser(state, userId) {
  return state.user.users[userId];
}

export function selectUserGetPending(state) {
  return state.user.userGetPending;
}

export function selectUserGetErr(state) {
  return state.user.userGetErr;
}

A data module can be used by a React component or other data modules.

/**
 * components/UserProfileLoadable.js
 */
import loadable from "@loadable/component";
import React from "react";

export default loadable(
  () => import("./UserProfile"),
  { fallback: <p>Loading...</p> },
);
/**
 * components/UserProfile.js
 */
import React, { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";

import { userGetRequest, selectUser } from "../data/user";

export default function UserProfile({ userId }) {
  const dispatch = useDispatch();
  const user = useSelector(state =>
    selectUser(state, userId),
  );

  useEffect(() => {
    dispatch(userGetRequest(userId));
  }, [userId, dispatch]);

  return (
    <>
      <p>{user.username}</p>
    </>
  );
}

To enable a module's reducer/sagas, we need to add them to the Redux store. Since Redux only has a single root reducer function, this root reducer is normally generated by calling combineReducers() when the application is initialized. Similar to the reducer, all sagas are merged into a single root saga and this root saga will be run at the initialization phase of Redux.

/**
 * data/index.js
 */
import {
  applyMiddleware,
  combineReducers,
  createStore,
} from "redux";
import createSagaMiddleware from "redux-saga";
import { fork } from "redux-saga/effects";

import {
  default as userReducer,
  userSaga,
} from "./user";

const reducers = {
  user: userReducer,
};

function* rootSaga() {
  yield fork(userSaga);
}

export default function configureStore(initialState) {
  const sagaMiddleware = createSagaMiddleware();

  const store = createStore(
    combineReducers(reducers),
    initialState,
    applyMiddleware(sagaMiddleware),
  );
  store.runSaga = sagaMiddleware.run;

  store.runSaga(rootSaga);

  return store;
}

By doing this, we are importing all data modules into the main bundle instead of injecting them on-demand by following the dynamically loaded React components. 😔 Hence, we need to find some alternatives, which will allow us to dynamically add more reducers or sagas to the Redux store as users navigate the app.

ReducerManager

For reducers, the solution is to create a ReducerManager singleton, which keeps track of all the registered reducers while also registering additional reducers on-demand. The ReducerManager will also have the responsibility to provide the latest combined reducer to the Redux store based on the list of registered reducers.

/**
 * data/reducerManager.js
 */
import { combineReducers } from "redux";

export function createReducerManager() {
  // Mapping from each module name to its reducer
  const reducers = {};
  // Up-to-date current root reducer
  let combinedReducer = combineReducers(reducers);

  return {
    add(key, reducer) {
      reducers[key] = reducer;
      combinedReducer = combineReducers(reducers);
    },
    getReducers() {
      return reducers;
    },
    reducer(state, action) {
      return combinedReducer(state, action);
    },
  };
}

const reducerManager = createReducerManager();

export default reducerManager;

We will then need to update the logic of initializing Redux store to use reducerManager.reducer() instead. In each data module file, we will also need to update them to use reducerManager.add() to register the module's reducer.

/**
 * data/user.js
 */
import reducerManager from "./reducerManager";

// Actions
// ...

// Reducers
const initialState = {
  // ...
};

export default function reducer(
  state = initialState,
  action,
) {
  // ...
}

reducerManager.add("user", reducer);

// ...
/**
 * data/index.js
 */
import {
  applyMiddleware,
  combineReducers,
  createStore,
} from "redux";
import createSagaMiddleware from "redux-saga";
import { fork } from "redux-saga/effects";

import { userSaga } from "./user";
import reducerManager from "./reducerManager";

function* rootSaga() {
  yield fork(userSaga);
}

export default function configureStore() {
  const sagaMiddleware = createSagaMiddleware();

  const store = createStore(
    reducerManager.reducer,
    applyMiddleware(sagaMiddleware),
  );
  store.runSaga = sagaMiddleware.run;

  store.runSaga(rootSaga);

  return store;
}

With this, we have managed to dynamically inject reducers to the Redux store. However, as we can see, we still need to import all data modules in the main bundle to run all the module sagas. So what we need to solve next is to allow the app to also run sagas on-demand.

Saga Manager

Similar to reducers, we will also create a SagaManager singleton, which keeps track of all the running saga tasks and create more saga tasks on the fly.

/**
 * data/sagaManager.js
 */

export function createSagaManager() {
  // Mapping from each module name to its root saga
  const sagas = {};
  // Mapping from each module name to the running saga task
  const tasks = {};
  // Current Redux Store
  let store;

  function cancelTask(key) {
    if (tasks[key] != null) {
      tasks[key].cancel();
      delete tasks[key];
    }
  }

  return {
    addSaga(key, saga) {
      cancelTask(key);
      sagas[key] = saga;

      if (store != null) {
        tasks[key] = store.runSaga(saga);
      }
    },
    getTasks() {
      return Object.values(tasks);
    },
    setStore(s) {
      store = s;

      for (const key of Object.keys(sagas)) {
        const saga = sagas[key];
        cancelTask(key);
        tasks[key] = store.runSaga(saga);
      }
    },
  };
}

const sagaManager = createSagaManager();

export default sagaManager;

Same as before, we will then update the logic of initializing Redux store to use sagaManager.setStore() and remove the call to runSaga here. In each data module file, we will also need to update them to use reducerManager.addSaga() to register the module's root saga.

/**
 * data/user.js
 */
import sagaManager from "./sagaManager";

// Actions
// ...

// Reducers
// ...

// Sagas
export function* executeUserGet(action) {
  // ...
}

export function* userSaga() {
  yield takeLatest("USER_GET_REQUEST", executeUserGet);
}

sagaManager.addSaga("user", userSaga);

// ...
/**
 * data/index.js
 */
import {
  applyMiddleware,
  combineReducers,
  createStore,
} from "redux";
import createSagaMiddleware from "redux-saga";

import reducerManager from "./reducerManager";
import sagaManager from "./sagaManager";


export default function configureStore() {
  const sagaMiddleware = createSagaMiddleware();

  const store = createStore(
    reducerManager.reducer,
    applyMiddleware(sagaMiddleware),
  );
  store.runSaga = sagaMiddleware.run;

  sagaManager.setStore(store);

  return store;
}

At this point, we have succeeded in dynamically injecting reducer/saga to the Redux store. No data modules will be included in the main bundle and they will only be fetched if the React components requiring them are asynchronous loaded through @loadable/component. 🎉

Server-side Rendering

The solution so far has been completed, but it will only work for a purely client-side rendered application. For server-side rendering, there are 2 issues to be tackled.

Rehydrated Initial State

When a Redux app is rendered on the server-side, its data state will be hydrated and passed to the client together with the HTML response. On the client-side, it will first rehydrate the app state and pass to Redux as the initial app state.

During the Redux store creation process, any key in this initial app state without a corresponding reducer will be ignored and that part of app data will be lost. To avoid this, we need to register a stub reducer for these unregistered keys before creating the Redux store.

/**
 * data/index.js
 */
import {
  applyMiddleware,
  combineReducers,
  createStore,
} from "redux";
import createSagaMiddleware from "redux-saga";

import reducerManager from "./reducerManager";
import sagaManager from "./sagaManager";


export default function configureStore(initialState) {
  const sagaMiddleware = createSagaMiddleware();

  const reducers = reducerManager.getReducers();
  for (const key of Object.keys(initialState)) {
    if (reducers[key] == null) {
      reducerManager.add(
        key,
        (state = initialState[key]) => state,
      );
    }
  }

  const store = createStore(
    reducerManager.reducer,
    initialState,
    applyMiddleware(sagaMiddleware),
  );
  store.runSaga = sagaMiddleware.run;

  sagaManager.setStore(store);

  return store;
}

Multiple Redux Stores

For server-side rendering, each server instance will need to handle multiple client requests concurrently. Each rendering request is separate from each other so they cannot share the same app data state.

Hence, a web server needs to create multiple Redux stores instead of one single store like on the client-side. Each of these Redux stores will be unique for one single client request.

However, as we know, our ReducerManager and SagaManager are singletons and each exists as one single instance in the app. The reason we put these 2 managers as singletons were because they were used in every data module to register its reducer or saga. At the data module layer, it does not have any knowledge of which Redux store is using it so creating the registries as singletons are the only way for data module to access the registries and tell them to register its reducer or saga.

Thus, we need to revisit ReducerManager and SagaManager to ensure they work well with handling multiple Redux stores.

For ReducerManager, since there are no dependencies on the Redux store, there's no problem of using ReducerManager singleton with multiple Redux stores.

On the other hand, SagaManager actually requires the Redux store instance to run a saga task. So instead of only keeping track of one single store through sagaManager.setStore(), we will need to update SagaManager to maintain multiple store instances at the same time.

/**
 * data/sagaManager.js
 */

export function createSagaManager() {
  // Mapping from each module name to its root saga
  const sagas = {};
  // Mapping from each store to its tasks map
  const storeTasks = new Map();

  function cancelTask(key) {
    for (const tasks of storeTasks.values()) {
      if (tasks[key] != null) {
        tasks[key].cancel();
        delete tasks[name];
      }
    }
  }

  return {
    addSaga(key, saga) {
      cancelTask(key);
      sagas[key] = saga;

      // Run this new saga for all stores
      for (const store of storeTasks.keys()) {
        const tasks = storeTasks.get(store);
        tasks[key] = store.runSaga(saga);
      }
    },
    getTasks(store) {
      const tasks = storeTasks.get(store);
      if (tasks == null) {
        return [];
      }

      return Object.values(tasks);
    },
    addStore(store) {
      const tasks = {};

      // Run existing registered saga for this store
      for (const key of Object.keys(sagas)) {
        const saga = sagas[key];
        tasks[key] = store.runSaga(saga);
      }
      storeTasks.set(store, tasks);
    },
    removeStore(store) {
      storeTasks.delete(store);
    }
  };
}

const sagaManager = createSagaManager();

export default sagaManager;
/**
 * data/index.js
 */
import {
  applyMiddleware,
  combineReducers,
  createStore,
} from "redux";
import createSagaMiddleware from "redux-saga";

import reducerManager from "./reducerManager";
import sagaManager from "./sagaManager";


export default function configureStore(initialState) {
  const sagaMiddleware = createSagaMiddleware();

  const reducers = reducerManager.getReducers();
  for (const key of Object.keys(initialState)) {
    if (reducers[key] == null) {
      reducerManager.add(
        key,
        (state = initialState[key]) => state,
      );
    }
  }

  const store = createStore(
    reducerManager.reducer,
    initialState,
    applyMiddleware(sagaMiddleware),
  );
  store.runSaga = sagaMiddleware.run;

  sagaManager.addStore(store);

  return store;
}

So with this, ReducerManager and SagaManager are ready to work on the server-side. Our app now will allow us to use the same code-splitting logic and hundreds of modular data modules for both client-side and server-side rendering. 💯

Final Thoughts

Through this article, we have learned how to code split a React / Redux application most optimally so no unused data modules / UI components are included in the initial JS payload. Furthermore, we also figured out how to make Redux code-splitting work with a server-side rendered application.

However, code splitting is just one of the first stepping stones in our journey of improving web performance. I would love to share more about interesting performance solutions that we at Carousell did in the future articles.

But until the next sharing comes, see you when I see you! ✌️