This seems to be a tricky problem, but I have a solution which seems to work. It's not ideal and I would dearly like to see alternatives.
The basic idea is that one React component can trigger the import
of another in order to facilitate code splitting. This is reasonably simple, but extending this to support server side rendering added a lot of complexity.
The rules:
- Import must be synchronous on the server side as there is only a single render.
- Server side must be able to inform the client side which bundles are required for whatever view is being rendered by the server.
- Client must then load any bundles that the server informed it about, before React begins rendering.
- Client then can continue ordinary code-splitting practice from this point onwards. Bundles are loaded asynchronously and once loaded, React rerenders to include them in the rendering.
Here is the Lazy
class which is responsible for managing code splitting for the SplitComponent
. It makes use of 2 functions from split.js
When Lazy
is rendered on the server side, componentWillMount
is run and checks if it is actually the server side. If it is, it causes the loading of the SplitComponent
synchronously. The module default that is loaded is stored in the state of the Lazy
component so that it can then immediately be rendered. It also dispatches an action to Redux to register the fact that this bundle is required for the view that is being rendered.
The server side will successfully render the application and the redux store will contain the fact that the bundle containing ./SplitComponent
is required on the client side.
//Lazy.jsx
import React from 'react';
import { connect } from 'react-redux';
import { splitComponent, splitComponentSync } from './split';
const canUseDOM = !!(
(typeof window !== 'undefined' &&
window.document && window.document.createElement)
);
class Lazy extends React.Component {
constructor() {
super();
this.state = {
module: null
};
}
componentWillMount() {
// On server side only, synchronously load
const { dispatch } = this.props;
if (!canUseDOM) {
// Also, register this bundle with the current component state as on
// the server there is only a single render and thus the redux state
// available through mapStateToProps is not up-to-date because it was
// requested before the above dispatch.
this.setState({
module: splitComponentSync(dispatch)
});
}
}
componentDidMount() {
const { dispatch, modules } = this.props;
if (!modules.hasOwnProperty('./SplitComponent')) {
splitComponent(dispatch);
}
}
render() {
const { module } = this.state;
const { modules } = this.props;
// On server side, rely on everything being loaded
if (!canUseDOM && module) {
return React.createElement(module);
// On client side, use the redux store
} else if (modules.hasOwnProperty('./SplitComponent') && modules['./SplitComponent']) {
return React.createElement(modules['./SplitComponent']);
}
return null;
}
}
function mapStateToProps(state) {
const modules = state.modules;
return {
modules
};
}
export default connect(mapStateToProps)(Lazy);
//split.js
export const splitComponent = dispatch => {
return System.import('./SplitComponent').then((m) => {
dispatch({
type: 'MODULE_IMPORT',
moduleName: './SplitComponent',
module: m.default
});
});
};
export const splitComponentSync = dispatch => {
// This must be an expression or it will cause the System.import or
// require.ensure to not generate separate bundles
const NAME = './SplitComponent';
const m = require(NAME);
// Reduce into state so that the list of bundles that need to be loaded
// on the client can be, before the application renders. Set the module
// to null as this needs to be imported on the client explicitly before
// it can be used
dispatch({
type: 'MODULE_IMPORT',
moduleName: './SplitComponent',
module: null
});
// Also, register this bundle with the current component state as on
// the server there is only a single render and thus the redux state
// available through mapStateToProps is not up-to-date because it was
// requested before the above dispatch.
return m.default;
};
//reducer.js (Excerpt)
export function modules(
state={}, action) {
switch (action.type) {
case 'MODULE_IMPORT':
const newState = {
...state
};
newState[action.moduleName] = action.module;
return newState;
}
return state;
}
The client initialise as per the usual procedure for incorporating the redux store from server rendering.
Once that has happened, it is necessary to ensure that any required bundles are imported before rendering can begin. We examine the redux store modules
to see what is required. I look them up in a simple if statement here. For each bundle that is required, it is loaded asynchronously, it's module default stored in the redux store and a Promise returned. Once all those promises are resolved, then React will be allowed to render.
//configureStore.js (Excerpt)
let ps;
if (initialState && initialState.hasOwnProperty('modules')) {
ps = Object.keys(initialState.modules).map(m => {
if (m === './SplitComponent') {
return splitComponent(store.dispatch);
}
});
}
// My configureStore.js returns a Promise and React only renders once it has resolved
return Promise.all(ps).then(() => store);
Going forward, whenever Lazy
+SplitComponent
are used, no code loading is required because it already exists in the redux store.
In the case when the initial application did not include Lazy
+SplitComponent
, then at the point when Lazy
is rendered by React, componentDidMount
will fire an asynchronous action to import ./SplitComponent
and register this with redux. Like any redux action, this change in state will cause the Lazy
component to attempt to rerender and as the SplitComponent
is now loaded and registered, it can do so.