react-code-split
v1.0.0
Published
A higher order component for loading components with promises
Downloads
495
Readme
A higher order component for loading components with dynamic imports.
Install
npm install react-code-split
Example
import CodeSplit from 'react-code-split';
import Loading from './my-loading-component';
const CodeSplitComponent = CodeSplit({
loader: () => import('./my-component'),
loading: Loading,
});
export default class App extends React.Component {
render() {
return <CodeSplitComponent/>;
}
}
Introducing React CodeSplit
React CodeSplit is a small library that makes component-centric code splitting incredibly easy in React.
CodeSplit
is a higher-order component (a function that creates a component)
which lets you dynamically load any module before rendering it into your app.
Let's imagine two components, one that imports and renders another.
import Bar from './components/Bar';
class Foo extends React.Component {
render() {
return <Bar/>;
}
}
Right now we're depending on Bar
being imported synchronously via import
,
but we don't need it until we go to render it. So why don't we just defer that?
Using a dynamic import (a tc39 proposal currently at Stage 3)
we can modify our component to load Bar
asynchronously.
class MyComponent extends React.Component {
state = {
Bar: null
};
componentWillMount() {
import('./components/Bar').then(Bar => {
this.setState({ Bar: Bar.default });
});
}
render() {
let {Bar} = this.state;
if (!Bar) {
return <div>Loading...</div>;
} else {
return <Bar/>;
};
}
}
But that's a whole bunch of work, and it doesn't even handle a bunch of cases.
What about when import()
fails? What about server-side rendering?
Instead you can use CodeSplit
to abstract away the problem.
import CodeSplit from 'react-code-split';
const CodeSplitBar = CodeSplit({
loader: () => import('./components/Bar'),
loading() {
return <div>Loading...</div>
}
});
class MyComponent extends React.Component {
render() {
return <CodeSplitBar/>;
}
}
Automatic code-splitting on import()
When you use import()
with Webpack 2+, it will
automatically code-split for
you with no additional configuration.
This means that you can easily experiment with new code splitting points just
by switching to import()
and using React CodeSplit. Figure out what performs
best for your app.
Creating a great "Loading..." Component
Rendering a static "Loading..." doesn't communicate enough to the user. You also need to think about error states, timeouts, and making it a nice experience.
function Loading() {
return <div>Loading...</div>;
}
CodeSplit({
loader: () => import('./WillFailToLoad'), // oh no!
loading: Loading,
});
To make this all nice, your loading component receives a couple different props.
Loading error states
When your loader
fails, your loading component
will receive an error
prop which will be an Error
object (otherwise it
will be null
).
function Loading(props) {
if (props.error) {
return <div>Error! <button onClick={ props.retry }>Retry</button></div>;
} else {
return <div>Loading...</div>;
}
}
Avoiding Flash Of Loading Component
Sometimes components load really quickly (<200ms) and the loading screen only quickly flashes on the screen.
A number of user studies have proven that this causes users to perceive things taking longer than they really have. If you don't show anything, users perceive it as being faster.
So your loading component will also get a pastDelay
prop
which will only be true once the component has taken longer to load than a set
delay.
function Loading(props) {
if (props.error) {
return <div>Error! <button onClick={ props.retry }>Retry</button></div>;
} else if (props.pastDelay) {
return <div>Loading...</div>;
} else {
return null;
}
}
This delay defaults to 200ms
but you can also customize the
delay in CodeSplit
.
CodeSplit({
loader: () => import('./components/Bar'),
loading: Loading,
delay: 300, // 0.3 seconds
});
Timing out when the loader
is taking too long
Sometimes network connections suck and never resolve or fail, they just hang there forever. This sucks for the user because they won't know if it should always take this long, or if they should try refreshing.
The loading component will receive a
timedOut
prop which will be set to true
when the
loader
has timed out.
function Loading(props) {
if (props.error) {
return <div>Error! <button onClick={ props.retry }>Retry</button></div>;
} else if (props.timedOut) {
return <div>Taking a long time... <button onClick={ props.retry }>Retry</button></div>;
} else if (props.pastDelay) {
return <div>Loading...</div>;
} else {
return null;
}
}
However, this feature is disabled by default. To turn it on, you can pass a
timeout
option to CodeSplit
.
CodeSplit({
loader: () => import('./components/Bar'),
loading: Loading,
timeout: 10000, // 10 seconds
});
Customizing rendering
By default CodeSplit
will render the default
export of the returned module.
If you want to customize this behavior you can use the
render
option.
CodeSplit({
loader: () => import('./my-component'),
render(loaded, props) {
let Component = loaded.namedExport;
return <Component {...props}/>;
}
});
Loading multiple resources
Technically you can do whatever you want within loader()
as long as it
returns a promise and you're able to render something.
But writing it out can be a bit annoying.
To make it easier to load multiple resources in parallel, you can use
CodeSplit.Map
.
CodeSplit.Map({
loader: {
Bar: () => import('./Bar'),
i18n: () => fetch('./i18n/bar.json').then(res => res.json()),
},
render(loaded, props) {
let Bar = loaded.Bar.default;
let i18n = loaded.i18n;
return <Bar {...props} i18n={i18n}/>;
},
});
When using CodeSplit.Map
the render()
method is required. It
will be passed a loaded
param which will be an object matching the shape of
your loader
.
Preloading
As an optimization, you can also decide to preload a component before it gets rendered.
For example, if you need to load a new component when a button gets pressed, you could start preloading the component when the user hovers over the button.
The component created by CodeSplit
exposes a
static preload
method which does exactly this.
const CodeSplitBar = CodeSplit({
loader: () => import('./Bar'),
loading: Loading,
});
class MyComponent extends React.Component {
state = { showBar: false };
onClick = () => {
this.setState({ showBar: true });
};
onMouseOver = () => {
CodeSplit.preload();
};
render() {
return (
<div>
<button
onClick={this.onClick}
onMouseOver={this.onMouseOver}>
Show Bar
</button>
{this.state.showBar && <CodeSplitBar/>}
</div>
)
}
}
When you go to render all these dynamically loaded components, what you'll get is a whole bunch of loading screens.
This really sucks, but the good news is that React CodeSplit is designed to make server-side rendering work as if nothing is being loaded dynamically.
Here's our starting server using Express.
import express from 'express';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import App from './components/App';
const app = express();
app.get('/', (req, res) => {
res.send(`
<!doctype html>
<html lang="en">
<head>...</head>
<body>
<div id="app">${ReactDOMServer.renderToString(<App/>)}</div>
<script src="/dist/main.js"></script>
</body>
</html>
`);
});
app.listen(3000, () => {
console.log('Running on http://localhost:3000/');
});
Preloading all your loadable components on the server
The first step to rendering the correct content from the server is to make sure that all of your loadable components are already loaded when you go to render them.
To do this, you can use the CodeSplit.preloadAll
method. It returns a promise that will resolve when all your loadable
components are ready.
CodeSplit.preloadAll().then(() => {
app.listen(3000, () => {
console.log('Running on http://localhost:3000/');
});
});
Picking up a server-side rendered app on the client
This is where things get a little bit tricky. So let's prepare ourselves little bit.
In order for us to pick up what was rendered from the server we need to have all the same code that was used to render on the server.
To do this, we first need our loadable components telling us which modules they are rendering.
Declaring which modules are being loaded
There are two options in CodeSplit
and
CodeSplit.Map
which are used to tell us which modules our
component is trying to load: opts.modules
and
opts.webpack
.
CodeSplit({
loader: () => import('./Bar'),
modules: ['./Bar'],
webpack: () => [require.resolveWeak('./Bar')],
});
But don't worry too much about these options. React CodeSplit includes a Babel plugin to add them for you.
Just add the react-code-split/babel
plugin to your Babel config:
{
"plugins": [
"react-code-split/babel"
]
}
Now these options will automatically be provided.
Finding out which dynamic modules were rendered
Next we need to find out which modules were actually rendered when a request comes in.
For this, there is CodeSplit.Capture
component which can
be used to collect all the modules that were rendered.
import CodeSplit from 'react-code-split';
app.get('/', (req, res) => {
let modules = [];
let html = ReactDOMServer.renderToString(
<CodeSplit.Capture report={moduleName => modules.push(moduleName)}>
<App/>
</CodeSplit.Capture>
);
console.log(modules);
res.send(`...${html}...`);
});
Mapping loaded modules to bundles
In order to make sure that the client loads all the modules that were rendered server-side, we'll need to map them to the bundles that Webpack created.
This comes in two parts.
First we need Webpack to tell us which bundles each module lives inside. For this there is the React CodeSplit Webpack plugin.
Import the ReactCodeSplitPlugin
from react-code-split/webpack
and include it
in your webpack config. Pass it a filename
for where to store the JSON data
about our bundles.
// webpack.config.js
import { ReactCodeSplitPlugin } from 'react-code-split/webpack';
export default {
plugins: [
new ReactCodeSplitPlugin({
filename: './dist/react-code-split.json',
}),
],
};
Then we'll go back to our server and use this data to convert our modules to bundles.
To convert from modules to bundles, import the getBundles
method from react-code-split/webpack
and the data from Webpack.
import CodeSplit from 'react-code-split';
import { getBundles } from 'react-code-split/webpack'
import stats from './dist/react-code-split.json';
app.get('/', (req, res) => {
let modules = [];
let html = ReactDOMServer.renderToString(
<CodeSplit.Capture report={moduleName => modules.push(moduleName)}>
<App/>
</CodeSplit.Capture>
);
let bundles = getBundles(stats, modules);
// ...
});
We can then render these bundles into <script>
tags in our HTML.
It is important that the bundles are included before the main bundle, so that they can be loaded by the browser prior to the app rendering.
However, as the Webpack manifest (including the logic for parsing bundles) lives in the main bundle, it will need to be extracted into its own chunk.
This is easy to do with the CommonsChunkPlugin
// webpack.config.js
export default {
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest',
minChunks: Infinity
})
]
}
Notice: As of Webpack 4 the CommonsChunkPlugin has been removed and the manifest doesn't need to be extracted anymore.
let bundles = getBundles(stats, modules);
res.send(`
<!doctype html>
<html lang="en">
<head>...</head>
<body>
<div id="app">${html}</div>
<script src="/dist/manifest.js"></script>
<script src="/dist/main.js"></script>
${bundles.map(bundle => {
return `<script src="/dist/${bundle.file}"></script>`
// alternatively if you are using publicPath option in webpack config
// you can use the publicPath value from bundle, e.g:
// return `<script src="${bundle.publicPath}"></script>`
}).join('\n')}
<script>window.main();</script>
</body>
</html>
`);
Preloading ready loadable components on the client
We can use the CodeSplit.preloadReady()
method on the
client to preload the loadable components that were included on the page.
Like CodeSplit.preloadAll()
, it returns a promise,
which on resolution means that we can hydrate our app.
// src/entry.js
import React from 'react';
import ReactDOM from 'react-dom';
import CodeSplit from 'react-code-split';
import App from './components/App';
window.main = () => {
CodeSplit.preloadReady().then(() => {
ReactDOM.hydrate(<App/>, document.getElementById('app'));
});
};
CodeSplit
A higher-order component for dynamically loading a module before rendering it, a loading component is rendered while the module is unavailable.
const CodeSplitComponent = CodeSplit({
loader: () => import('./Bar'),
loading: Loading,
delay: 200,
timeout: 10000,
});
This returns a CodeSplitComponent.
CodeSplit.Map
A higher-order component that allows you to load multiple resources in parallel.
CodeSplit.Map's opts.loader
accepts an object of functions, and
needs a opts.render
method.
CodeSplit.Map({
loader: {
Bar: () => import('./Bar'),
i18n: () => fetch('./i18n/bar.json').then(res => res.json()),
},
render(loaded, props) {
let Bar = loaded.Bar.default;
let i18n = loaded.i18n;
return <Bar {...props} i18n={i18n}/>;
}
});
When using CodeSplit.Map
the render()
method's loaded
param will be an
object with the same shape as your loader
.
CodeSplit
and CodeSplit.Map
Options
opts.loader
A function returning a promise that loads your module.
CodeSplit({
loader: () => import('./Bar'),
});
When using with CodeSplit.Map
this accepts an object of these
types of functions.
CodeSplit.Map({
loader: {
Bar: () => import('./Bar'),
i18n: () => fetch('./i18n/bar.json').then(res => res.json()),
},
});
When using with CodeSplit.Map
you'll also need to pass a
opts.render
function.
opts.loading
A LoadingComponent
that renders while a module is
loading or when it errors.
CodeSplit({
loading: LoadingComponent,
});
This option is required, if you don't want to render anything, return null
.
CodeSplit({
loading: () => null,
});
opts.delay
Time to wait (in milliseconds) before passing
props.pastDelay
to your loading
component. This defaults to 200
.
CodeSplit({
delay: 200
});
opts.timeout
Time to wait (in milliseconds) before passing
props.timedOut
to your loading
component.
This is turned off by default.
CodeSplit({
timeout: 10000
});
opts.render
A function to customize the rendering of loaded modules.
Receives loaded
which is the resolved value of opts.loader
and props
which are the props passed to the
CodeSplitComponent
.
CodeSplit({
render(loaded, props) {
let Component = loaded.default;
return <Component {...props}/>;
}
});
opts.webpack
An optional function which returns an array of Webpack module ids which you can
get with require.resolveWeak
.
CodeSplit({
loader: () => import('./Foo'),
webpack: () => [require.resolveWeak('./Foo')],
});
This option can be automated with the Babel Plugin.
opts.modules
An optional array with module paths for your imports.
CodeSplit({
loader: () => import('./my-component'),
modules: ['./my-component'],
});
This option can be automated with the Babel Plugin.
CodeSplitComponent
This is the component returned by CodeSplit
and CodeSplit.Map
.
const CodeSplitComponent = CodeSplit({
// ...
});
Props passed to this component will be passed straight through to the
dynamically loaded component via opts.render
.
CodeSplitComponent.preload()
This is a static method on CodeSplitComponent
which can
be used to load the component ahead of time.
const CodeSplitComponent = CodeSplit({...});
CodeSplitComponent.preload();
This returns a promise, but you should avoid waiting for that promise to resolve to update your UI. In most cases it creates a bad user experience.
LoadingComponent
This is the component you pass to opts.loading
.
function LoadingComponent(props) {
if (props.error) {
// When the loader has errored
return <div>Error! <button onClick={ props.retry }>Retry</button></div>;
} else if (props.timedOut) {
// When the loader has taken longer than the timeout
return <div>Taking a long time... <button onClick={ props.retry }>Retry</button></div>;
} else if (props.pastDelay) {
// When the loader has taken longer than the delay
return <div>Loading...</div>;
} else {
// When the loader has just started
return null;
}
}
CodeSplit({
loading: LoadingComponent,
});
Read more about loading components
props.error
An Error
object passed to LoadingComponent
when the
loader
has failed. When there is no error, null
is
passed.
function LoadingComponent(props) {
if (props.error) {
return <div>Error!</div>;
} else {
return <div>Loading...</div>;
}
}
props.retry
A function prop passed to LoadingComponent
when the
loader
has failed, used to retry loading the component.
function LoadingComponent(props) {
if (props.error) {
return <div>Error! <button onClick={ props.retry }>Retry</button></div>;
} else {
return <div>Loading...</div>;
}
}
props.timedOut
A boolean prop passed to LoadingComponent
after a set
timeout
.
function LoadingComponent(props) {
if (props.timedOut) {
return <div>Taking a long time...</div>;
} else {
return <div>Loading...</div>;
}
}
props.pastDelay
A boolean prop passed to LoadingComponent
after a set
delay
.
function LoadingComponent(props) {
if (props.pastDelay) {
return <div>Loading...</div>;
} else {
return null;
}
}
CodeSplit.preloadAll()
This will call all of the
CodeSplitComponent.preload
methods recursively
until they are all resolved. Allowing you to preload all of your dynamic
modules in environments like the server.
CodeSplit.preloadAll().then(() => {
app.listen(3000, () => {
console.log('Running on http://localhost:3000/');
});
});
It's important to note that this requires that you declare all of your loadable components when modules are initialized rather than when your app is being rendered.
Good:
// During module initialization...
const CodeSplitComponent = CodeSplit({...});
class MyComponent extends React.Component {
componentDidMount() {
// ...
}
}
Bad:
// ...
class MyComponent extends React.Component {
componentDidMount() {
// During app render...
const CodeSplitComponent = CodeSplit({...});
}
}
Note:
CodeSplit.preloadAll()
will not work if you have more than one copy ofreact-code-split
in your app.
Read more about preloading on the server.
CodeSplit.preloadReady()
Check for modules that are already loaded in the browser and call the matching
CodeSplitComponent.preload
methods.
CodeSplit.preloadReady().then(() => {
ReactDOM.hydrate(<App/>, document.getElementById('app'));
});
Read more about preloading on the client.
CodeSplit.Capture
A component for reporting which modules were rendered.
Accepts a report
prop which is called for every moduleName
that is
rendered via React CodeSplit.
let modules = [];
let html = ReactDOMServer.renderToString(
<CodeSplit.Capture report={moduleName => modules.push(moduleName)}>
<App/>
</CodeSplit.Capture>
);
console.log(modules);
Read more about capturing rendered modules.
Babel Plugin
Providing opts.webpack
and opts.modules
for
every loadable component is a lot of manual work to remember to do.
Instead you can add the Babel plugin to your config and it will automate it for you:
{
"plugins": ["react-code-split/babel"]
}
Input
import CodeSplit from 'react-code-split';
const CodeSplitMyComponent = CodeSplit({
loader: () => import('./MyComponent'),
});
const CodeSplitComponents = CodeSplit.Map({
loader: {
One: () => import('./One'),
Two: () => import('./Two'),
},
});
Output
import CodeSplit from 'react-code-split';
import path from 'path';
const CodeSplitMyComponent = CodeSplit({
loader: () => import('./MyComponent'),
webpack: () => [require.resolveWeak('./MyComponent')],
modules: [path.join(__dirname, './MyComponent')],
});
const CodeSplitComponents = CodeSplit.Map({
loader: {
One: () => import('./One'),
Two: () => import('./Two'),
},
webpack: () => [require.resolveWeak('./One'), require.resolveWeak('./Two')],
modules: [path.join(__dirname, './One'), path.join(__dirname, './Two')],
});
Read more about declaring modules.
Webpack Plugin
In order to send the right bundles down when rendering server-side, you'll need the React CodeSplit Webpack plugin to provide you with a mapping of modules to bundles.
// webpack.config.js
import { ReactCodeSplitPlugin } from 'react-code-split/webpack';
export default {
plugins: [
new ReactCodeSplitPlugin({
filename: './dist/react-code-split.json',
}),
],
};
This will create a file (opts.filename
) which you can import to map modules
to bundles.
Read more about mapping modules to bundles.
getBundles
A method exported by react-code-split/webpack
for converting modules to
bundles.
import { getBundles } from 'react-code-split/webpack';
let bundles = getBundles(stats, modules);
Read more about mapping modules to bundles.
How do I avoid repetition?
Specifying the same loading
component or delay
every time you use
CodeSplit()
gets repetitive fast. Instead you can wrap CodeSplit
with your
own Higher-Order Component (HOC) to set default options.
import CodeSplit from 'react-code-split';
import Loading from './my-loading-component';
export default function MyCodeSplit(opts) {
return CodeSplit(Object.assign({
loading: Loading,
delay: 200,
timeout: 10000,
}, opts));
};
Then you can just specify a loader
when you go to use it.
import MyCodeSplit from './MyCodeSplit';
const CodeSplitMyComponent = MyCodeSplit({
loader: () => import('./MyComponent'),
});
export default class App extends React.Component {
render() {
return <CodeSplitMyComponent/>;
}
}
Unfortunately at the moment using wrapped CodeSplit breaks react-code-split/babel so in such case you have to add required properties (modules
, webpack
) manually.
import MyCodeSplit from './MyCodeSplit';
const CodeSplitMyComponent = MyCodeSplit({
loader: () => import('./MyComponent'),
modules: ['./MyComponent'],
webpack: () => [require.resolveWeak('./MyComponent')],
});
export default class App extends React.Component {
render() {
return <CodeSplitMyComponent/>;
}
}
How do I handle other styles .css
or sourcemaps .map
with server-side rendering?
When you call getBundles
, it may return file types other than
JavaScript depending on your Webpack configuration.
To handle this, you should manually filter down to the file extensions that you care about:
let bundles = getBundles(stats, modules);
let styles = bundles.filter(bundle => bundle.file.endsWith('.css'));
let scripts = bundles.filter(bundle => bundle.file.endsWith('.js'));
res.send(`
<!doctype html>
<html lang="en">
<head>
...
${styles.map(style => {
return `<link href="/dist/${style.file}" rel="stylesheet"/>`
}).join('\n')}
</head>
<body>
<div id="app">${html}</div>
<script src="/dist/main.js"></script>
${scripts.map(script => {
return `<script src="/dist/${script.file}"></script>`
}).join('\n')}
</body>
</html>
`);