Skip to main content

Component-based Code Splitting #134

📖 Introduction

Following our deep dive into The Suspense Component (Part 2) and its use with useTransition, we now shift our focus from primarily route-based code splitting to component-based code splitting. While splitting code by routes is highly effective, there are many scenarios where you might want to lazy load individual components within a page or view. This article explores how to identify such components and apply React.lazy and Suspense to them for further performance gains.


📚 Prerequisites

Before we begin, ensure you have a solid understanding of:

  • React.lazy and Suspense (Articles 130-133).
  • Conditional rendering in React.
  • Identifying performance bottlenecks (conceptual understanding from Article 128 is helpful).

🎯 Article Outline: What You'll Master

In this article, you will learn:

  • Why Component-based Splitting? Identifying use cases beyond route-level splitting.
  • Lazy Loading Conditionally Rendered Components: Components shown based on user interaction (e.g., modals, dropdowns).
  • Lazy Loading Components Below the Fold: Optimizing initial load by deferring components not immediately visible.
  • Lazy Loading Large or Rarely Used Components: Improving performance by splitting out heavy UI elements.
  • Practical Examples: Implementing lazy loading for various component types.

🧠 Section 1: Why Go Beyond Route-based Splitting?

Route-based code splitting is a fantastic starting point: load the code for a page only when the user navigates to it. However, even within a single page, there can be components that are:

  • Large and Complex: Components that include heavy libraries (e.g., charting libraries, rich text editors, complex data grids) or have a lot of their own JavaScript logic can significantly add to the initial bundle size of the page they reside on.
  • Conditionally Rendered: Components that only appear after a user interaction (clicking a button to open a modal, expanding a panel) or based on certain application state might not be needed immediately.
  • Below the Fold: Content that is not visible until the user scrolls down the page. Loading this upfront can delay rendering of critical above-the-fold content.
  • Rarely Used: Features or components that most users don't interact with during a typical session.

By applying React.lazy to these individual components, you can further reduce the initial JavaScript payload for a given page, leading to faster interactions and a better user experience. The core principle remains the same: load code only when it's needed.


💻 Section 2: Lazy Loading Conditionally Rendered Components

This is one of the most common use cases for component-based code splitting.

2.1 - Example: Lazy Loading a Modal Dialog

Modal dialogs are often complex and might include forms, lists, or other UI elements. They are typically hidden until a user action triggers them.

Standard (Non-Lazy) Modal:

// components/MyModal.jsx
import React from 'react';
// Assume some styling for MyModal.css

const MyModal = ({ isOpen, onClose, title, children }) => {
if (!isOpen) return null;

return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{title}</h2>
<button onClick={onClose} className="modal-close-btn">&times;</button>
</div>
<div className="modal-body">
{children}
</div>
</div>
</div>
);
};
export default MyModal;

// App.jsx (using the non-lazy modal)
import React, { useState } from 'react';
import MyModal from './components/MyModal'; // Standard import

function AppWithModal() {
const [isModalOpen, setIsModalOpen] = useState(false);

return (
<div>
<button onClick={() => setIsModalOpen(true)}>Open Modal</button>
<MyModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="My Information Modal"
>
<p>This is some important information inside the modal!</p>
{/* Imagine more complex content here */}
</MyModal>
</div>
);
}

In this case, the code for MyModal.jsx is included in the initial bundle, even if the user never opens the modal.

Lazy-Loaded Modal:

// AppLazyModal.jsx
import React, { useState, Suspense, lazy } from 'react';
import Spinner from './components/Spinner'; // Assuming a Spinner component

const LazyMyModal = lazy(() => import('./components/MyModal')); // Lazy import

function AppLazyModal() {
const [isModalOpen, setIsModalOpen] = useState(false);

const openModal = () => setIsModalOpen(true);
const closeModal = () => setIsModalOpen(false);

return (
<div style={{padding: '20px'}}>
<button onClick={openModal}>Open Lazy Modal</button>

{/*
The modal is only rendered (and thus its code potentially loaded) when isModalOpen is true.
Suspense wraps the conditional rendering part.
*/}
{isModalOpen && (
<Suspense fallback={<div className="modal-loading-placeholder"><Spinner /> Loading Modal...</div>}>
<LazyMyModal
isOpen={isModalOpen}
onClose={closeModal}
title="My Lazy Information Modal"
>
<p>This is some important information inside the lazy-loaded modal!</p>
<p>The code for this modal was only downloaded when you clicked "Open Lazy Modal".</p>
</LazyMyModal>
</Suspense>
)}
</div>
);
}
export default AppLazyModal;

/* Add some placeholder CSS for .modal-loading-placeholder if desired
.modal-loading-placeholder {
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
padding: 20px; background: white; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.2);
display: flex; align-items: center; gap: 10px;
}
*/

