Skip to main content

🧱 Part 15 — Code Splitting & Suspense (Lazy Routes, Error Boundaries, Preloading, Skeletons) 🧩


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.glob for 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 production then add a plugin like rollup-plugin-visualizer to 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.glob pattern
  • Optional Suspense with React Query

Comments

Popular posts from this blog

🌟 Dot net Microservices interview questions

Here is a comprehensive list of 200 .NET microservices coding questions covering all core microservices concepts and cross-cutting concerns relevant for designing, building, deploying, and maintaining .NET-based distributed systems. 🧩 A. Microservices Fundamentals (20) Build a microservice in .NET 8 that exposes a simple CRUD API. Implement communication between two microservices using REST. How would you design microservices for an e-commerce application? Create a microservice that handles user registration and login. How do you isolate domain logic in a microservice? How to apply the "Single Responsibility Principle" in microservices? Design a service registry/discovery mechanism using custom middleware. Implement a service that handles file uploads and metadata separately. Build a stateless microservice and explain its benefits. Implement health check endpoints in .NET 8. Demonstrate versioning in a microservice API. Add Swagger/OpenAPI support to your m...

⚡ Part 1: Introduction to Generics in C#

🌍 Why Do We Need Generics? Imagine you want to create a stack (like a pile of books 📚): You can push items on top You can pop items off the top If we write a stack for integers : public class IntStack { private int[] items = new int[10]; private int index = 0; public void Push(int item) => items[index++] = item; public int Pop() => items[--index]; } 👉 Problem: This only works for int . What if we want a string stack ? Or a Customer stack ? We’d have to duplicate code for every type. 😢 ✅ Solution: Generics Generics let us create type-safe reusable code without duplication. We can say: “I don’t care what type it is yet — I’ll decide later.” 1) Generic Classes Here’s a generic stack : // Generic class "Stack<T>" // The <T> is a placeholder for any type public class Stack<T> { private T[] items = new T[10]; // Array of type T private int index = 0; // Push adds an item of type T public void P...

🚪 Part 9: API Gateway for .NET 8 Microservices (Ocelot & YARP)

Once you have multiple microservices (Products, Orders, Payments…), exposing each one directly to clients gets messy: Different base URLs Duplicated auth logic No unified rate limiting / caching Hard to evolve routes or aggregate data 👉 Enter the API Gateway — your single front door for all microservices. An API Gateway handles: ✅ Routing & path rewriting ✅ Load balancing, retries, circuit breakers ✅ Authentication & Authorization (JWT, OAuth2) ✅ Rate limiting & caching ✅ Aggregation (compose results from multiple services) In this post we’ll implement two strong options: Ocelot → config-driven, mature, DevOps-friendly YARP (Yet Another Reverse Proxy) → Microsoft’s code-first, extensible gateway ⚖️ Ocelot vs YARP — When to Choose Ocelot → JSON config, minimal C#, built-in QoS (rate limit, circuit breaker). Perfect for teams that like DevOps config-as-code. YARP → full C# control, middleware-friendly, can embed into broader apps (e.g. add dashb...