Skip to content

React.lazy & Suspense — Code-Splitting React Components

DodaTech Updated 2026-06-28 5 min read

In this tutorial, you will learn about React.lazy & Suspense. We cover key concepts, practical examples, and best practices to help you master this topic.

React.lazy and Suspense enable component-level Code Splitting, loading React components only when rendered for smaller initial bundles.

What You'll Learn

By the end of this tutorial, you'll understand how to use React.lazy for component splitting, manage loading states with Suspense, handle errors with error boundaries, and implement route-based code splitting.

Why It Matters

React applications commonly grow to 500KB+ bundles. React.lazy lets you split these into smaller chunks loaded on demand, dramatically improving initial load time for large applications.

Real-World Use

A SaaS dashboard with 50+ screens uses React.lazy for each route. The initial login page loads in under 200KB. The analytics dashboard, settings panel, and reports section each load only when the user navigates to them.

Lazy Loading Flow

graph TD
    A[App Loads] --> B[Load core bundle
router, layout, auth] B --> C[User navigates
to /dashboard] C --> D[React.lazy triggers
dynamic import] D --> E[Suspense shows
loading fallback] E --> F[Chunk loads
from network] F --> G[Component renders] G --> H[Subsequent visits
use cached chunk] style B fill:#27ae60,color:#fff style D fill:#4a90d9,color:#fff style E fill:#f39c12,color:#fff style G fill:#27ae60,color:#fff

Basic React.lazy

import React, { lazy, Suspense } from 'react';

// Static import (loaded eagerly)
import Header from './components/Header';
import Footer from './components/Footer';

// Lazy imports (loaded on demand)
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Reports = lazy(() => import('./pages/Reports'));

function App() {
    const [page, setPage] = useState('home');

    const renderPage = () => {
        switch (page) {
            case 'dashboard':
                return <Dashboard />;
            case 'settings':
                return <Settings />;
            case 'analytics':
                return <Analytics />;
            case 'reports':
                return <Reports />;
            default:
                return <Home />;
        }
    };

    return (
        <div>
            <Header onNavigate={setPage} />
            <main>
                <Suspense fallback={<div className="loading-spinner" />}>
                    {renderPage()}
                </Suspense>
            </main>
            <Footer />
        </div>
    );
}

Route-Based Code Splitting

// App.jsx — Route-based splitting with React Router
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import LoadingScreen from './components/LoadingScreen';
import ErrorBoundary from './components/ErrorBoundary';

// Each route loads its own chunk
const Home = lazy(() => import('./pages/Home'));
const Products = lazy(() => import('./pages/Products'));
const ProductDetail = lazy(() => import('./pages/ProductDetail'));
const Cart = lazy(() => import('./pages/Cart'));
const Checkout = lazy(() => import('./pages/Checkout'));
const AdminDashboard = lazy(() => import('./pages/admin/Dashboard'));
const AdminUsers = lazy(() => import('./pages/admin/Users'));

function App() {
    return (
        <BrowserRouter>
            <ErrorBoundary>
                <Suspense fallback={<LoadingScreen />}>
                    <Routes>
                        <Route path="/" element={<Home />} />
                        <Route path="/products" element={<Products />} />
                        <Route path="/products/:id" element={<ProductDetail />} />
                        <Route path="/cart" element={<Cart />} />
                        <Route path="/checkout" element={<Checkout />} />

                        {/* Admin routes load separately */}
                        <Route path="/admin" element={<AdminDashboard />} />
                        <Route path="/admin/users" element={<AdminUsers />} />
                    </Routes>
                </Suspense>
            </ErrorBoundary>
        </BrowserRouter>
    );
}

Advanced Patterns

// 1. Named exports with React.lazy
const HeavyComponent = lazy(() =>
    import('./components/HeavyComponent').then(module => ({
        default: module.HeavyComponent
    }))
);

// 2. Preloading on hover
function LazyLink({ to, componentPath, children }) {
    const preload = () => {
        const Component = lazy(() => import(componentPath));
    };

    return (
        <a
            href={to}
            onMouseEnter={preload}
            onTouchStart={preload}
        >
            {children}
        </a>
    );
}

// 3. Conditional loading with timeout
const DataTable = lazy(() => {
    return Promise.race([
        import('./components/DataTable'),
        new Promise((_, reject) =>
            setTimeout(() => reject(new Error('Load timeout')), 10000)
        )
    ]);
});

