6 min read

ReactJS: Code-Splitting - Supercharge Your App's Performance

React Code-Splitting: Smaller Bundles, Faster Apps

Let’s be real, nobody likes waiting for a website to load. In the fast-paced world of the internet, every millisecond counts. Large JavaScript bundles are a major culprit behind slow initial load times. React’s code-splitting feature comes to the rescue, allowing you to break down your app into smaller, more manageable chunks that load on demand. This means users only download the code they need when they need it. This post will show you how to implement code-splitting in your React apps using React.lazy, Suspense, and dynamic imports.

Why Code-Splitting Matters: Performance is Key!

Code-splitting offers several key benefits:

  • Improved Initial Load Time: By loading only the essential code upfront, you drastically reduce the initial download size, making your app load faster and improving the user experience.
  • Reduced Bundle Sizes: Smaller bundles mean faster downloads and less bandwidth consumption, which is especially important for users on mobile devices or slow internet connections.
  • Improved Perceived Performance: Even if the total download size remains the same, code-splitting can improve perceived performance by loading content progressively, making the app feel more responsive.
  • Better Caching: Smaller, more granular bundles are easier to cache efficiently. Changes to one part of your app won’t invalidate the cache for unrelated parts.

React.lazy and Suspense: The Dynamic Duo of Code-Splitting

React provides two key components for code-splitting:

  • React.lazy: This function lets you render a dynamic import as a regular component. The dynamic import returns a Promise which resolves to a module with a default export containing a React component.
  • Suspense: This component lets you specify a loading indicator while the dynamically imported component is loading.

Here’s how they work together:

import React, { Suspense } from "react";
// Dynamically import the About component
const About = React.lazy(() => import("./About"));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <About />
      </Suspense>
    </div>
  );
}

In this example, the About component is only loaded when MyComponent renders. While About is loading, the Suspense component displays the “Loading…” fallback.

Dynamic import(): Loading Code on Demand

The dynamic import() syntax is the heart of code-splitting. It allows you to import a module asynchronously.

import("./About")
  .then((module) => {
    const About = module.default;
    // Use the About component
  })
  .catch((error) => {
    // Handle the error
  });

The import() function returns a promise that resolves to the imported module. You can use .then to handle the loaded module and .catch to handle errors.

Route-Based Code-Splitting: A Common Use Case

One of the most common use cases for code-splitting is route-based splitting, where components for different routes are loaded only when the user navigates to those routes. This is usually done with libraries like react-router-dom.

import React, { Suspense, lazy } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";

const Home = lazy(() => import("./routes/Home"));
const About = lazy(() => import("./routes/About"));
const Dashboard = lazy(() => import("./routes/Dashboard"));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div>Loading...</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="about" element={<About />} />
          <Route path="dashboard" element={<Dashboard />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

In this example, the Home, About, and Dashboard components are loaded only when the user navigates to the corresponding routes, significantly improving initial load time.

Named Exports: Handling Multiple Components

If you need to import multiple components from a single file, you can use named exports. However, React.lazy only supports default exports directly. You can handle named exports by creating a wrapper around the dynamic import.

Example: Lazy Loading Named Exports

Suppose you have a file MyComponents.tsx with named exports:

// MyComponents.tsx
export const MyComponent1 = () => <div>Component 1</div>;
export const MyComponent2 = () => <div>Component 2</div>;

You can create a wrapper component to handle this:

// MyComponentsLazy.tsx
import React from "react";

export const MyComponent1Lazy = React.lazy(() =>
  import("./MyComponents").then((module) => ({ default: module.MyComponent1 }))
);
export const MyComponent2Lazy = React.lazy(() =>
  import("./MyComponents").then((module) => ({ default: module.MyComponent2 }))
);

Then, you can use these lazy-loaded components in your app:

import React, { Suspense } from "react";
import { MyComponent1Lazy, MyComponent2Lazy } from "./MyComponentsLazy";

function App() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <MyComponent1Lazy />
        <MyComponent2Lazy />
      </Suspense>
    </div>
  );
}

export default App;

Preventing Unnecessary Loading with React.memo

For small components, importing might incur more overhead than just including them with your main bundle. Use React.memo to prevent unnecessary re-renders, potentially removing the need for code-splitting in these cases.

Example: Using React.memo

import React, { memo } from "react";

const SmallComponent = () => {
  return <div>Small Component</div>;
};

export default memo(SmallComponent);

Caveats and Considerations

  • Error Boundaries: Combine code-splitting with Error Boundaries to handle errors gracefully during the loading process.
  • Bundle Analysis: Use tools like Webpack Bundle Analyzer to visualize your bundles and identify opportunities for further optimization.
  • User Experience: Provide clear loading indicators to inform the user that content is loading.

Implementing Error Boundaries with Code-Splitting

You can use Error Boundaries along with Suspense to handle loading errors gracefully. Here’s an example using the react-error-boundary library:

import React, { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
import MyComponent from "./MyComponent";

function FallbackComponent({
  error,
  resetErrorBoundary,
}: {
  error: Error;
  resetErrorBoundary: () => void;
}) {
  return (
    <div>
      <h1>Something went wrong!</h1>
      <p>{error.message}</p>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary FallbackComponent={FallbackComponent} onReset={() => {}}>
      <Suspense fallback={<div>Loading...</div>}>
        <MyComponent />
      </Suspense>
    </ErrorBoundary>
  );
}

export default App;

Conclusion: Code-Splitting for a Better UX

Code-splitting is a powerful technique for optimizing React app performance. By breaking down your app into smaller bundles and loading them on demand, you can improve initial load times, reduce bandwidth consumption, and create a smoother, more responsive user experience. With React.lazy, Suspense, and dynamic imports, implementing code-splitting is easy and effective. So, go forth and split your code for a better web!

References: