Code Splitting

In large web applications, it is often desirable to split up the app code into multiple JS bundles that can be loaded on-demand. This strategy, called 'code splitting', helps to increase performance of your application by reducing the size of the initial JS payload that must be fetched.

To code split with Redux, we want to be able to dynamically add reducers to the store. However, Redux really only has a single root reducer function. This root reducer is normally generated by calling combineReducers() or a similar function when the application is initialized. In order to dynamically add more reducers, we need to call that function again to re-generate the root reducer. Below, we discuss some approaches to solving this problem and reference two libraries that provide this functionality.

Basic Principle

Using replaceReducer

The Redux store exposes a replaceReducer function, which replaces the current active root reducer function with a new root reducer function. Calling it will swap the internal reducer function reference, and dispatch an action to help any newly-added slice reducers initialize themselves:

  1. const newRootReducer = combineReducers({
  2. existingSlice: existingSliceReducer,
  3. newSlice: newSliceReducer
  4. })
  5. store.replaceReducer(newRootReducer)

Reducer Injection Approaches

Defining an injectReducer function

We will likely want to call store.replaceReducer() from anywhere in the application. Because of that, it's helpfulto define a reusable injectReducer() function that keeps references to all of the existing slice reducers, and attachthat to the store instance.

  1. import { createStore } from 'redux'
  2. // Define the Reducers that will always be present in the application
  3. const staticReducers = {
  4. users: usersReducer,
  5. posts: postsReducer
  6. }
  7. // Configure the store
  8. export default function configureStore(initialState) {
  9. const store = createStore(createReducer(), initialState)
  10. // Add a dictionary to keep track of the registered async reducers
  11. store.asyncReducers = {}
  12. // Create an inject reducer function
  13. // This function adds the async reducer, and creates a new combined reducer
  14. store.injectReducer = (key, asyncReducer) => {
  15. store.asyncReducers[key] = asyncReducer
  16. store.replaceReducer(createReducer(store.asyncReducers))
  17. }
  18. // Return the modified store
  19. return store
  20. }
  21. function createReducer(asyncReducers) {
  22. return combineReducers({
  23. ...staticReducers,
  24. ...asyncReducers
  25. })
  26. }

Now, one just needs to call store.injectReducer to add a new reducer to the store.

Using a 'Reducer Manager'

Another approach is to create a 'Reducer Manager' object, which keeps track of all the registered reducers and exposes a reduce() function. Consider the following example:

  1. export function createReducerManager(initialReducers) {
  2. // Create an object which maps keys to reducers
  3. const reducers = { ...initialReducers }
  4. // Create the initial combinedReducer
  5. let combinedReducer = combineReducers(reducers)
  6. // An array which is used to delete state keys when reducers are removed
  7. let keysToRemove = []
  8. return {
  9. getReducerMap: () => reducers,
  10. // The root reducer function exposed by this object
  11. // This will be passed to the store
  12. reduce: (state, action) => {
  13. // If any reducers have been removed, clean up their state first
  14. if (keysToRemove.length > 0) {
  15. state = { ...state }
  16. for (let key of keysToRemove) {
  17. delete state[key]
  18. }
  19. keysToRemove = []
  20. }
  21. // Delegate to the combined reducer
  22. return combinedReducer(state, action)
  23. },
  24. // Adds a new reducer with the specified key
  25. add: (key, reducer) => {
  26. if (!key || reducers[key]) {
  27. return
  28. }
  29. // Add the reducer to the reducer mapping
  30. reducers[key] = reducer
  31. // Generate a new combined reducer
  32. combinedReducer = combineReducers(reducers)
  33. },
  34. // Removes a reducer with the specified key
  35. remove: key => {
  36. if (!key || !reducers[key]) {
  37. return
  38. }
  39. // Remove it from the reducer mapping
  40. delete reducers[key]
  41. // Add the key to the list of keys to clean up
  42. keysToRemove.push(key)
  43. // Generate a new combined reducer
  44. combinedReducer = combineReducers(reducers)
  45. }
  46. }
  47. }
  48. const staticReducers = {
  49. users: usersReducer,
  50. posts: postsReducer
  51. }
  52. export function configureStore(initialState) {
  53. const reducerManager = createReducerManager(staticReducers)
  54. // Create a store with the root reducer function being the one exposed by the manager.
  55. const store = createStore(reducerManager.reduce, initialState)
  56. // Optional: Put the reducer manager on the store so it is easily accessible
  57. store.reducerManager = reducerManager
  58. }

To add a new reducer, one can now call store.reducerManager.add("asyncState", asyncReducer).

To remove a reducer, one can now call store.reducerManager.remove("asyncState")

Libraries and Frameworks

There are a few good libraries out there that can help you add the above functionality automatically:

  • redux-dynostore:Provides tools for building dynamic Redux stores, including dynamically adding reducers and sagas, and React bindings to help you add in association with components.
  • redux-dynamic-modules:This library introduces the concept of a 'Redux Module', which is a bundle of Redux artifacts (reducers, middleware) that should be dynamically loaded. It also exposes a React higher-order component to load 'modules' when areas of the application come online. Additionally, it has integrations with libraries like redux-thunk and redux-saga which also help dynamically load their artifacts (thunks, sagas).
  • Redux Ecosystem Links: Reducers - Dynamic Reducer Injection