// 4. Retry logic for failed imports
function retryLazy(fn, retries = 3, delay = 1000) {
    return new Promise((resolve, reject) => {
        const attempt = (n) => {
            fn()
                .then(resolve)
                .catch((err) => {
                    if (n > 0) {
                        setTimeout(() => attempt(n - 1), delay);
                    } else {
                        reject(err);
                    }
                });
        };
        attempt(retries);
    });
}

const Chart = lazy(() =>
    retryLazy(() => import('./charts/ChartComponent'))
);

Suspense with Data Fetching

// Suspense for data fetching (React 18+)
import { Suspense, use } from 'react';

// Create a resource that suspends while loading
function fetchUserData(userId) {
    let status = 'pending';
    let result;

    const promise = fetch(`/api/users/${userId}`)
        .then(r => r.json())
        .then(data => {
            status = 'success';
            result = data;
        })
        .catch(err => {
            status = 'error';
            result = err;
        });

    return {
        read() {
            if (status === 'pending') throw promise;
            if (status === 'error') throw result;
            return result;
        }
    };
}

// Component that suspends
function UserProfile({ userId }) {
    const userData = use(fetchUserData(userId));

    return (
        <div className="user-profile">
            <h2>{userData.name}</h2>
            <p>{userData.email}</p>
        </div>
    );
}

// Parent with Suspense boundaries
function Dashboard() {
    return (
        <div>
            <h1>Dashboard</h1>

            <Suspense fallback={<div>Loading user profile...</div>}>
                <UserProfile userId="123" />
            </Suspense>

            <Suspense fallback={<div>Loading recent activity...</div>}>
                <RecentActivity userId="123" />
            </Suspense>
        </div>
    );
}

Common Mistakes

  1. Lazy loading components used in the initial render. If a component renders on the first view, lazy loading it adds unnecessary delay. Import it statically.
  2. Not providing a Suspense fallback. Without a fallback, the user sees nothing while the chunk loads. Show a spinner or skeleton placeholder.
  3. Nesting Suspense without error boundaries. If a lazy component fails to load, the error propagates. Wrap each Suspense in an ErrorBoundary.
  4. Lazy loading very small components. A 1KB component doesn't benefit from splitting. The HTTP overhead negates the savings. Only split components over 10-20KB.
  5. Not preloading likely-next routes. If users commonly go from Home to Products, preload the Products chunk after Home renders.

Practice Questions

  1. How does React.lazy differ from dynamic import() in plain JavaScript?
  2. What is the role of Suspense when using React.lazy?
  3. How do you handle errors in lazy loaded React components?
  4. What is the recommended chunk size for React.lazy components?
  5. How do you preload lazy components before navigation?

Challenge: Convert a React application with 10+ pages to use route-based code splitting: implement React.lazy for each route, add Suspense with custom loading states, add error boundaries, implement preload-on-hover for navigation links, and compare bundle sizes.

FAQ

Can React.lazy be used with TypeScript?

Yes. React.lazy works with TypeScript. The imported module must have a default export. For named exports, wrap in a Promise that returns { default: NamedExport }.

Does React.lazy work with server-side rendering?

React.lazy doesn't work with SSR directly. Use @loadable/component for SSR-compatible code splitting, or use React.lazy with the selective hydration pattern.

How many Suspense boundaries should I have?

One per logical section. Too many boundaries fragment the UI into loading pieces. Too few means one slow component blocks the entire page.

Can I use React.lazy with Next.js?

Next.js uses its own dynamic import (next/dynamic) which builds on React.lazy with SSR support. Use next/dynamic instead of React.lazy in Next.js.

What happens if a lazy component fails to load?

The Suspense boundary's fallback displays indefinitely. Wrap in an error boundary to show a meaningful error or retry button.

Mini Project

Build a multi-page dashboard with React.lazy: 8 routes (Home, Dashboard, Analytics, Reports, Settings, Users, Products, Profile), each lazy loaded with Suspense, error boundaries with retry buttons, preload-on-hover for sidebar navigation, and a bundle analysis comparison.

What's Next

You've mastered React.lazy. Now learn about Route-Based Splitting for larger-scale code splitting strategies.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro