Code Splitting and Lazy Loading
Optimize your React Shopify theme by splitting code into smaller chunks. Learn dynamic imports, route-based splitting, and component-level lazy loading strategies.
A Shopify theme with React can easily balloon to hundreds of kilobytes of JavaScript. When customers visit your store, they shouldn’t have to download code for the cart drawer, account pages, and product zoom—all before seeing the first product. Code splitting lets you break your bundle into smaller chunks that load on demand.
Why Code Splitting Matters
Consider a typical React Shopify theme bundle:
┌─────────────────────────────────────────────────────────────────┐│ BEFORE CODE SPLITTING │├─────────────────────────────────────────────────────────────────┤│ ││ main.js (450KB) ││ ├── React + ReactDOM ................ 140KB ││ ├── Framer Motion ................... 30KB ││ ├── Zustand + state logic ........... 15KB ││ ├── All components .................. 180KB ││ │ ├── Header/Navigation ││ │ ├── Product page components ││ │ ├── Cart drawer ││ │ ├── Search modal ││ │ ├── Account components ││ │ └── Footer ││ └── Utilities + helpers ............. 85KB ││ ││ Every page loads EVERYTHING ││ │└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐│ AFTER CODE SPLITTING │├─────────────────────────────────────────────────────────────────┤│ ││ main.js (95KB) - loads on every page ││ ├── React + ReactDOM ................ 140KB (shared) ││ ├── Core UI components .............. 40KB ││ └── Essential utilities ............. 15KB ││ ││ product.js (65KB) - loads on product pages ││ cart.js (35KB) - loads when cart opens ││ search.js (25KB) - loads when search opens ││ account.js (45KB) - loads on account pages ││ ││ Each page loads only what it needs ││ │└─────────────────────────────────────────────────────────────────┘The goal: reduce initial JavaScript by 50-70%, loading additional code only when needed.
Dynamic Imports with React.lazy
React’s built-in lazy function creates components that load on demand:
import { lazy, Suspense } from 'react';
// These components are split into separate chunksconst CartDrawer = lazy(() => import('@/components/cart/CartDrawer'));const SearchModal = lazy(() => import('@/components/search/SearchModal'));const QuickView = lazy(() => import('@/components/product/QuickView'));
// Loading fallbackfunction LoadingSpinner() { return ( <div className="loading-spinner" aria-label="Loading"> <span className="spinner" /> </div> );}
export function App() { const { isCartOpen } = useUIState(); const { isSearchOpen } = useUIState(); const { quickViewProduct } = useUIState();
return ( <> {/* Always-visible header */} <Header />
{/* Lazy-loaded overlays */} <Suspense fallback={<LoadingSpinner />}> {isCartOpen && <CartDrawer />} </Suspense>
<Suspense fallback={<LoadingSpinner />}> {isSearchOpen && <SearchModal />} </Suspense>
<Suspense fallback={<LoadingSpinner />}> {quickViewProduct && <QuickView product={quickViewProduct} />} </Suspense> </> );}When isCartOpen becomes true, React fetches the CartDrawer chunk, parses it, and renders. Until then, that code never downloads.
Naming Chunks for Debugging
Vite/Webpack can name chunks for easier debugging:
// Named chunks appear in Network tab as "cart.js" instead of "chunk-abc123.js"const CartDrawer = lazy(() => import(/* webpackChunkName: "cart" */ '@/components/cart/CartDrawer'));
// Vite uses the file path by default, but you can configure itconst SearchModal = lazy(() => import('@/components/search/SearchModal'));Configure Vite for meaningful chunk names:
import { defineConfig } from 'vite';
export default defineConfig({ build: { rollupOptions: { output: { // Name chunks based on their entry point chunkFileNames: (chunkInfo) => { const facadeModuleId = chunkInfo.facadeModuleId || '';
if (facadeModuleId.includes('/cart/')) return 'cart-[hash].js'; if (facadeModuleId.includes('/search/')) return 'search-[hash].js'; if (facadeModuleId.includes('/account/')) return 'account-[hash].js'; if (facadeModuleId.includes('/product/')) return 'product-[hash].js';
return '[name]-[hash].js'; }, }, }, },});Page-Based Splitting Strategy
For a Shopify theme, split by page type:
/** * Entry point router - loads the appropriate module based on page type. * This file stays small; heavy components are loaded dynamically. */
// Detect page type from body classes or data attributesfunction getPageType(): string { const body = document.body;
if (body.classList.contains('template-product')) return 'product'; if (body.classList.contains('template-collection')) return 'collection'; if (body.classList.contains('template-cart')) return 'cart'; if (body.classList.contains('template-customers-login')) return 'login'; if (body.classList.contains('template-customers-account')) return 'account'; if (body.classList.contains('template-search')) return 'search';
return 'default';}
// Load page-specific modulesasync function initializePage() { const pageType = getPageType();
// Always load core functionality const { initializeCore } = await import('./core'); initializeCore();
// Load page-specific modules switch (pageType) { case 'product': const { initializeProductPage } = await import('./pages/product'); initializeProductPage(); break;
case 'collection': const { initializeCollectionPage } = await import('./pages/collection'); initializeCollectionPage(); break;
case 'cart': const { initializeCartPage } = await import('./pages/cart'); initializeCartPage(); break;
case 'login': case 'account': const { initializeAccountPages } = await import('./pages/account'); initializeAccountPages(); break;
case 'search': const { initializeSearchPage } = await import('./pages/search'); initializeSearchPage(); break; }}
// Start when DOM is readyif (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializePage);} else { initializePage();}Component-Level Splitting
Split heavy components that aren’t immediately visible:
import { lazy, Suspense, useState } from 'react';import { ProductGallery } from './ProductGallery';import { ProductForm } from './ProductForm';import { ProductInfo } from './ProductInfo';
// Heavy components loaded on interactionconst ProductZoom = lazy(() => import('./ProductZoom'));const ProductReviews = lazy(() => import('./ProductReviews'));const RecentlyViewed = lazy(() => import('@/components/shared/RecentlyViewed'));
// Skeleton components for loading statesimport { ReviewsSkeleton, RecentlyViewedSkeleton } from './skeletons';
export function ProductPage({ product }: ProductPageProps) { const [showZoom, setShowZoom] = useState(false); const [activeImage, setActiveImage] = useState(0);
return ( <div className="product-page"> {/* Critical above-the-fold content - not lazy loaded */} <div className="product-main"> <ProductGallery images={product.images} onImageClick={(index) => { setActiveImage(index); setShowZoom(true); }} /> <ProductInfo product={product} /> <ProductForm product={product} /> </div>
{/* Zoom modal - lazy loaded when opened */} {showZoom && ( <Suspense fallback={<div className="zoom-loading" />}> <ProductZoom images={product.images} initialIndex={activeImage} onClose={() => setShowZoom(false)} /> </Suspense> )}
{/* Below-the-fold content - lazy loaded */} <Suspense fallback={<ReviewsSkeleton />}> <ProductReviews productId={product.id} /> </Suspense>
<Suspense fallback={<RecentlyViewedSkeleton />}> <RecentlyViewed currentProductId={product.id} /> </Suspense> </div> );}Preloading for Better UX
Preload chunks before they’re needed to eliminate loading delays:
import { useEffect } from 'react';
/** * Preload a dynamic import when a condition is met. * Use for components that will likely be needed soon. */export function usePreload( importFn: () => Promise<unknown>, condition: boolean) { useEffect(() => { if (condition) { importFn(); } }, [condition, importFn]);}
// Usage: Preload cart drawer when hovering over cart iconexport function CartIcon() { const [isHovered, setIsHovered] = useState(false);
// Start loading cart drawer code on hover usePreload( () => import('@/components/cart/CartDrawer'), isHovered );
return ( <button onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} onClick={openCart} > <CartIconSvg /> </button> );}Preload on route intent:
import { useCallback } from 'react';
export function ProductCard({ product }: ProductCardProps) { // Preload product page code when card is in viewport const preloadProductPage = useCallback(() => { import('@/entries/pages/product'); }, []);
return ( <a href={`/products/${product.handle}`} onMouseEnter={preloadProductPage} onFocus={preloadProductPage} > <ProductImage image={product.featuredImage} /> <ProductTitle>{product.title}</ProductTitle> <ProductPrice price={product.priceRange} /> </a> );}Intersection Observer for Lazy Loading
Load components when they scroll into view:
import { useState, useEffect, useRef, ComponentType } from 'react';
interface UseLazyComponentOptions { rootMargin?: string; threshold?: number;}
export function useLazyComponent<P extends object>( importFn: () => Promise<{ default: ComponentType<P> }>, options: UseLazyComponentOptions = {}) { const [Component, setComponent] = useState<ComponentType<P> | null>(null); const [isInView, setIsInView] = useState(false); const containerRef = useRef<HTMLDivElement>(null);
// Observe when container enters viewport useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { setIsInView(true); observer.disconnect(); } }, { rootMargin: options.rootMargin || '200px', threshold: options.threshold || 0, } );
if (containerRef.current) { observer.observe(containerRef.current); }
return () => observer.disconnect(); }, [options.rootMargin, options.threshold]);
// Load component when in view useEffect(() => { if (isInView && !Component) { importFn().then((module) => { setComponent(() => module.default); }); } }, [isInView, Component, importFn]);
return { Component, containerRef, isLoaded: !!Component };}// Usage in a pagefunction ProductPage({ product }) { const { Component: Reviews, containerRef: reviewsRef, isLoaded: reviewsLoaded, } = useLazyComponent(() => import('@/components/product/ProductReviews'));
return ( <div> <ProductMain product={product} />
{/* Container triggers load when scrolled into view */} <div ref={reviewsRef} className="reviews-section"> {reviewsLoaded ? ( <Reviews productId={product.id} /> ) : ( <ReviewsSkeleton /> )} </div> </div> );}Splitting Third-Party Libraries
Large libraries should be split separately:
export default defineConfig({ build: { rollupOptions: { output: { manualChunks: { // React goes in its own chunk (shared across all pages) 'react-vendor': ['react', 'react-dom'],
// Animation library in separate chunk 'framer-motion': ['framer-motion'],
// State management 'zustand': ['zustand'],
// Date utilities (if used) 'date-fns': ['date-fns'], }, }, }, },});For conditional library loading:
// Only load Framer Motion when animations are neededasync function loadAnimatedComponent() { const [{ motion }, { AnimatedList }] = await Promise.all([ import('framer-motion'), import('@/components/AnimatedList'), ]);
return { motion, AnimatedList };}Error Boundaries for Lazy Components
Handle chunk loading failures gracefully:
import { Component, ReactNode } from 'react';
interface Props { children: ReactNode; fallback?: ReactNode;}
interface State { hasError: boolean; error?: Error;}
export class ChunkErrorBoundary extends Component<Props, State> { constructor(props: Props) { super(props); this.state = { hasError: false }; }
static getDerivedStateFromError(error: Error): State { return { hasError: true, error }; }
componentDidCatch(error: Error) { // Check if it's a chunk loading error if (error.message.includes('Loading chunk') || error.message.includes('Failed to fetch')) { console.error('Chunk loading failed:', error);
// Could report to analytics here } }
handleRetry = () => { this.setState({ hasError: false, error: undefined }); };
render() { if (this.state.hasError) { return this.props.fallback || ( <div className="chunk-error"> <p>Failed to load this section.</p> <button onClick={this.handleRetry}>Try Again</button> <button onClick={() => window.location.reload()}> Reload Page </button> </div> ); }
return this.props.children; }}Wrap lazy components:
<ChunkErrorBoundary fallback={<CartDrawerFallback />}> <Suspense fallback={<LoadingSpinner />}> {isCartOpen && <CartDrawer />} </Suspense></ChunkErrorBoundary>Measuring the Impact
Track chunk sizes and loading performance:
export function trackChunkLoad(chunkName: string) { const startTime = performance.now();
return { complete() { const duration = performance.now() - startTime;
// Log in development if (process.env.NODE_ENV === 'development') { console.log(`[Chunk] ${chunkName} loaded in ${duration.toFixed(0)}ms`); }
// Report to analytics in production if (typeof gtag !== 'undefined') { gtag('event', 'chunk_load', { chunk_name: chunkName, load_time: Math.round(duration), }); } }, };}
// Usageconst tracker = trackChunkLoad('cart-drawer');import('@/components/cart/CartDrawer').then((module) => { tracker.complete(); return module;});Key Takeaways
-
Split by page type first: Product, collection, cart, and account pages should load different bundles.
-
Lazy load overlays: Cart drawers, modals, and search overlays are perfect candidates—they’re not needed on initial load.
-
Preload on intent: Start loading chunks on hover/focus before the user clicks.
-
Use Intersection Observer: Load below-the-fold components when they scroll into view.
-
Split heavy libraries: Framer Motion, date libraries, and other large dependencies should be in separate chunks.
-
Handle errors gracefully: Chunk loading can fail on slow connections. Provide retry options.
-
Measure impact: Track chunk sizes and loading times to verify improvements.
-
Keep critical path lean: The code needed for initial render should be as small as possible.
In the next lesson, we’ll dive into bundle analysis to identify exactly what’s in your bundles and where to focus optimization efforts.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...