Behavior:

  1. Initially, isModalOpen is false. LazyMyModal is not rendered, and its code is not loaded.
  2. When the "Open Lazy Modal" button is clicked, isModalOpen becomes true.
  3. The Suspense boundary is now active because LazyMyModal is attempted to be rendered.
  4. If LazyMyModal.js hasn't been loaded yet, it suspends. The Suspense fallback (<Spinner /> Loading Modal...) is displayed.
  5. Once LazyMyModal.js is downloaded and parsed, the actual modal content renders.

Key Point: The Suspense boundary should wrap the part of the tree where the lazy component might render. Placing it around the conditional block {isModalOpen && (...) } is effective here.

2.2 - Other Conditionally Rendered UI: Tooltips, Popovers, Tabs

The same pattern applies to other UI elements that are not always visible:

  • Complex Tooltips/Popovers: If a tooltip contains rich content or interactive elements.
  • Content of Tabs: If you have a tabbed interface where only one tab's content is visible at a time, you can lazy load the content components for each tab.
    const Tab1Content = lazy(() => import('./Tab1Content'));
    const Tab2Content = lazy(() => import('./Tab2Content'));
    // ...
    {activeTab === 'tab1' && <Suspense fallback={<Spinner/>}><Tab1Content /></Suspense>}
    {activeTab === 'tab2' && <Suspense fallback={<Spinner/>}><Tab2Content /></Suspense>}

🛠️ Section 3: Lazy Loading Components "Below the Fold"

"Below the fold" refers to content on a webpage that is not visible in the browser's viewport until the user scrolls down. Loading all content, including what's off-screen, can significantly slow down the initial rendering of the visible part of the page.

Strategy: Identify components or sections that are typically below the fold and lazy load them. You'll often need a way to detect when these components are about to become visible (e.g., using the Intersection Observer API or a React library that implements it).

Example: A Long Article Page with a Comments Section The comments section at the bottom of an article is a prime candidate for lazy loading.

// ArticlePage.jsx
import React, { Suspense, lazy, useState, useRef, useEffect } from 'react';
import Spinner from './components/Spinner';

const CommentsSection = lazy(() => import('./CommentsSection')); // Lazy load comments

function ArticlePage({ article }) {
const [showComments, setShowComments] = useState(false);
const commentsTriggerRef = useRef(null); // Ref for the trigger element

useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setShowComments(true);
observer.unobserve(entry.target); // Stop observing once visible
}
},
{
rootMargin: '100px', // Load 100px before it enters viewport
}
);

if (commentsTriggerRef.current) {
observer.observe(commentsTriggerRef.current);
}

return () => {
if (commentsTriggerRef.current) {
// eslint-disable-next-line react-hooks/exhaustive-deps
observer.unobserve(commentsTriggerRef.current);
}
};
}, []);

return (
<div className="article">
<h1>{article.title}</h1>
<div className="article-content" dangerouslySetInnerHTML={{ __html: article.content }} />

{/* This div will trigger loading when it's about to become visible */}
<div ref={commentsTriggerRef} style={{ minHeight: '10px' }}></div>

{showComments && (
<Suspense fallback={<div style={{padding: '20px', textAlign: 'center'}}><Spinner /> Loading comments...</div>}>
<CommentsSection articleId={article.id} />
</Suspense>
)}
</div>
);
}

export default ArticlePage;

// Dummy CommentsSection.jsx
// const CommentsSection = ({ articleId }) => {
// // Simulate loading comments
// const [comments, setComments] = useState([]);
// useEffect(() => {
// console.log(`Fetching comments for article ${articleId}... (Simulated)`);
// setTimeout(() => setComments(['Comment 1', 'Comment 2', 'Comment 3']), 1500);
// }, [articleId]);
// return <div><h3>Comments</h3>{comments.length > 0 ? <ul>{comments.map((c,i) => <li key={i}>{c}</li>)}</ul> : <p>No comments yet.</p>}</div>;
// };
// export default CommentsSection;

Explanation:

  1. CommentsSection is lazy-loaded.
  2. An empty div with ref={commentsTriggerRef} is placed just before where the comments would appear.
  3. IntersectionObserver watches this trigger div.
  4. When the trigger div is about to enter the viewport (due to rootMargin: '100px'), setShowComments(true) is called.
  5. This causes the Suspense block for CommentsSection to render. If the code for CommentsSection.js hasn't loaded, the fallback is shown while it downloads.
  6. The observer is then disconnected to avoid unnecessary checks.

Libraries for Visibility Detection: Manually implementing IntersectionObserver is fine, but libraries like react-intersection-observer can simplify this:

