The Code Splitting Dilemma

The React team recognized bundle size challenges by introducing React.lazy() and Suspense for component-based code splitting. This native solution works great for client-side rendering, but falls apart when you need SSR:

// This works fine with client-side rendering
const LazyComponent = React.lazy(() => import('./LazyComponent'));

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

If you’re using Next.js, Gatsby, or any other SSR solution, the code above will throw the dreaded error: “Suspense is not supported during server-side rendering.”

@loadable/component shines

@loadable/component was originally created as a higher-level abstraction over React.lazy(), it has evolved into the go-to solution for universal code splitting in React applications.

Installing is straightforward:

npm install @loadable/component
# or
yarn add @loadable/component

The basic usage mirrors React.lazy(), but with SSR compatibility:

import loadable from '@loadable/component';

const LazyComponent = loadable(() => import('./LazyComponent'), {
  fallback: <div>Loading...</div>
});

function MyComponent() {
  // No Suspense wrapper needed!
  return <LazyComponent />;
}

Why I Migrated from React.lazy()

After implementing @loadable/component, I discovered several additional benefits:

  1. Library-level loading indicators: The fallback is specified at the component declaration level, reducing boilerplate.

  2. Enhanced prefetching capabilities: Unlike React.lazy(), @loadable/component gives you fine-grained control over when to prefetch components:

const LazyComponent = loadable(() => import('./LazyComponent'));

// Prefetch on demand
const handlePrefetch = () => {
  LazyComponent.preload();
};
  1. Dynamic loading with props: You can pass variables into your dynamic imports, enabling context-dependent code loading:
const LazyComponent = loadable(props => 
  import(`./components/${props.componentName}`)
);

// Usage
<LazyComponent componentName="Dashboard" />

Fine-grained Control with Gatsby

For those using Gatsby, @loadable/component offers particularly powerful optimizations. As highlighted in Gatsby’s documentation, without code splitting, all components will be included in every page bundle—even those that aren’t used on the current page.

Consider this example from a Gatsby-based CMS project:

// components/ContentBlocks.js
import loadable from '@loadable/component';

// Instead of direct imports:
// import Carousel from './blocks/Carousel';
// import VideoPlayer from './blocks/VideoPlayer';
// import TestimonialCard from './blocks/TestimonialCard';

// Use loadable components:
const Carousel = loadable(() => import('./blocks/Carousel'));
const VideoPlayer = loadable(() => import('./blocks/VideoPlayer'));
const TestimonialCard = loadable(() => import('./blocks/TestimonialCard'));

export const getBlockComponent = (blockType) => {
  switch(blockType) {
    case 'carousel':
      return Carousel;
    case 'video':
      return VideoPlayer;
    case 'testimonial':
      return TestimonialCard;
    default:
      return null;
  }
};

This approach ensures that if a page only uses testimonials, the code for carousels and video players won’t be included in the bundle.

Next.js

For a full SSR setup with Next.js, you’ll need the server modules:

npm install @loadable/component @loadable/server @loadable/babel-plugin @loadable/webpack-plugin

Then update your Next.js configuration:

// next.config.js
const LoadablePlugin = require('@loadable/webpack-plugin');

module.exports = {
  webpack: (config, options) => {
    config.plugins.push(new LoadablePlugin());
    return config;
  },
};

Gatsby

Gatsby works well with the basic @loadable/component package, as demonstrated in the earlier example. The performance improvements are particularly noticeable in content-rich sites where different pages use widely varying components.

Loading State Best Practices

Rather than displaying a generic spinner, consider skeleton screens that match your component’s final layout:

const ArticleComponent = loadable(() => import('./Article'), {
  fallback: <ArticleSkeleton />
});

// ArticleSkeleton.js
const ArticleSkeleton = () => (
  <div className="article-skeleton">
    <div className="skeleton-title"></div>
    <div className="skeleton-metadata"></div>
    <div className="skeleton-content">
      <div className="skeleton-line"></div>
      <div className="skeleton-line"></div>
      <div className="skeleton-line"></div>
    </div>
  </div>
);

This approach provides a more seamless user experience than abrupt loading indicators.

Pitfalls to Avoid

  1. Over-splitting: Don’t create loadable components for tiny components. Code-splitting has overhead, so focus on larger components or logical feature groups.

  2. Ignoring the critical path: Components visible above the fold should often be included in the main bundle.

  3. Missing error boundaries: Always wrap dynamically loaded components in error boundaries to prevent the entire application from crashing if a chunk fails to load.

import { ErrorBoundary } from 'react-error-boundary';

function MyApp() {
  return (
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      onError={handleError}
    >
      <LazyComponent />
    </ErrorBoundary>
  );
}

Forecast

Code splitting remains a crucial optimization technique. While React.lazy() may eventually gain SSR support, @loadable/component offers a robust solution today with an API that’s likely to remain compatible with future React updates.