Skip to main content

Route-based Code Splitting with `React.lazy` (Part 2) #131

📖 Introduction

Following our initial exploration of Route-based Code Splitting with React.lazy (Part 1), where we learned the basics of React.lazy and Suspense, this article dives deeper. We'll cover more advanced scenarios, including error handling for failed chunk loads, using React.lazy with named exports, strategically placing Suspense components, and the concept of preloading components for an even smoother user experience.


📚 Prerequisites

Before we begin, please ensure you have a solid grasp of:

  • Basic implementation of React.lazy and Suspense (covered in Part 1).
  • React Error Boundaries (conceptual understanding is helpful, or refer to Series 18).
  • JavaScript Promises and the import() function.
  • React Router setup.

🎯 Article Outline: What You'll Master

In this article, you will learn:

  • Error Handling: Using Error Boundaries to catch errors if a lazy component fails to load.
  • Named Exports: How to use React.lazy with components that are named exports (not default exports).
  • Suspense Placement: Best practices for placing Suspense components for optimal UX and granular loading states.
  • Preloading Components: Techniques to start loading code for a route before the user navigates to it.
  • Real-world Considerations: Discussing trade-offs and common patterns.

🧠 Section 1: Handling Loading Errors with Error Boundaries

Network errors can happen. What if a lazy component's chunk fails to load? By default, this will cause a runtime error that can crash your React application. To gracefully handle such scenarios, you can use Error Boundaries.

An Error Boundary is a React component that catches JavaScript errors anywhere in its child component tree, logs those errors, and displays a fallback UI instead of the component tree that crashed.

1.1 - Creating a Simple Error Boundary

Here's a basic example of an Error Boundary component (typically a class component, as functional components with hooks don't yet have a direct equivalent for getDerivedStateFromError or componentDidCatch for this specific use case):

// src/components/ErrorBoundary.jsx
import React, { Component } from 'react';

class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}

static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI.
return { hasError: true, error };
}

componentDidCatch(error, errorInfo) {
// You can also log the error to an error reporting service
console.error("ErrorBoundary caught an error:", error, errorInfo);
// logErrorToMyService(error, errorInfo);
}

render() {
if (this.state.hasError) {
// You can render any custom fallback UI
return (
<div>
<h2>Oops! Something went wrong.</h2>
<p>We're sorry, the content could not be loaded. Please try refreshing the page.</p>
{this.props.fallbackMessage && <p>{this.props.fallbackMessage}</p>}
{/* <details style={{ whiteSpace: 'pre-wrap' }}>
{this.state.error && this.state.error.toString()}
<br />
{this.state.error && this.state.error.stack}
</details> */}
</div>
);
}

return this.props.children;
}
}

export default ErrorBoundary;

1.2 - Using ErrorBoundary with Suspense and React.lazy

You can wrap your Suspense component (or the part of your UI containing lazy components) with an ErrorBoundary:

// src/App.jsx (with ErrorBoundary)
import React, { Suspense } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

import Navbar from './components/Navbar';
import ErrorBoundary from './components/ErrorBoundary'; // Import ErrorBoundary

const HomePage = React.lazy(() => import('./pages/HomePage'));
// Simulate a component that might fail to load
const AboutPage = React.lazy(() =>
import('./pages/AboutPage').then(module => {
// For testing: randomly throw an error to simulate a failed import
// if (Math.random() > 0.5) {
// throw new Error("Simulated network error loading AboutPage");
// }
return module;
})
);
const ContactPage = React.lazy(() => import('./pages/ContactPage'));

function App() {
return (
<Router>
<Navbar />
<div className="container" style={{ padding: '20px' }}>
<ErrorBoundary fallbackMessage="Specifically, a page component failed to load.">
<Suspense fallback={<div style={{ textAlign: 'center', marginTop: '50px', fontSize: '1.5em' }}>Loading page...</div>}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/contact" element={<ContactPage />} />
</Routes>
</Suspense>
</ErrorBoundary>
</div>
</Router>
);
}

export default App;