import { useInView } from 'react-intersection-observer';
// ...
// const { ref, inView } = useInView({ triggerOnce: true, rootMargin: '100px' });
// ...
// <div ref={ref}>
// {inView && (
// <Suspense fallback={<Spinner />}>
// <LazyComponent />
// </Suspense>
// )}
// </div>

🔬 Section 4: Lazy Loading Large or Rarely Used Components

Even if a component is always rendered (not conditional or below the fold), if it's particularly large or complex and not essential for the initial core experience of the page, it can be a candidate for lazy loading.

Example: A Complex Charting Library Imagine a dashboard page that includes an advanced, interactive chart. The library for this chart might be quite large.

// DashboardPage.jsx
import React, { Suspense, lazy } from 'react';
import ChartSkeleton from './ChartSkeleton'; // A skeleton for the chart

// Assume MyFancyChart is a wrapper around a large charting library
const LazyMyFancyChart = lazy(() => import('./components/MyFancyChart'));

function DashboardPage({ summaryData, chartData }) {
return (
<div className="dashboard">
<header>
<h1>Dashboard Overview</h1>
{/* Display summaryData, which is quick to load/render */}
</header>
<main>
<section className="chart-section">
<h2>Detailed Analytics</h2>
<Suspense fallback={<ChartSkeleton />}>
<LazyMyFancyChart data={chartData} />
</Suspense>
</section>
{/* Other dashboard widgets */}
</main>
</div>
);
}
export default DashboardPage;

Benefits:

  • The dashboard's main structure and summary data can load and render quickly.
  • The ChartSkeleton provides an immediate placeholder.
  • The potentially large JavaScript for MyFancyChart is loaded in the background. The user can start interacting with other parts of the dashboard while the chart loads.

Considerations:

  • User Experience: If the component is critical, ensure the fallback (e.g., skeleton screen) is good and the load time is acceptable. For very critical components, lazy loading might not be ideal if it significantly delays user interaction with that component.
  • Bundle Analysis: Use tools like webpack-bundle-analyzer to identify which components contribute most to your bundle sizes. This helps prioritize what to lazy load.

✨ Section 5: Best Practices and Trade-offs

  • Granularity: Don't lazy load every tiny component. There's a small overhead to lazy loading (the dynamic import mechanism, Suspense boundary). Focus on components that provide a meaningful reduction in bundle size or are genuinely non-critical for the initial view.
  • Fallback Quality: Invest time in good fallbacks (spinners, skeletons). A good fallback makes lazy loading feel seamless. A bad or missing fallback makes it feel broken.
  • Error Handling: Always consider using Error Boundaries around Suspense components that load critical lazy components, as network issues can prevent chunks from loading.
  • Testing: Test the loading states and fallbacks on various network conditions (use browser dev tools for throttling) to ensure a good experience for all users.
  • Measure Impact: Use performance profiling tools to measure the actual impact of your component-based code splitting on load times and bundle sizes.

💡 Conclusion & Key Takeaways

Component-based code splitting with React.lazy and Suspense offers a powerful way to fine-tune your application's performance beyond just route-level splitting. By strategically deferring the loading of modals, below-the-fold content, large libraries, or rarely used UI elements, you can create faster, more responsive user experiences.

Key Takeaways:

  • Component-based splitting targets individual components within a page for lazy loading.
  • Ideal for conditionally rendered UI (modals, tabs), content below the fold, or large/complex components.
  • Requires careful placement of Suspense boundaries and well-designed fallbacks (skeletons are often best).
  • Tools like IntersectionObserver (or libraries using it) help trigger loading for off-screen content.
  • Balance the benefits of splitting with the small overhead involved; prioritize impactful components.

Challenge Yourself: Identify a component in one of your existing projects (or a conceptual one) that is either:

  1. A modal dialog.
  2. A section that would typically be below the fold on a long page. Refactor it to use React.lazy and Suspense. Implement a simple skeleton loader as its fallback.

➡️ Next Steps

We've now covered various strategies for applying code splitting at both the route and component levels. The next article, "Preloading Lazy Components", will explore techniques to make lazy loading even smoother by starting to download component code before it's explicitly rendered, based on user intent or other heuristics.

Optimizing React applications is an ongoing journey of refinement!


glossary

  • Component-based Code Splitting: Applying code splitting techniques to individual components within a page, rather than just entire pages/routes.
  • Below the Fold: Content on a webpage that is not visible in the browser's initial viewport and requires scrolling to be seen.
  • Intersection Observer API: A browser API that provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport. Useful for triggering actions like lazy loading when an element scrolls into view.
  • Viewport: The visible area of a web page in a browser window.
  • Bundle Analysis Tools: Utilities (e.g., webpack-bundle-analyzer) that help visualize the composition of JavaScript bundles, making it easier to identify large or unnecessary code.

Further Reading