Component-centric code splitting and loading in React
When you have a large enough application, a single large bundle with all of your code becomes a problem for startup time. You need to start breaking your app into separate bundles and load them dynamically when needed.
How to split a single bundle into multiple is a well solved problem with tools like Browserify and Webpack.
But now you need to find places in your application where you can decide to split off into another bundle and load it asynchronously. You also need a way to communicate in your app when something is loading.
A common piece of advice you will see is to break your app into separate routes and load each one asynchronously. This seems to work well enough for most apps, clicking on a link and loading a new page is not a terrible experience.
But we can do better than that.
Using most routing tools for React, a route is simply a component. There's nothing particularly special about them. So what if we optimized around components instead of delegating that responsibility to routes? What would that buy us?
It turns out quite a lot. There are many more places than just routes where you can pretty easily split apart your app. Modals, tabs, and many more UI components hide content until the user has done something to reveal it.
Not to mention all the places where you can defer loading content until higher priority content is finished loading. That component at the very bottom of your page which loads a bunch of libraries: Why does that need to be loaded at the same time as the content near the top?
You can still easily split on routes too since they are simply components. Just do whatever is best for your app.
But we need to make splitting up at the component-level as easy as splitting at the route-level. To split in a new place should be as easy as changing a few lines of app code and everything else is automatic.
React Loadable is a small library I wrote to make component-centric code splitting easier in React.
Loadable is a higher-order component (a function that creates a component) which makes it easy to split up bundles on a component level.
Let's imagine two components, one that imports and renders another.
import AnotherComponent from './another-component';
class MyComponent extends React.Component {
render() {
return <AnotherComponent/>;
}
}
Right now we are depending on AnotherComponent
being imported
synchronously via import
. We need a way to make it loaded
asynchronously.
Using a dynamic import
(a tc39 proposal
currently at stage 3) we can modify our component to load
AnotherComponent
asynchronously.
class MyComponent extends React.Component {
state = {
AnotherComponent: null
};
componentWillMount() {
import('./another-component').then(AnotherComponent => {
this.setState({ AnotherComponent });
});
}
render() {
let {AnotherComponent} = this.state;
if (!AnotherComponent) {
return <div>Loading...</div>;
} else {
return <AnotherComponent/>;
};
}
}
However, this is a bunch of manual work, and it doesn't even handle a lot of
different cases. What about when the import()
fails? What about
server-side rendering?
Instead you can use Loadable
to abstract away the problem. Using
Loadable is simple. All you need to do is pass in a function which loads your
component and a "Loading" component to show while your component loads.
import Loadable from 'react-loadable';
function MyLoadingComponent() {
return <div>Loading...</div>;
}
const LoadableAnotherComponent = Loadable({
loader: () => import('./another-component'),
LoadingComponent: MyLoadingComponent
});
class MyComponent extends React.Component {
render() {
return <LoadableAnotherComponent/>;
}
}
But what if the component fails to load? We need to also have an error state.
In order to give you maximum control over what gets displayed when, the
error
will simply be passed to your
LoadingComponent
as a prop.
function MyLoadingComponent({ error }) {
if (error) {
return <div>Error!</div>;
} else {
return <div>Loading...</div>;
}
}
import()
The great things about import()
is that Webpack 2 can actually
automatically split your code for you whenever you add a new one without any
additional work.
This means that you can easily experiment with new code splitting points just
by switching to import()
and using React Loadable. Figure out
what performs best on your app.
You can see an example project here. Or read the Webpack 2 docs (Note: some of the relevant docs are also in the require.ensure() section).
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
.
export default function MyLoadingComponent({ error, pastDelay }) {
if (error) {
return <div>Error!</div>;
} else if (pastDelay) {
return <div>Loading...</div>;
} else {
return null;
}
}
This delay
defaults to 200ms
but you can also
customize the delay using a third argument to Loadable
.
Loadable({
loader: () => import('./another-component'),
LoadingComponent: MyLoadingComponent,
delay: 300
});
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 clicked you could start preloading the component when the user hovers over the button.
The component created by Loadable
exposes a preload
static method which does exactly this.
let LoadableMyComponent = Loadable({
loader: () => import('./another-component'),
LoadingComponent: MyLoadingComponent,
});
class MyComponent extends React.Component {
state = { showComponent: false };
onClick = () => {
this.setState({ showComponent: true });
};
onMouseOver = () => {
LoadableMyComponent.preload();
};
render() {
return (
<div>
<button
onClick={this.onClick}
onMouseOver={this.onMouseOver}>
Show loadable component
</button>
{this.state.showComponent && <LoadableMyComponent/>}
</div>
)
}
}
Loader also supports server-side rendering through one final argument.
Passing the exact path to the module you are loading dynamically allows
Loader to require()
it synchronously when running on the server.
import path from 'path';
const LoadableAnotherComponent = Loadable({
loader: () => import('./another-component'),
LoadingComponent: MyLoadingComponent,
delay: 200,
serverSideRequirePath: path.join(__dirname, './another-component')
});
This means that your async-loaded code-splitted bundles can render synchronously server-side.
The problem then comes with picking back up on the client. We can render the application in full on the server-side but then on the client we need to load in bundles one at a time.
But what if we could figure out which bundles were needed as part of the server-side bundling process? Then we could ship those bundles to the client all at once and the client picks up in the exact state the server rendered.
You can actually get really close to this today.
Because we have the all the paths for server-side requires in
Loadable
, we can add a new flushServerSideRequires
function that returns all the paths that ended up getting rendered
server-side. Then using webpack --json
we can match together the
files with the bundles they ended up in
(
You can see my code here).
The only remaining issue is to get Webpack playing nicely on the client. I'll be waiting for your message after I publish this Sean.
There's all sorts of cool shit we could build once this all integrates nicely. React Fiber will enable us to be even smarter about which bundles we want to ship immediately and which ones we want to defer until higher priority work is complete.
In closing, please install this shit and give me a star on the repo.
yarn add react-loadable
# or
npm install --save react-loadable