Big bundles make apps feel sluggish. Code splitting ships only what’s needed when it’s needed. Suspense gives you a clean way to show fallbacks (spinners/skeletons) while chunks/data load. This guide is beginner‑friendly and copy‑paste safe.
🎯 What you’ll do
- Split code at route and component level with
React.lazy - Wrap chunks in Suspense with polished fallbacks
- Add Error Boundaries so chunk or render failures don’t blank your app
- Preload chunks on hover/touch for instant nav
- Use Vite goodies like
import.meta.globfor large lazy trees - Show skeleton UIs instead of jumpy spinners
Works with Vite + React Router 6+ (what we’ve been using).
1) Route‑level code splitting with React.lazy
Each route becomes its own chunk. React loads it on demand.
// src/app/AppRouter.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Suspense, lazy } from 'react';
import Layout from '@/app/Layout';
import NotFound from '@/pages/errors/NotFound';
import ErrorBoundary from '@/app/ErrorBoundary';
// One chunk per page
const Home = lazy(() => import('@/pages/home/Home'));
const Tasks = lazy(() => import('@/pages/tasks/Tasks'));
const TaskDetails = lazy(() => import('@/pages/tasks/TaskDetails'));
const Settings = lazy(() => import('@/pages/settings/Settings'));
export default function AppRouter() {
return (
<BrowserRouter>
{/* Top-level Suspense to catch any lazy route loads */}
<Suspense fallback={<RouteFallback />}>
<Routes>
<Route element={<Layout />}>
{/* Error boundary per branch keeps failures local */}
<Route
path="/"
element={
<ErrorBoundary fallback={<ErrorFallback where="Home" />}>
<Home />
</ErrorBoundary>
}
/>
<Route
path="/tasks"
element={
<ErrorBoundary fallback={<ErrorFallback where="Tasks" />}>
<Tasks />
</ErrorBoundary>
}
/>
<Route
path="/tasks/:id"
element={
<ErrorBoundary fallback={<ErrorFallback where="Task Details" />}>
<TaskDetails />
</ErrorBoundary>
}
/>
<Route
path="/settings"
element={
<ErrorBoundary fallback={<ErrorFallback where="Settings" />}>
<Settings />
</ErrorBoundary>
}
/>
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
// Polished route loader (better than plain "Loading…")
function RouteFallback() {
return (
<div className="p-6 grid gap-3 animate-pulse">
<div className="h-6 w-40 bg-gray-200 rounded" />
<div className="h-4 w-3/4 bg-gray-200 rounded" />
<div className="h-4 w-2/3 bg-gray-200 rounded" />
</div>
);
}
function ErrorFallback({ where }: { where: string }) {
return (
<div className="p-6 text-red-700">
<p>Something went wrong while loading <b>{where}</b>. Please retry.</p>
</div>
);
}
2) Error Boundary (tiny, production‑ready)
If a lazy chunk throws (network error or runtime), this boundary prevents a full‑app crash.
// src/app/ErrorBoundary.tsx
import { Component, type ReactNode } from 'react';
type Props = { fallback: ReactNode; children: ReactNode };
type State = { hasError: boolean };
export default class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError() { return { hasError: true }; }
componentDidCatch(err: unknown) { console.error('Boundary caught', err); }
render() { return this.state.hasError ? this.props.fallback : this.props.children; }
}
3) Component‑level splitting (heavy widgets)
Split off seldom‑used widgets so the main view loads fast.
// src/features/charts/TaskChart.lazy.tsx
import { lazy, Suspense } from 'react';
const TaskChart = lazy(() => import('./TaskChart')); // heavy chart lib inside
export function TaskChartSection() {
return (
<Suspense fallback={<ChartSkeleton />}>
<TaskChart />
</Suspense>
);
}
function ChartSkeleton() {
return <div className="h-48 bg-gray-200 animate-pulse rounded" />;
}
4) Preload chunks on hover/touch (instant navigation)
Kick off the dynamic import before the user clicks.
// src/components/NavPreload.tsx
import { useRef } from 'react';
export function PreloadLink({ label, to, preload }: { label: string; to: string; preload: () => Promise<unknown>; }) {
const warmed = useRef(false);
const warm = () => { if (!warmed.current) { warmed.current = true; preload(); } };
return (
<a
href={to}
onMouseEnter={warm}
onTouchStart={warm}
className="underline"
>
{label}
</a>
);
}
Use it with your lazy routes:
// somewhere in a nav
import { PreloadLink } from '@/components/NavPreload';
// must match the dynamic import used by React.lazy
const preloadTasks = () => import('@/pages/tasks/Tasks');
<PreloadLink label="Tasks" to="/tasks" preload={preloadTasks} />
Why this works: The browser fetches the chunk during hover; the eventual navigation finds it already cached.
5) Vite’s import.meta.glob for many lazy screens
When you have a folder of feature pages, import.meta.glob gives a typed map of lazy importers.
// src/app/lazyMap.ts
// Eager: false (default) returns functions you can call to import modules on demand
export const pages = import.meta.glob('../pages/**/index.tsx');
// usage:
// const loadUsers = pages['../pages/users/index.tsx'];
// const Users = lazy(async () => await loadUsers!());
6) Data fetching with Suspense (optional)
If you enable Suspense in your data layer (e.g., TanStack Query’s suspense: true), you can colocate loading UI with the component tree.
// src/features/tasks/TasksSuspense.tsx
import { Suspense } from 'react';
import { useQuery } from '@tanstack/react-query';
import { listTasks } from '@/lib/api/tasks';
function TasksList() {
const { data } = useQuery({
queryKey: ['tasks'],
queryFn: () => listTasks({ page: 1, pageSize: 10 }),
suspense: true, // throws a promise to nearest Suspense boundary
select: (d) => d.items,
});
return (
<ul className="space-y-2">{data!.map(t => <li key={t.id}>{t.title}</li>)}</ul>
);
}
export default function TasksPageSuspense() {
return (
<Suspense fallback={<ListSkeleton />}>
<TasksList />
</Suspense>
);
}
function ListSkeleton() {
return (
<div className="space-y-2 animate-pulse">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-4 bg-gray-200 rounded" />
))}
</div>
);
}
Suspense centralizes loading UIs. Prefer skeletons over spinners for lists/forms to reduce layout shift.
7) Error handling with Suspense‑aware data (React Query)
Pair Suspense with an error boundary to catch rejected promises/mutations.
// src/pages/tasks/TasksWithBoundaries.tsx
import { Suspense } from 'react';
import ErrorBoundary from '@/app/ErrorBoundary';
import TasksPageSuspense from '@/features/tasks/TasksSuspense';
export default function TasksWithBoundaries() {
return (
<ErrorBoundary fallback={<div className="p-4 text-red-700">Failed to load tasks.</div>}>
<Suspense fallback={<div className="p-4">Loading tasks…</div>}>
<TasksPageSuspense />
</Suspense>
</ErrorBoundary>
);
}
8) Skeleton patterns you can reuse
- Heading + paragraph blocks for pages
- List rows (fixed height gray bars)
- Cards with image + text placeholders
Quick utility:
// src/components/skeletons/Blocks.tsx
export function Blocks({ lines = 3 }: { lines?: number }) {
return (
<div className="animate-pulse space-y-2">
{Array.from({ length: lines }).map((_, i) => (
<div key={i} className="h-4 bg-gray-200 rounded" />
))}
</div>
);
}
9) Production tips
- Bundle analyze: run
vite build --mode productionthen add a plugin likerollup-plugin-visualizerto inspect chunks. - Avoid oversized vendors: import only what you use; prefer ESM builds; consider lighter libs.
- Group rarely‑used features: lazy entire feature folders.
- Preload critical chunks on likely paths with the hover trick.
✅ Wrap‑Up
You now ship smaller initial bundles, show smoother loading UIs, and isolate failures with error boundaries. Start by lazy‑loading routes, add skeletons, then sprinkle component‑level lazy + preloading where it matters.
Part 15 is live on your canvas now—focused only on Code Splitting & Suspense with:
- Lazy routes & component-level splitting
- Suspense fallbacks and skeletons
- Error boundaries
- Hover preloading
import.meta.globpattern- Optional Suspense with React Query
Comments
Post a Comment