Blog / Development

Redux store code splitting with sagas

One not-so-weird trick to remove 300K from your main Javascript bundle

Thu Nov 11 2021

At scite we recently put some engineering effort into reducing our Javascript bundle size.

Early on we were able to make some headway both by reducing our reliance on 3rd party libraries and even refactoring some of the more boilerplate heavy parts of the code into re-useable functions. We found pretty quickly, however, that we were hitting up against a wall: despite splitting our application by route, there was a lot of code being pulled into the main bundle that could, in theory, be split out into other chunks.

At first we were able to make some easy cuts by being more careful about when and where we import library code (shifting imports to the view layer, lazy loading certain libraries when needed etc), but this process quickly revealed an architectural bottleneck that I am sure is pretty common: all of our Redux store and saga logic was imported centrally at Redux store creation. This meant that despite our app being split at the route level, all of this state related code and its dependents were being pulled into the main bundle.

In the early days of scite when we only really had two or three pages and not many 'app like' capabilities this wasn't so bad. It was not a particularly noticeable part of the main bundle, but as the site had grown so had our central store, and it had gotten to the point where we were pulling in 100s of kilobytes of Javascript that were likely entirely irrelevant to the page.

The (our) solution

The Redux documentation mentions that you can dynamically inject reducers using the built in replaceReducer function, and includes some related code examples. Similarly there are some examples online for loading new redux-sagas on the fly. Ultimately we had a few scenarios to think about:

  1. On the backend: when we render a route we dispatch a list of actions it specifies and wait for any 'in progress' saga tasks to complete. This ensures that when the initial render is calculated it has the relevent state.
  2. On the frontend: when we render a component it can use useSelector (or older components mapStateToProps) to select/subscribe to redux state updates. Obviously components can also dispatch actions and expect them to be handled. We already have 100s of components and don't really want to comb through each one and make them less likely to blow if some state isn't present.
  3. Both frontend and backend: When we create the redux store we need to have a consistent initial state, both static and dynamic reducers/sagas.

To solve this we need to sort our reducers/sagas into two categories: 'async' and 'static'. Async reducers and their sagas are not required for all areas of the app, and are loaded asyncronously when they are needed. Static reducers and their sagas are imported at store creation and included in the main bundle.

First off we need to make some changes to store creation so that:

  1. We can specify async sagas/reducers to load at initialization if the specific page requires it.
  2. We can add more async sagas/reducers dynamically as the user navigates around the app. We augment the store with two functions to support this (one for reducers and one for sagas).
import {
    createStore as reduxCreateStore,
    applyMiddleware,
    combineReducers
} from 'redux'
import createSagaMiddleware from 'redux-saga'
import { staticReducers, staticSagas } from 'client/store'


//
// This simple function just lets us augment our
// static reducers with arbitrary async ones.
//
function createReducer (asyncReducers) {
  return combineReducers({
    ...staticReducers,
    ...asyncReducers
  })
}

//
// This returns a function that allows us to
// inject sagas onto an existing store instance
// we add it to the store object below once we've
// set up the saga middleware.
//
// The function also takes a unique key which ensures
// we don't accidentally inject the same saga twice.
//
function createSagaInjector (runSaga) {
  const injectedSagas = {}

  const injectSaga = (key, saga) => {
    if (injectedSagas[key]) return injectedSagas[key]

    const task = runSaga(saga)
    injectedSagas[key] = task
    return task
  }

  return injectSaga
}

export const createStore = (initialState = {}, extraReducers = {}) => {
  const sagaMiddleware = createSagaMiddleware()

  //
  // Add any other Redux middleware here too
  //
  const middleware = [sagaMiddleware]
  const store = reduxCreateStore(
    createReducer(extraReducers), initialState, applyMiddleware(...middleware)
  )

  //
  // Add a dictionary to keep track of the registered async reducers
  //
  store.asyncReducers = {}

  //
  // Add injectReducer function to created store object.
  // This lets you inject one or more store slices like:
  // 
  // injectReducers({ mySlice: myReducer })
  //
  // but will only actually add the reducer if it does
  // not already exist.
  //
  store.injectReducer = (asyncReducers) => {
    Object.keys(asyncReducers).forEach(key => {
      if (!store.asyncReducers[key]) {
        store.asyncReducers[key] = asyncReducers[key]
      }
    })
    store.replaceReducer(createReducer(store.asyncReducers))
  }

  //
  // Create/add the saga injector also
  //
  store.injectSaga = createSagaInjector(sagaMiddleware.run)

  //
  // Here we both inject and gather up the specified
  // sagas.
  //
  // This means that we can return them below which is useful
  // on the backend when rendering the page, since we want to
  // wait for running sagas to complete.
  //
  const rootTask = store.injectSaga('root', staticSagas)
  const asyncTasks = Object.entries(extraReducers)
    .filter(([_, storeSlice]) => storeSlice.saga)
    .map(([key, storeSlice]) => store.injectSaga(key, storeSlice.saga))

  return { store, sagaTasks: [rootTask, ...asyncTasks] }
}

