These days, to achieve optimal app load times when users visit our website, we are questioning every byte of code that is being transferred on the network.
Let's say a user is visiting the homepage of an e-commerce website (react & redux). To achieve the best time to interactive, the javascript bundle should only have the UI components needed to render above-the-fold part of the homepage. We shouldn't load the code of product list or checkout before visiting those pages.
To achieve this you can:
- lazy load routes - each route's UI components in on-demand bundles.
- lazy load the components below-the-fold of the page.
What about reducers?
Unlike components, the main bundle has all the reducers and not just the ones needed by homepage. The reasons why we couldn't do it were -
- The best practice is to keep the redux state tree flat - no parent-child relationships between reducers to create a code-split point.
- The module dependency trees of components and reducers are not same.
store.js -imports-> rootReducer.js -imports-> reducer.js(files)
so the store's dependency tree contains all the reducers of the app even if the data stored is used by a main component or an on-demand component. - Knowledge of which data is used in a component is business logic or at least isn't statically analyzable -
mapStateToProps
is a runtime function. - Redux store API doesn't support code-splitting out of the box and all the reducers need to be part of the rootReducer before creation of the store. But wait, during development, whenever I update my reducer code my store gets updated via webpack's Hot Module Replacement. How does that work? Yes, for that we re-create rootReducer and use store.replaceReducer API. It's not as straightforward as switching a single reducer or adding a new one.
Came across any unfamiliar concepts? Please refer to the links and description below to gain a basic understanding of redux, modules and webpack.
- Redux - a simple library to manage app state, core concepts, with react.
- Modules - Intro, es6 modules, dynamic import
- Dependency Tree - If
moduleB
is imported inmoduleA
, thenmoduleB
is a dependency ofmoduleA
and ifmoduleC
is imported inmoduleB
, then the resultant dependency tree is -moduleA -> moduleB -> moduleC
. Bundlers like webpack traverse this dependency tree to bundle the codebase. - Code-Splitting - When a parent module imports a child module using a dynamic import, webpack bundles the child module and its dependencies in a different build file that will be loaded by the client when the import call is run at runtime. Webpack traverses the modules in the codebase and generates bundles to be loaded by browser.
Now you are familiar with the above concepts, let's dive in.
Let's look at the typical structure of a react-redux app -
// rootReducer.js
export default combineReducers({
home: homeReducer,
productList: productListReducer
});
// store.js
export default createStore(rootReducer/* , initialState, enhancer */);
// Root.js
import store from './store';
import AppContainer from './AppContainer';
export default function Root() {
return (
<Provider store={store}>
<AppContainer />
</Provider>
);
}
First you create the rootReducer and redux store, then import the store into Root Component. This results in a dependency tree as shown below
RootComponent.js
|_store.js
| |_rootReducer.js
| |_homeReducer.js
| |_productListReducer.js
|_AppContainer.js
|_App.js
|_HomePageContainer.js
| |_HomePage.js
|_ProductListPageContainer.js
|_ProductListPage.js
Our goal is to merge the dependency trees of store and AppContainer -
So that when a component is code-split, webpack bundles this component and the corresponding reducer in the on-demand chunk. Let's see how the desired dependency tree may look like -
RootComponent.js
|_AppContainer.js
|_App.js
|_HomePageContainer.js
| |_HomePage.js
| |_homeReducer.js
|_ProductListPageContainer.js
|_ProductListPage.js
|_productListReducer.js
If you observe. you will notice that there is no store in the dependency tree!
In the above dependency tree
- Say
ProductListPageContainer
is dynamically imported inAppContainer
. Webpack now buildsproductListReducer
in the on-demand chunk and not in the main chunk. - Each reducer is now imported and registered on the store in a container.
Interesting! Now containers not only bind data & actions but reducers as well.
Now let's figure out how to achieve this!
Redux store expects a rootReducer
as the first argument of createStore
. With this limitation we need two things -
- Make containers bind reducers before creation of the
rootReducer
- A higher order entity that can hold the definitions of all the reducers to be present in the
rootReducer
before they are packaged into one.
So let's say we have a higher-order entity called storeManager which provides the following APIs
- sm.registerReducers()
- sm.createStore()
- sm.refreshStore()
Below is the refactored code & the dependency tree with storeManager
-
// HomePageContainer.js
import storeManager from 'react-store-manager';
import homeReducer from './homeReducer';
storeManager.registerReducers({ home: homeReducer });
export default connect(/* mapStateToProps, mapDispatchToProps */)(HomePage);
// ProductListPageContainer.js
import storeManager from 'react-store-manager';
import productListReducer from './productListReducer';
storeManager.registerReducers({ productList: productListReducer });
export default connect(/* mapStateToProps, mapDispatchToProps */)(ProductListPage);
// AppContainer.js
import storeManager from 'react-store-manager';
const HomeRoute = Loadable({
loader: import('./HomePageContainer'),
loading: () => <div>Loading...</div>
});
const ProductListRoute = Loadable({
loader: import('./ProductListPageContainer'),
loading: () => <div>Loading...</div>
});
function AppContainer({login}) {
return (
<App login={login}>
<Switch>
<Route exact path="/" component={HomeRoute} />
<Route exact path="/products" component={ProductListRoute} />
</Switch>
</App>
);
}
export default connect(/* mapStateToProps, mapDispatchToProps */)(AppContainer);
// Root.js
import storeManager from 'react-store-manager';
import AppContainer from './AppContainer';
export default function Root() {
return (
<Provider store={storeManager.createStore(/* initialState, enhancer */)}>
<AppContainer />
</Provider>
);
}
Reducers are just registered and Store is created when RootComponent is being mounted. Now this has the desired dependency tree
RootComponent.js
|_AppContainer.js
|_App.js
|_HomePageContainer.js
| |_HomePage.js
| |_homeReducer.js
|_ProductListPageContainer.js
|_ProductListPage.js
|_productListReducer.js
Now if ProductListPageContainer
is on-demand loaded using a dynamic import, productListReducer
is also moved inside the on-demand chunk.
Hurray! mission accomplished?… Almost
Problem is, when the on-demand chunk is loaded -
sm.registerReducers()
calls present in the on-demand chunk register the reducers on the storeManager but don't refresh the redux store with a new rootReducer
containing newly registered reducers. So to update the store's rootReducer we need to use redux's store.replaceReducer API.
So when a parent (AppContainer.js
) that is dynamically loading a child(ProductListPageContainer.js
), it simply has to do a sm.refreshStore()
call. So that store has productListReducer
, before ProductListPageContainer
can start accessing the data or trigger actions on, the productList
datapoint.
// AppContainer.js
import {withRefreshedStore} from 'react-store-manager';
const HomeRoute = Loadable({
loader: withRefreshedStore(import('./HomePageContainer')),
loading: () => <div>Loading...</div>
});
const ProductListRoute = Loadable({
loader: withRefreshedStore(import('./ProductListPageContainer')),
loading: () => <div>Loading...</div>
});
function AppContainer({login}) {
return (
<App login={login}>
<Switch>
<Route exact path="/" component={HomeRoute} />
<Route exact path="/products" component={ProductListRoute} />
</Switch>
</App>
);
}
We saw how storeManager
helps achieve our goals. Let's implement it -
import { createStore, combineReducers } from 'redux';
const reduceReducers = (reducers) => (state, action) =>
reducers.reduce((result, reducer) => (
reducer(result, action)
), state);
export const storeManager = {
store: null,
reducerMap: {},
registerReducers(reducerMap) {
Object.entries(reducerMap).forEach(([name, reducer]) => {
if (!this.reducerMap[name]) this.reducerMap[name] = [];
this.reducerMap[name].push(reducer);
});
},
createRootReducer() {
return (
combineReducers(Object.keys(this.reducerMap).reduce((result, key) => Object.assign(result, {
[key]: reduceReducers(this.reducerMap[key]),
}), {}))
);
},
createStore(...args) {
this.store = createStore(this.createRootReducer(), ...args);
return this.store;
},
refreshStore() {
this.store.replaceReducer(this.createRootReducer());
},
};
export const withRefreshedStore = (importPromise) => (
importPromise
.then((module) => {
storeManager.refreshStore();
return module;
},
(error) => {
throw error;
})
);
export default storeManager;
You can use the above snippet as a module in your codebase or use the npm package listed below -
sagiavinash / redux-store-manager
Declaratively code-split your redux store and make containers own entire redux flow using redux-store-manager
redux-store-manager
Declaratively code-split your redux store and make containers own entire redux flow using redux-store-manager
Installation
yarn add redux-store-manager
Problem
- rootReducer is traditionally created manually using combineReducers and this makes code-splitting reducers based on how widgets consuming their data are loaded(whether they are in the main bundle or on-demand bundles) hard.
- Bundler cant tree-shake or dead code eliminate the rootReducer to not include reducers whose data is not consumed by any container components
Solution
- Let the containers that are going to consume the data stored by a reducer and trigger actions take responsibility of adding a reducer to the store
This makes the container owning the entire redux flow by linking
- Actions as component props via mapDispatchToProps
- Reducer responsible for updating the data via storeManager.registerReduers
- Data as component props via mapStateToProps
- Use the redux store's replaceReducer API whatever reducers are registered when an on-demand chunk loads the store gets refreshed…
Say hello to an untapped area of build optimizations :)
Like the concept? - Please share the article and star the git repo :)
Top comments (7)
Hey Sagi great work on lazy loading reducers!
What are the benfits / performance gains that you are seeing in a production app? I ask this because I believe reducers are generally light weight and donot have a huge dependency tree that could benefit from lazy loading.
Actually my production app is a buisiness intelligence app. It involves lot of data stitching happening inside the reducer from multiple api calls. And I try to keep the data flat by normalizing the api responses. So for my usecase i need towrite lot of data transformation utilities.
If your app has so many routes then you should think of code splitting the redux dtore sine the total size contribution from sll routes would be huge
Sounds solid. Will look through for future projects!
So I'm trying to implement your example within my own project and the webpack complier is giving me an error on the individual import statements for my ClientContainer and the CategoryContainer in the AppContainer.
loader: withRefreshedStore(import('./ClientContainer'))
Everything is implemented just as you laid out in this example, just altered for my app. Any ideas?
Great article Sagi, we recently opensourced our library to help in code splitting react redux apps, it is built on similar principels, please try it out.
github.com/Microsoft/redux-dynamic...
Very cool Sagi! thanks a lot for sharing this!
In the example you are importing the storeManager in AppContainer but not using it.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.