Code Splitting

Wrap non‑critical modules in `React.lazy` + `Suspense`; prefer route‑level boundaries.

Why?  Improves first‑paint times without complicating component logic.

Every import you add bundles more JavaScript that the browser must parse before your app can paint. If you always load everything up front—charts, admin panels, rarely‑used modals—first‑time users wait on code they never touch. Conversely, if you sprinkle React.lazy around every little component you can create a waterfall of tiny requests that actually slows things down. Good code splitting strikes a balance: defer non‑essential chunks (whole routes, big libraries) behind a single Suspense boundary so the shell loads fast and the user sees meaningful UI immediately.

Correct Example

// App.tsx — route‑level code splitting
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { Suspense, lazy } from "react";
import HomePage from "./pages/HomePage";

const AdminPage = lazy(() => import("./pages/AdminPage")); // heavy chunk
const ReportsPage = lazy(() => import("./pages/ReportsPage")); // heavy chart lib

export default function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<p className="p-8">Loading page…</p>}>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/admin/*" element={<AdminPage />} />
          <Route path="/reports/*" element={<ReportsPage />} />
          <Route path="*" element={<Navigate to="/" replace />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

Why this is good

  • Only the home bundle ships on first load; admin and reports code download the moment a user navigates there.
  • One central Suspense fallback handles loading UI for all lazy routes.

Incorrect Example A — monolithic bundle

// App.tsx — everything imported eagerly
import HomePage from "./pages/HomePage";
import AdminPage from "./pages/AdminPage"; // ⬅ loads React‑Table, rich‑text editor…
import ReportsPage from "./pages/ReportsPage"; // ⬅ loads charting library

export default function App() {
  return (
    <BrowserRouter>
      {/* first paint delayed by 500 KB+ of admin/report code the user may never see */}

    </BrowserRouter>
  );
}

Incorrect Example B — over‑splitting waterfall

// HeavyChart.tsx split into too many micro‑chunks
const Axis = lazy(() => import("./HeavyChart/Axis"));
const Legend = lazy(() => import("./HeavyChart/Legend"));
const Bars = lazy(() => import("./HeavyChart/Bars"));
/* dozens more… */

export default function HeavyChart() {
  return (
    <Suspense fallback={<p>Chart loading…</p>}>
      <Axis />
      <Legend />
      <Bars /> {/* → dozens of small network requests */}
    </Suspense>
  );
}
// Each sub‑component triggers its own HTTP request; blocking until
// *all* arrive often feels slower than loading one consolidated chunk.

Key Takeaways

  • Split by user journey, not by every component: Routes, feature panels, and big third‑party libs are prime candidates.
  • Use one Suspense boundary per split group: Keeps loading UI simple and avoids nested spinners.
  • Bundle size vs. request count: Fewer, moderately sized chunks typically beat dozens of tiny files.
  • Measure! Verify impact with Webpack Bundle Analyzer or Chrome’s Coverage tab instead of guessing.
  • Server‑side / streaming friendly: Route‑level code splitting pairs nicely with React Server Components and HTTP streaming for even faster perceived loads.