The next step is to write an interface that lets us specify where we want these 'async' reducers and sagas to be injected. We settled on using a HOC component for this that delays rendering the wrapped component until the store slice has been loaded and injected, unless it is already present in which case we can just render the component as normal.

This HOC also re-exposes the injected stores as .asyncStores on the returned component. This means that if stores are injected by the root 'page-level' component we can access this at store creation and make sure the relevant slices are present for the initial render. This is important for two scenarios:

  1. We can render pages that depend on async reducers/sagas on the backend, since in the SSR flow there is only one render.
  2. We can ensure that the initial client render/ReactDOM.hydrate call is the same as the SSR render pass.
import { useEffect, useContext } from 'react'
import { ReactReduxContext, useSelector } from 'react-redux'
import RouteLoading from 'routes/ssr/RouteLoading'

const injectAsyncStore = stores => WrappedComponent => {
  const InjectReducer = (props) => {
    //
    // Get the Redux store from the React context
    //
    const { store } = useContext(ReactReduxContext)
    
    //
    // Use the normal Redux useSelector hook
    // to check if the specified store slices
    // are present or not.
    //
    const hasStores = useSelector(state => {
      const keys = Object.keys(stores)
      for (const key of keys) {
        if (!state[key]) {
          return false
        }
      }
      return true
    })

    //
    // Inject these stores on the initial render
    // with the normal React useEffect hook.
    //
    // Note that in our code base reducers and
    // sagas are bundled, so we can just get
    // the relevant root saga from the passed
    // reducer and re-use the same key.
    //
    useEffect(() => {
      store.injectReducers(stores)

      for (const [key, storeSlice] of Object.entries(stores)) {
        if (storeSlice.saga) {
          store.injectSaga(key, storeSlice.saga)
        }
      }
    }, [])


    //
    // If we are waiting for the stores slices return
    // a loading component/animation.
    //
    // Otherwise return the passed/wrapped component.
    //
    if (!hasStores) {
      return <RouteLoading />
    }

    return <WrappedComponent {...props} />
  }
  
  //
  // Cheekily attach the the specified store slices
  // to the returned HOC. See explanation above for why
  // we need this.
  //
  InjectReducer.asyncStores = stores
  return InjectReducer
}

export default injectAsyncStore

This HOC can be used when components are exported (or wherever you want... go nuts):

export default injectAsyncStore({
  mySlice: myReducer,
  myOtherSlice: myOtherReducer
})(MyComponentThatNeedsThem)

If you wanted to make it more flexible you could also allow for an arbitrary selection of sagas with a similar API. For us this single argument is enough as our sagas are imported and attached to their reducers, so we can just pass in one object as a shorthand for both.

Finally to tie it all together we need to fix up our two (backend and frontend) store creation points to make use of the new code:

On the backend:

//
// The 'page' returned by this function is the
// root component for the rendered page, so it is
// different depending on where you are in the app.
//
const { route, page } = await getPageRoute(req.path)
const getInitialActions = page?.getInitialActions || (() => ([]))
const asyncStores = page.default?.asyncStores || {}
const { store, sagaTasks } = createStore({}, asyncStores)

//
// This is included for clarity.
//
// We just get/dispatch an initial list of actions
// actions and wait for the saga tasks returned by
// our `createStore` function to complete.
//
try {
  const actions = getInitialActions({
    match: matchPath(req.path, route),
    req,
    res
  }, store.getState())
  actions.forEach(store.dispatch)
  store.dispatch(END)
  await Promise.all(sagaTasks.map(task => task.toPromise()))
} catch (e) {
  req.log.error(e, 'Could not bootstrap stores :(')
  return next(e)
}

On the frontend:

const { page } = await getPageRoute(history.location.pathname)
const initialAsyncReducers = page.default?.asyncStores || {}

//
// This initial store state is passed from the backend
// after the store creation shown above.
//
// We make sure to include the same async reducers/sagas,
// so the render will be exactly the same.
//
const initialStoreState = window.__SCITE_STORE_STATE
const { store } = createStore(initialStoreState, initialAsyncReducers)

That's pretty much it. The only remaining step is to sprinkle injectAsyncStore around the app and reap the rewards of a much slimmer main bundle.