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:
Library-level loading indicators: The fallback is specified at the component declaration level, reducing boilerplate.
Enhanced prefetching capabilities: Unlike
React.lazy(),@loadable/componentgives you fine-grained control over when to prefetch components:
const LazyComponent = loadable(() => import('./LazyComponent'));
// Prefetch on demand
const handlePrefetch = () => {
LazyComponent.preload();
};
- 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.
Integration with Popular Frameworks
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
Over-splitting: Don’t create loadable components for tiny components. Code-splitting has overhead, so focus on larger components or logical feature groups.
Ignoring the critical path: Components visible above the fold should often be included in the main bundle.
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.