How it works:

  1. If AboutPage.js (or any other lazy component) fails to load (e.g., due to a network issue or if the file doesn't exist), the Promise returned by the dynamic import() will reject.
  2. React.lazy will then throw this error during rendering.
  3. The ErrorBoundary surrounding the Suspense and Routes will catch this error.
  4. getDerivedStateFromError in ErrorBoundary updates its state to hasError: true.
  5. ErrorBoundary re-renders and displays its fallback UI instead of the crashed component tree.

This prevents the entire application from crashing and provides a better user experience.

To test this: You can simulate a network error by using your browser's developer tools (Network tab -> Throttling -> Offline) and then trying to navigate to a lazy-loaded route that hasn't been cached yet. Or, as in the commented-out code above, you can programmatically throw an error within the dynamic import's then block for testing purposes.


💻 Section 2: Using React.lazy with Named Exports

By default, React.lazy expects the dynamically imported module to have a default export that is a React component.

// ./pages/MyComponent.js
const MyComponent = () => <div>Hello from MyComponent!</div>;
export default MyComponent; // Default export

// Usage with React.lazy
const MyLazyComponent = React.lazy(() => import('./pages/MyComponent'));

What if your component is a named export?

// ./pages/UtilityComponents.js
export const UsefulWidget = () => <div>This is a useful widget.</div>;
export const AnotherWidget = () => <div>Another one!</div>;

You can still use React.lazy by using the .then() method of the Promise returned by import() to pick out the named export and return an object that mimics a module with a default export.

import React, { Suspense } from 'react';

// For UsefulWidget
const LazyUsefulWidget = React.lazy(() =>
import('./pages/UtilityComponents').then(module => {
return { default: module.UsefulWidget };
})
);

// For AnotherWidget
const LazyAnotherWidget = React.lazy(() =>
import('./pages/UtilityComponents').then(module => ({ default: module.AnotherWidget })) // Shorthand
);

function AppWidgets() {
return (
<div>
<h2>Widgets Section</h2>
<Suspense fallback={<div>Loading widget...</div>}>
<LazyUsefulWidget />
</Suspense>
<Suspense fallback={<div>Loading another widget...</div>}>
<LazyAnotherWidget />
</Suspense>
</div>
);
}

export default AppWidgets;

Explanation:

  1. import('./pages/UtilityComponents') returns a Promise that resolves to the module object { UsefulWidget: ..., AnotherWidget: ... }.
  2. In the .then(module => { ... }) callback, we receive this module object.
  3. We then return a new object { default: module.UsefulWidget }. This object structure is what React.lazy expects (an object with a default property whose value is the component).

This pattern allows you to lazy-load components regardless of whether they are default or named exports.


🛠️ Section 3: Strategic Placement of Suspense Components

You can place Suspense components at various levels in your application tree. The placement affects the user experience during loading.

3.1 - Single Suspense at the Top Level (Coarse-grained)

As seen in our App.jsx examples, placing a single Suspense high up (e.g., around your <Routes>) means that any lazy component within that boundary will trigger the same fallback.

// App.jsx
// ...
<Suspense fallback={<GenericAppLoadingSpinner />}>
<Routes>
<Route path="/" element={<LazyHomePage />} />
<Route path="/products" element={<LazyProductsPage />} />
{/* ProductsPage might have its own lazy-loaded sub-components */}
</Routes>
</Suspense>
// ...
  • Pros: Simple to implement.
  • Cons: The fallback is generic. If LazyProductsPage takes a while to load, the entire page area controlled by <Routes> shows the spinner. If LazyProductsPage itself contains other lazy-loaded components (e.g., a LazyProductFilters sidebar and a LazyProductList main area), they won't have their own distinct loading states visible to the user; the entire page will just show GenericAppLoadingSpinner.

3.2 - Multiple Suspense Components (Fine-grained)

You can nest Suspense components or place them closer to individual lazy components to provide more granular loading feedback.

// ProductsPage.jsx
import React, { Suspense } from 'react';

const ProductFilters = React.lazy(() => import('./ProductFilters'));
const ProductList = React.lazy(() => import('./ProductList'));

function ProductsPage() {
return (
<div className="products-page">
<aside>
<Suspense fallback={<div>Loading filters...</div>}>
<ProductFilters />
</Suspense>
</aside>
<main>
<Suspense fallback={<div>Loading products list...</div>}>
<ProductList />
</Suspense>
</main>
</div>
);
}
export default ProductsPage;

And then in App.jsx:

// App.jsx
const LazyProductsPage = React.lazy(() => import('./pages/ProductsPage'));
// ...
<Suspense fallback={<PageLevelSpinner />}> {/* For the page itself */}
<Routes>
<Route path="/products" element={<LazyProductsPage />} />
</Routes>
</Suspense>
// ...
  • Pros: More specific loading feedback. Users can see parts of the page (like the main layout or already loaded sections) while other parts are still loading with their own indicators. This often results in a better perceived performance.
  • Cons: Slightly more complex to manage multiple Suspense boundaries.

Recommendation: Start with a top-level Suspense for route-based code splitting. Then, identify parts of your application that could benefit from more granular loading states (e.g., large individual components within a page, sidebars, content sections) and introduce additional Suspense boundaries there. This provides a good balance between simplicity and user experience.


🚀 Section 4: Preloading Components for Smoother Transitions

While React.lazy defers loading until a component is rendered, sometimes you might want to start loading a component's code before it's actually needed to make navigations feel faster. This is known as preloading.

Imagine a user is on the HomePage and hovers over a link to the AboutPage. At that moment (on hover), you could trigger the download of AboutPage.js so that if they click the link, the code is already (or mostly) loaded, making the transition almost instant.

React itself doesn't have a built-in API for preloading React.lazy components directly, but bundlers like Webpack offer mechanisms, or you can implement simple preloading strategies.

4.1 - Webpack Magic Comments for Preloading/Prefetching

If you're using Webpack, you can use "magic comments" within your dynamic import() statements to give hints to the bundler:

  • webpackPrefetch: Tells the browser to prefetch the resource during browser idle time. It's for resources that might be needed for future navigations. The browser fetches these with low priority.
    const AboutPage = React.lazy(() =>
    import(/* webpackPrefetch: true */ './pages/AboutPage')
    );
  • webpackPreload: Tells the browser to preload the resource. It's for resources that are needed for the current navigation, but their discovery is late. The browser fetches these with high priority. This is more useful for resources needed for the current page rather than future ones.
    const CriticalModal = React.lazy(() =>
    import(/* webpackPreload: true */ './components/CriticalModal')
    );

Usage: When Webpack sees these comments, it adds <link rel="prefetch" href="..."> or <link rel="preload" href="..."> tags to the HTML, prompting the browser to fetch these resources accordingly.

Note: Support and behavior can vary slightly between bundlers and browser versions. Always test the impact.

4.2 - Manual Preloading on Event (e.g., onHover)

You can manually trigger the dynamic import (which starts the download) on a user event like a mouse hover.

// Navbar.jsx
import React from 'react';
import { Link } from 'react-router-dom';

// Function to trigger the preload
const preloadAboutPage = () => import('../pages/AboutPage');

function Navbar() {
// ... styles ...
return (
<nav style={navStyle}>
<Link to="/" style={linkStyle}>Home</Link>
<Link
to="/about"
style={linkStyle}
onMouseEnter={preloadAboutPage} // Preload on hover
onFocus={preloadAboutPage} // Preload on focus (for keyboard nav)
>
About
</Link>
<Link to="/contact" style={linkStyle}>Contact</Link>
</nav>
);
}
export default Navbar;

And your App.jsx would still use React.lazy as usual:

// App.jsx
const AboutPage = React.lazy(() => import('./pages/AboutPage'));
// ... rest of the setup

How it works:

  1. When the user hovers over the "About" link, onMouseEnter calls preloadAboutPage.
  2. preloadAboutPage executes import('../pages/AboutPage'), which initiates the download of the AboutPage.js chunk.
  3. The Promise returned by import() isn't used directly here for rendering; React.lazy will still handle its own dynamic import when AboutPage is actually rendered.
  4. However, because the browser has already started (or completed) downloading the chunk due to the manual import() call, when React.lazy triggers its import for the same chunk, the browser can often serve it from its cache much faster.

Considerations for Manual Preloading:

  • Don't overdo it: Preloading too many components aggressively can consume unnecessary bandwidth, especially for mobile users.
  • Choose critical paths: Focus on preloading components for likely next navigations or high-value interactions.
  • Event choice: onMouseEnter is common, but also consider onFocus for keyboard accessibility.

✨ Section 5: Conclusion & Key Takeaways (Part 2)

Mastering these advanced aspects of React.lazy and Suspense allows you to build more robust and performant React applications. Handling errors gracefully, working with different export types, fine-tuning loading states with Suspense placement, and strategically preloading components are all valuable techniques.

Key Takeaways:

  • Error Boundaries are Crucial: They prevent UI crashes when lazy components fail to load, providing a better user experience.
  • Named Exports are Supported: React.lazy can work with named exports by transforming the resolved module in the .then() callback.
  • Suspense Placement Matters: Granular Suspense boundaries offer more specific loading feedback, improving perceived performance.
  • Preloading Enhances UX: Techniques like Webpack magic comments or manual preloading on events can make navigations feel instantaneous by loading code just before it's needed.

Challenge Yourself: In the example application from Part 1 (or a new one), implement an ErrorBoundary around your routes. Then, try to simulate a loading failure for one of your lazy components (e.g., by temporarily renaming the component file or using the random error simulation shown earlier) and observe how your ErrorBoundary catches the error and displays its fallback UI.


➡️ Next Steps

We've now taken a comprehensive look at route-based code splitting using React.lazy. In the upcoming articles, we'll shift our focus to the Suspense component itself, exploring its capabilities beyond just lazy loading fallbacks. The next article, "The Suspense Component (Part 1)", will delve into providing more sophisticated fallback UIs like skeleton screens and managing multiple Suspense boundaries.

Keep optimizing, and your users will thank you for the snappy experience!


glossary

  • Error Boundary: A React component that catches JavaScript errors in its child component tree, logs them, and displays a fallback UI.
  • Named Export: A way to export multiple values from a JavaScript module using the export { value1, value2 } syntax, as opposed to a single export default value.
  • Preloading/Prefetching: Techniques to load resources (like JavaScript chunks) before they are explicitly required, often during browser idle time or based on user interaction hints, to improve future navigation speed.
  • Webpack Magic Comments: Special comments used within dynamic import() statements to pass instructions or hints to Webpack, such as webpackPrefetch or webpackPreload.

Further Reading