If you're considering introducing code splitting or lazy loading to your React app, this article will give you a bird's-eye view of what's going on behind the scenes.
You may have heard of dynamic imports, React.lazy, Loadable Components and React Loadable. What libraries and/or language feature should you use, and are they mutually exclusive?
Dynamic imports
First, let's visit a fairly new JavaScript feature (which is technically still a proposal).
Dynamic
import()
is a function-like form of import
that lets you import a module on demand or
conditionally.
import("./utils")
.then((module) => {
module.default(); // The default export
module.doSomething(); // A named export
});
Since import()
returns a promise, you can use async/await
instead of
attaching callback functions (then()
):
let module = await import('./utils');
Webpack and dynamic import
Webpack supports dynamic imports out of the box. Consider this example:
// ./src/index.js
console.log("Our bundle has loaded.");
import("lodash").then(( { default: _ } ) => {
console.log("... and our additional chunk has loaded.")
_.merge(["a", "b"]);
})
Let's add a Webpack configuration that doesn't contain anything out of the ordinary:
// webpack.config.js
const path = require("path");
module.exports = {
mode: "development",
entry: "./src/index.js",
output: {
path: path.join(__dirname, "./dist");
filename: "[name].js",
},
};
Due to the dynamic import, bundling the code with Webpack will result in two chunks: your main bundle, and the dynamically imported lodash library.
main.js
vendors-node_modules_lodash_js.js
If you run main.js
in your browser you'll see that
vendors-node_modules_lodash_js.js
is fetched over the network after
main.js
has loaded.
Webpack adds its own runtime to your bundle which takes care of imports,
including dynamic ones. This runtime contains Webpack's own import()
and
export()
equivalents which piece together all of your code. Even so, the raw
unbundled code would work in modern browsers because dynamic imports are already
widely supported.
The chunk file names will be less verbose in production mode (mode: "production"
). Webpack provides sensible defaults based on performance best
practices, like adding a hash of the file's contents to the file name in order
to break the browser cache. But you're free to name your
chunks however you want.
Furthermore, create-react-app
provides its own configuration. You don't need to get your hands dirty with
Webpack.
React
The React documentation refers to dynamic imports and mentions that the feature
is supported by Webpack. You don't need a dedicated code-splitting or
lazy-loading library to split your bundle, even if you use React. The challenge,
however, is how to split React components. You can't simply add an import()
statement at the top level of your code—that would cause the component to be
loaded prematurely.
Consider this example which uses React Router:
import HomeScreen from "./screens/HomeScreen";
import AboutScreen from "./screens/AboutScreen";
const App = () => (
<Router>
<main>
<Switch>
<Route exact path=`/`>
<HomeScreen>
</Route>
<Route path=`/about`>
<AboutScreen>
</Route>
</Switch>
</main>
</Router>
);
There's no code splitting going on yet. We can't simply write
import("./HomeScreen").then((HomeScreen) => { ... })
, because then we'd have
to wait for the promise to settle before we render anything.
We don't want to load the components immediately, but clearly some kind of components need to written, imported and rendered. This is where the React code-splitting libraries come in.
First, take a look at this home-made implementation:
class AsyncHome extends React.Component {
constructor(props) {
super(props);
this.state = {
component: () => <div>Loading ...</div>,
};
}
componentDidMount() {
import("./screens/Home").then((Home) => {
this.setState({
component: Home.default,
});
});
}
render() {
const Component = this.state.component;
return <Component />;
}
}
If you put <AsyncHome>
in a parent component, <Home>
won't be loaded before
<AsyncHome>
renders and componentDidMount
is triggered. Using the routing
example above, this means that <Home>
will not load until the route matches
the path /
.
The approach works, but the implementation is crude and shouldn't be used in production.
Instead, use React.lazy or Loadable Components. These libraries give you all the advantages of code-splitting while taking care of the implementation details, loading states and various edge cases.
Conclusion: You probably want to use React.lazy, which is React's own code-splitting solution. If you need more functionality, or server-side rendering which isn't yet supported by React.lazy, use Loadable Components.
Finally, here's the routing example rewritten using React.lazy:
import { Suspense, lazy } from "react";
const HomeScreen = lazy(() => import("./screens/HomeScreen"));
const AboutScreen = lazy(() => import("./routes/AboutScreen"));
const App = () => (
<Router>
<Suspense fallback={<div>Loading ...</div>}>
<Switch>
<Route exact path=`/`>
<HomeScreen />
</Route>
<Route path=`/about`>
<AboutScreen>
</Route>
</Switch>
</Suspense>
</Router>
);