Skip to main content

πŸ”’ Lesson 6.3: Route Protection and Loading

Every serious app has rooms that not everyone should walk into β€” a dashboard, an admin panel, a billing page. In this lesson you'll learn to put a friendly-but-firm bouncer on those doors (authentication guards), speed up the whole building by only loading rooms as people enter them (lazy loading + Suspense), and keep visitors comfortable while they wait or when something breaks (loading states and error boundaries). By the end you'll be able to build the routing layer of a real, production-ready React app. πŸ›οΈ

🧭 Versions used in this lesson

The code here targets React 19, TypeScript 5.x, and React Router v7 (the current major release). We use function components throughout. Where React Router offers two ways to do something β€” the classic component-guard approach and the newer data-router approach with loaders β€” we'll show both and call out which one to reach for. In v7, the router APIs live in the react-router package; the older react-router-dom still re-exports them, but new code should import from react-router.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Implement protected routes that require authentication (both a guard component and a loader-based redirect)
  • Create flexible redirect patterns that preserve the user's intended destination
  • Use React.lazy() and <Suspense> for code splitting
  • Optimize bundle size with lazy-loaded routes
  • Show global pending UI with the data router's useNavigation(), and stream data with deferred loaders
  • Handle routing errors with error boundaries
  • Build role-based and permission-based access control for routes
  • Type authentication and loading states properly with TypeScript

Estimated Time: 75-90 minutes

Prerequisites: Lessons 6.1 & 6.2 - React Router Basics and Advanced Routing

In This Lesson

πŸ›‘οΈ Protected Routes Overview

Think of a protected route as a velvet rope outside a club. Anyone can walk up to the door, but before they step inside, a bouncer checks their wristband. No wristband? You get politely pointed to the sign-up desk β€” and once you sort things out, you're walked right back to the door you were trying to reach. That "walk them back to where they wanted to go" detail is what separates a thoughtful app from an annoying one, and we'll build it properly.

In React, a "protected route" is any route whose component only renders when the user meets some condition β€” usually "is logged in," sometimes "has the right role." If the condition fails, we redirect instead of rendering. Simple idea, but the details (loading states, preserving the destination, avoiding redirect loops) are where real apps live or die.

πŸ“– Why Protected Routes Matter

  • Security - Prevent unauthorized access to sensitive data
  • User Experience - Guide users to login exactly when they need it, then send them back
  • Authorization - Control access based on user roles or permissions
  • Data Protection - Ensure only authenticated users can view or modify data
  • Session Management - Handle expired sessions gracefully instead of showing a broken page

The Protected Route Pattern

A protected route checks authentication status before rendering. If the user isn't authenticated, we redirect to the login page β€” but we first remember where they were headed, so we can return them there after they sign in:

flowchart TD A[User Navigates to Protected Route] --> B{"Authenticated?"} B -->|Yes| C[Render Protected Component] B -->|No| D[Redirect to Login] D --> E[Store Original Destination] E --> F[Show Login Page] F --> G[User Logs In] G --> H[Redirect to Original Destination] style A fill:#667eea,color:#fff style C fill:#48bb78,color:#fff style D fill:#fc8181,color:#fff

Common Protected Route Scenarios

Scenario Protection Type Example
User Dashboard Authentication Required /dashboard, /profile, /settings
Admin Panel Role-Based Access /admin/users, /admin/settings
Premium Content Subscription Status /premium/courses, /exclusive
Age-Restricted Age Verification /adult-content
Beta Features Feature Flag /beta/new-feature

Basic Protected Route Concept

Here's a simple conceptual example of how protected routes work:

// Conceptual example - we'll build this properly in the next section
function ProtectedRoute({ children }: { children: React.ReactNode }) {
  const isAuthenticated = checkAuthStatus(); // Check if user is logged in
  
  if (!isAuthenticated) {
    // Redirect to login if not authenticated
    return <Navigate to="/login" replace />;
  }
  
  // If authenticated, render the protected content
  return <>{children}</>;
}

// Usage in routes
<Routes>
  <Route path="/login" element={<Login />} />
  
  <Route 
    path="/dashboard" 
    element={
      <ProtectedRoute>
        <Dashboard />
      </ProtectedRoute>
    } 
  />
</Routes>

πŸ’‘ Key Concepts

  • Authentication Check - Verify user is logged in before rendering
  • Conditional Rendering - Show protected content or redirect based on auth state
  • Preserve Destination - Remember where user wanted to go for post-login redirect
  • Graceful Degradation - Provide clear feedback when access is denied

πŸ” Implementing Authentication Guards

Time to build the bouncer for real. A production auth guard has three moving parts: a source of truth for "who is logged in" (an auth context), a guard component that reads it and decides render-or-redirect, and a login flow that sends the user back where they came from. We'll build all three with full TypeScript typing so the compiler catches mistakes like forgetting to handle the loading state.

πŸ’‘ Two ways to guard a route in React Router v7

There are two idiomatic patterns, and this lesson covers both:

  • Guard component (this section) β€” wrap the page in a <ProtectedRoute> that reads context and renders <Navigate> when access is denied. Great when auth lives in React state/context.
  • Loader redirect (Section 3) β€” with the data router, a route's loader checks auth and throws redirect() before the component ever renders. This avoids a render-then-bounce flicker and is the modern default for data-router apps.

Pick one style per app and stay consistent. We start with the guard component because it maps most directly to "check a value, then render or redirect."

Setting Up Authentication Context

First, we'll create an authentication context β€” the single source of truth for auth state across the whole app. Think of it as the guest list the bouncer keeps checking:

// contexts/AuthContext.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';

interface User {
  id: number;
  email: string;
  name: string;
  role: 'user' | 'admin' | 'moderator';
}

interface AuthContextType {
  user: User | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  
  // Check authentication status on mount
  useEffect(() => {
    const checkAuth = async () => {
      try {
        // Check if user has valid token in localStorage
        const token = localStorage.getItem('authToken');
        
        if (token) {
          // Validate token with backend
          const response = await fetch('/api/auth/validate', {
            headers: {
              'Authorization': `Bearer ${token}`
            }
          });
          
          if (response.ok) {
            const userData = await response.json();
            setUser(userData);
          } else {
            // Invalid token, clear it
            localStorage.removeItem('authToken');
          }
        }
      } catch (error) {
        console.error('Auth check failed:', error);
      } finally {
        setIsLoading(false);
      }
    };
    
    checkAuth();
  }, []);
  
  const login = async (email: string, password: string) => {
    setIsLoading(true);
    
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ email, password })
      });
      
      if (!response.ok) {
        throw new Error('Login failed');
      }
      
      const { user, token } = await response.json();
      
      // Store token
      localStorage.setItem('authToken', token);
      
      // Update state
      setUser(user);
    } catch (error) {
      console.error('Login error:', error);
      throw error;
    } finally {
      setIsLoading(false);
    }
  };
  
  const logout = () => {
    localStorage.removeItem('authToken');
    setUser(null);
  };
  
  const value: AuthContextType = {
    user,
    isAuthenticated: !!user,
    isLoading,
    login,
    logout
  };
  
  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
}

// Custom hook to use auth context
export function useAuth() {
  const context = useContext(AuthContext);
  
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  
  return context;
}

Creating the ProtectedRoute Component

Now let's create a reusable ProtectedRoute component with proper TypeScript typing:

// components/ProtectedRoute.tsx
import { Navigate, useLocation } from 'react-router';
import { useAuth } from '../contexts/AuthContext';

interface ProtectedRouteProps {
  children: React.ReactNode;
  redirectTo?: string;
}

export function ProtectedRoute({ 
  children, 
  redirectTo = '/login' 
}: ProtectedRouteProps) {
  const { isAuthenticated, isLoading } = useAuth();
  const location = useLocation();
  
  // Show loading spinner while checking authentication
  if (isLoading) {
    return (
      <div className="loading-container">
        <div className="spinner"></div>
        <p>Checking authentication...</p>
      </div>
    );
  }
  
  // If not authenticated, redirect to login
  if (!isAuthenticated) {
    // Save the location they were trying to access
    return <Navigate to={redirectTo} state={{ from: location }} replace />;
  }
  
  // User is authenticated, render the protected content
  return <>{children}</>;
}

βœ… Key Features of This Implementation

  • Loading State - Shows spinner while checking auth status
  • Location Preservation - Saves where user wanted to go
  • Customizable Redirect - Can specify different login routes
  • Replace Navigation - Prevents back button issues
  • TypeScript Safety - Properly typed props and context

Using Protected Routes in Your App

Here's how to set up your application with authentication and protected routes:

// App.tsx
import { BrowserRouter, Routes, Route } from 'react-router';
import { AuthProvider } from './contexts/AuthContext';
import { ProtectedRoute } from './components/ProtectedRoute';

// Page components
import Home from './pages/Home';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Profile from './pages/Profile';
import Settings from './pages/Settings';
import AdminPanel from './pages/AdminPanel';

function App() {
  return (
    <AuthProvider>
      <BrowserRouter>
        <Routes>
          {/* Public routes */}
          <Route path="/" element={<Home />} />
          <Route path="/login" element={<Login />} />
          
          {/* Protected routes */}
          <Route 
            path="/dashboard" 
            element={
              <ProtectedRoute>
                <Dashboard />
              </ProtectedRoute>
            } 
          />
          
          <Route 
            path="/profile" 
            element={
              <ProtectedRoute>
                <Profile />
              </ProtectedRoute>
            } 
          />
          
          <Route 
            path="/settings" 
            element={
              <ProtectedRoute>
                <Settings />
              </ProtectedRoute>
            } 
          />
          
          {/* Admin route - we'll add role checking later */}
          <Route 
            path="/admin" 
            element={
              <ProtectedRoute>
                <AdminPanel />
              </ProtectedRoute>
            } 
          />
        </Routes>
      </BrowserRouter>
    </AuthProvider>
  );
}

export default App;

Creating the Login Component

The login component handles authentication and redirects users back to their intended destination:

// pages/Login.tsx
import { useState, FormEvent } from 'react';
import { useNavigate, useLocation } from 'react-router';
import { useAuth } from '../contexts/AuthContext';

interface LocationState {
  from?: {
    pathname: string;
  };
}

export default function Login() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  
  const { login } = useAuth();
  const navigate = useNavigate();
  const location = useLocation();
  
  // Get the location they came from, or default to dashboard
  const state = location.state as LocationState;
  const from = state?.from?.pathname || '/dashboard';
  
  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    setError('');
    setIsLoading(true);
    
    try {
      await login(email, password);
      
      // Redirect to the page they were trying to access
      navigate(from, { replace: true });
    } catch (err) {
      setError('Invalid email or password');
    } finally {
      setIsLoading(false);
    }
  };
  
  return (
    <div className="login-container">
      <h1>Login</h1>
      
      {state?.from && (
        <p className="info-message">
          Please log in to access {state.from.pathname}
        </p>
      )}
      
      <form onSubmit={handleSubmit}>
        {error && (
          <div className="error-message">{error}</div>
        )}
        
        <div className="form-group">
          <label htmlFor="email">Email</label>
          <input
            id="email"
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            required
            disabled={isLoading}
          />
        </div>
        
        <div className="form-group">
          <label htmlFor="password">Password</label>
          <input
            id="password"
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            required
            disabled={isLoading}
          />
        </div>
        
        <button type="submit" disabled={isLoading}>
          {isLoading ? 'Logging in...' : 'Login'}
        </button>
      </form>
    </div>
  );
}

⚠️ Security Considerations

  • Never trust client-side auth alone - Always validate on the server
  • Use HTTPS - Protect tokens and credentials in transit
  • Token expiration - Implement token refresh or re-authentication
  • Secure storage - Consider httpOnly cookies instead of localStorage for tokens
  • Input validation - Validate and sanitize all login inputs

πŸ‹οΈ Exercise: Build Protected User Profile

Create a protected profile page that requires authentication.

Requirements:

  1. Create a UserProfile component that displays user information
  2. Wrap it in a ProtectedRoute
  3. Show loading state while checking authentication
  4. Redirect to /login if not authenticated
  5. After login, redirect back to profile page
  6. Add a logout button that clears auth and redirects to home
πŸ’‘ Hint

Use the useAuth hook to access user data and logout function:

const { user, logout } = useAuth();

const handleLogout = () => {
  logout();
  navigate('/');
};
βœ… Solution
// pages/UserProfile.tsx
import { useAuth } from '../contexts/AuthContext';
import { useNavigate } from 'react-router';

export default function UserProfile() {
  const { user, logout } = useAuth();
  const navigate = useNavigate();
  
  const handleLogout = () => {
    logout();
    navigate('/', { replace: true });
  };
  
  if (!user) {
    return <div>Loading user data...</div>;
  }
  
  return (
    <div className="profile-container">
      <h1>πŸ‘€ User Profile</h1>
      
      <div className="profile-info">
        <div className="info-item">
          <label>Name:</label>
          <span>{user.name}</span>
        </div>
        
        <div className="info-item">
          <label>Email:</label>
          <span>{user.email}</span>
        </div>
        
        <div className="info-item">
          <label>Role:</label>
          <span>{user.role}</span>
        </div>
        
        <div className="info-item">
          <label>User ID:</label>
          <span>{user.id}</span>
        </div>
      </div>
      
      <button onClick={handleLogout} className="logout-btn">
        πŸšͺ Logout
      </button>
    </div>
  );
}

// App.tsx - Add this route
<Route 
  path="/profile" 
  element={
    <ProtectedRoute>
      <UserProfile />
    </ProtectedRoute>
  } 
/>

πŸ”„ Redirect Patterns and Strategies

Redirecting is the art of gently steering users where they need to be β€” to login when they're logged out, to the dashboard when they're already logged in, to an "access denied" page when they lack a role. Do it well and it feels invisible; do it poorly and you get the dreaded redirect loop where the browser bounces forever between two pages. Let's cover the common scenarios and the guardrails that keep them safe.

The Modern Way: Loader-Based Redirects with redirect()

Before we look at component guards, meet the approach React Router v7 nudges you toward. When you build your routes with the data router (createBrowserRouter), each route can have a loader that runs before the component renders. If the loader decides the user shouldn't be here, it simply throws a redirect() β€” no component mounts, no flash of protected content, no render-then-bounce flicker.

The analogy: instead of letting the guest walk into the room and then tapping them on the shoulder, the loader checks the wristband out in the hallway. If it's missing, they never see the inside of the room at all.

// auth.ts β€” a tiny helper the loaders can call
export async function requireUser(request: Request) {
  const user = await getCurrentUser(); // however you resolve the session

  if (!user) {
    // Remember where they were going, then bounce to login.
    const url = new URL(request.url);
    const params = new URLSearchParams({ from: url.pathname });
    throw redirect(`/login?${params.toString()}`);
  }

  return user; // available to the route via useLoaderData()
}

// routes.tsx
import { createBrowserRouter, redirect } from 'react-router';

export const router = createBrowserRouter([
  { path: '/', element: <Home /> },
  { path: '/login', element: <Login /> },
  {
    path: '/dashboard',
    element: <Dashboard />,
    // The guard IS the loader β€” it runs before Dashboard renders.
    loader: async ({ request }) => {
      const user = await requireUser(request);
      return { user };
    },
  },
]);

πŸ’‘ When to use which

Use loader redirects when you've adopted the data router (createBrowserRouter + <RouterProvider>) β€” they're faster and flicker-free because the redirect happens before render. Use the guard component pattern from Section 2 when your app renders <Routes> inside <BrowserRouter> and auth lives in React context. Both are valid in v7; just don't mix styles for the same route.

Common Redirect Scenarios

Scenario When to Use Pattern
Post-Login After successful authentication Redirect to intended destination or dashboard
Post-Logout After user logs out Redirect to home or login page
Already Authenticated User visits login while logged in Redirect to dashboard
Insufficient Permissions User lacks required role Redirect to unauthorized page
Not Found Route doesn't exist Redirect to 404 page
Session Expired Auth token expired during navigation Redirect to login with message

Preventing Login Page Access When Authenticated

Users who are already logged in shouldn't see the login page:

// components/PublicRoute.tsx
import { Navigate } from 'react-router';
import { useAuth } from '../contexts/AuthContext';

interface PublicRouteProps {
  children: React.ReactNode;
  redirectTo?: string;
}

export function PublicRoute({ 
  children, 
  redirectTo = '/dashboard' 
}: PublicRouteProps) {
  const { isAuthenticated, isLoading } = useAuth();
  
  if (isLoading) {
    return (
      <div className="loading-container">
        <div className="spinner"></div>
      </div>
    );
  }
  
  // If already authenticated, redirect to dashboard
  if (isAuthenticated) {
    return <Navigate to={redirectTo} replace />;
  }
  
  // Not authenticated, show the public page (login/register)
  return <>{children}</>;
}

// Usage
<Route 
  path="/login" 
  element={
    <PublicRoute>
      <Login />
    </PublicRoute>
  } 
/>

<Route 
  path="/register" 
  element={
    <PublicRoute>
      <Register />
    </PublicRoute>
  } 
/>

Handling Session Expiration

Detect and handle expired sessions gracefully:

// hooks/useAuthInterceptor.ts
import { useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useAuth } from '../contexts/AuthContext';

export function useAuthInterceptor() {
  const navigate = useNavigate();
  const { logout } = useAuth();
  
  useEffect(() => {
    // Intercept API responses
    const handleUnauthorized = (event: CustomEvent) => {
      if (event.detail.status === 401) {
        // Session expired
        logout();
        navigate('/login', {
          state: { 
            message: 'Your session has expired. Please log in again.',
            from: window.location.pathname
          },
          replace: true
        });
      }
    };
    
    window.addEventListener('unauthorized' as any, handleUnauthorized);
    
    return () => {
      window.removeEventListener('unauthorized' as any, handleUnauthorized);
    };
  }, [navigate, logout]);
}

// In your API utility
export async function fetchWithAuth(url: string, options: RequestInit = {}) {
  const token = localStorage.getItem('authToken');
  
  const response = await fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      'Authorization': `Bearer ${token}`
    }
  });
  
  if (response.status === 401) {
    // Dispatch custom event for session expiration
    window.dispatchEvent(
      new CustomEvent('unauthorized', { 
        detail: { status: 401 } 
      })
    );
  }
  
  return response;
}

Conditional Redirects Based on User State

Redirect users based on multiple conditions:

// components/ConditionalRedirect.tsx
import { Navigate } from 'react-router';
import { useAuth } from '../contexts/AuthContext';

interface ConditionalRedirectProps {
  children: React.ReactNode;
}

export function ConditionalRedirect({ children }: ConditionalRedirectProps) {
  const { user, isAuthenticated, isLoading } = useAuth();
  
  if (isLoading) {
    return <div>Loading...</div>;
  }
  
  // Not authenticated - redirect to login
  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }
  
  // Check if profile is complete
  if (!user?.profileComplete) {
    return <Navigate to="/complete-profile" replace />;
  }
  
  // Check if email is verified
  if (!user?.emailVerified) {
    return <Navigate to="/verify-email" replace />;
  }
  
  // Check if subscription is active (for premium features)
  if (!user?.subscriptionActive) {
    return <Navigate to="/subscription" replace />;
  }
  
  // All checks passed, render protected content
  return <>{children}</>;
}

βœ… Redirect Best Practices

  • Use replace: true - Prevents users from going back to redirect pages
  • Preserve state - Pass context about why redirect happened
  • Show messages - Inform users why they were redirected
  • Handle loading - Show loading state during auth checks
  • Chain carefully - Avoid redirect loops

404 and Fallback Routes

Handle unknown routes with a catch-all redirect:

// pages/NotFound.tsx
import { Link } from 'react-router';

export default function NotFound() {
  return (
    <div className="not-found">
      <h1>404 - Page Not Found</h1>
      <p>The page you're looking for doesn't exist.</p>
      <Link to="/">Go Home</Link>
    </div>
  );
}

// In App.tsx
<Routes>
  <Route path="/" element={<Home />} />
  {/* ... other routes */}
  
  {/* Catch-all route - must be last */}
  <Route path="*" element={<NotFound />} />
</Routes>

⚑ Lazy Loading Routes

Imagine a restaurant that cooked every dish on the menu the moment you sat down, just in case you ordered them. You'd wait forever and most of the food would go to waste. That's what shipping your entire app as one giant bundle feels like β€” the browser downloads the admin panel, the reports page, and the settings screen before the user has clicked anything.

Lazy loading fixes this by splitting your app into bite-sized chunks that load on demand. The home page loads instantly; the dashboard's code only downloads when someone actually navigates to it. The result is a dramatically smaller first download and a snappier initial paint β€” often the single biggest performance win you can make in a React app.

flowchart LR A[User Loads App] --> B[Initial Bundle] B --> C[Home Page Only] A --> D[User Navigates to Dashboard] D --> E[Load Dashboard Chunk] E --> F[Render Dashboard] A --> G[User Navigates to Profile] G --> H[Load Profile Chunk] H --> I[Render Profile] style B fill:#667eea,color:#fff style E fill:#48bb78,color:#fff style H fill:#48bb78,color:#fff

Why Lazy Load Routes?

πŸ“Š Performance Benefits

  • Smaller Initial Bundle - Faster first page load
  • Better Performance - Only load code when needed
  • Improved Caching - Chunks can be cached separately, so a change to one page doesn't bust the cache for all of them
  • Bandwidth Savings - Users only download what they use
  • Progressive Loading - The app becomes interactive faster

Basic React.lazy() Usage

Use lazy() to turn a normal import into a dynamic one. Instead of pulling the component into the main bundle, you hand lazy() a function that imports it β€” and the bundler (Vite, webpack, etc.) automatically carves that import into its own chunk:

// Traditional import (not lazy)
import Dashboard from './pages/Dashboard';

// Lazy import
const Dashboard = lazy(() => import('./pages/Dashboard'));

Setting Up Lazy Routes

Here's how to implement lazy loading in your application:

// App.tsx
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router';
import { AuthProvider } from './contexts/AuthContext';
import { ProtectedRoute } from './components/ProtectedRoute';

// Eager-loaded components (needed immediately)
import Home from './pages/Home';
import Login from './pages/Login';

// Lazy-loaded components (loaded on demand)
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Profile = lazy(() => import('./pages/Profile'));
const Settings = lazy(() => import('./pages/Settings'));
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
const Reports = lazy(() => import('./pages/Reports'));

// Loading fallback component
function LoadingFallback() {
  return (
    <div className="loading-container">
      <div className="spinner"></div>
      <p>Loading page...</p>
    </div>
  );
}

function App() {
  return (
    <AuthProvider>
      <BrowserRouter>
        <Suspense fallback={<LoadingFallback />}>
          <Routes>
            {/* Eager-loaded routes */}
            <Route path="/" element={<Home />} />
            <Route path="/login" element={<Login />} />
            
            {/* Lazy-loaded protected routes */}
            <Route 
              path="/dashboard" 
              element={
                <ProtectedRoute>
                  <Dashboard />
                </ProtectedRoute>
              } 
            />
            
            <Route 
              path="/profile" 
              element={
                <ProtectedRoute>
                  <Profile />
                </ProtectedRoute>
              } 
            />
            
            <Route 
              path="/settings" 
              element={
                <ProtectedRoute>
                  <Settings />
                </ProtectedRoute>
              } 
            />
            
            <Route 
              path="/admin" 
              element={
                <ProtectedRoute>
                  <AdminPanel />
                </ProtectedRoute>
              } 
            />
            
            <Route 
              path="/reports" 
              element={
                <ProtectedRoute>
                  <Reports />
                </ProtectedRoute>
              } 
            />
          </Routes>
        </Suspense>
      </BrowserRouter>
    </AuthProvider>
  );
}

export default App;

⚠️ Important: Suspense Wrapper

Lazy-loaded components must be wrapped in a <Suspense> component. The fallback prop specifies what to show while the component is loading.

<Suspense fallback={<LoadingFallback />}>
  {/* Lazy routes go here */}
</Suspense>

Custom Loading Fallback

Create a professional loading component for better UX:

// components/LoadingFallback.tsx
export function LoadingFallback() {
  return (
    <div className="page-loading">
      <div className="loading-content">
        <div className="spinner-large"></div>
        <h2>Loading...</h2>
        <p>Please wait while we prepare your page</p>
      </div>
    </div>
  );
}

// With skeleton loading
export function SkeletonLoading() {
  return (
    <div className="skeleton-container">
      <div className="skeleton-header"></div>
      <div className="skeleton-content">
        <div className="skeleton-line"></div>
        <div className="skeleton-line"></div>
        <div className="skeleton-line short"></div>
      </div>
    </div>
  );
}

Lazy Loading with Error Handling

Handle loading errors gracefully:

// utils/lazyWithRetry.ts
import { lazy, ComponentType } from 'react';

export function lazyWithRetry<T extends ComponentType<any>>(
  importFunc: () => Promise<{ default: T }>,
  retries = 3
) {
  return lazy(() => {
    return new Promise<{ default: T }>((resolve, reject) => {
      // Track the number of attempts
      let attempts = 0;
      
      const attemptImport = () => {
        attempts++;
        
        importFunc()
          .then(resolve)
          .catch((error) => {
            if (attempts >= retries) {
              reject(error);
            } else {
              // Wait before retrying (exponential backoff)
              const delay = Math.min(1000 * Math.pow(2, attempts), 5000);
              setTimeout(attemptImport, delay);
            }
          });
      };
      
      attemptImport();
    });
  });
}

// Usage
const Dashboard = lazyWithRetry(() => import('./pages/Dashboard'));

Preloading Routes

Preload routes that users are likely to visit next:

// Preload a route
const DashboardImport = () => import('./pages/Dashboard');
const Dashboard = lazy(DashboardImport);

// In your component
function Navigation() {
  const handleMouseEnter = () => {
    // Preload dashboard when user hovers over link
    DashboardImport();
  };
  
  return (
    <nav>
      <Link 
        to="/dashboard"
        onMouseEnter={handleMouseEnter}
      >
        Dashboard
      </Link>
    </nav>
  );
}

βœ… Lazy Loading Best Practices

  • Lazy load by route - Each route should be its own chunk
  • Keep critical paths eager - Don't lazy load login or home pages
  • Group related features - Bundle related pages together
  • Provide good loading UX - Use skeletons or meaningful loading states
  • Test on slow connections - Ensure good experience on 3G
  • Monitor chunk sizes - Keep chunks under 200KB when possible

What to Eager Load vs Lazy Load

Eager Load (Immediate) Lazy Load (On Demand)
Home page Dashboard pages
Login/Register pages Admin panels
Critical navigation Settings pages
Error boundaries Reports and analytics
Auth context User profile editor
Loading components Modal dialogs

πŸ‹οΈ Exercise: Implement Lazy Loading

Convert a multi-route application to use lazy loading for better performance.

Requirements:

  1. Create 5 different page components (Home, About, Products, Contact, Dashboard)
  2. Eager load Home and About pages
  3. Lazy load Products, Contact, and Dashboard
  4. Add a custom loading fallback with a spinner
  5. Wrap lazy routes in Suspense
  6. Add preloading to the navigation links
πŸ’‘ Hint

Structure your imports like this:

// Eager
import Home from './pages/Home';

// Lazy
const Products = lazy(() => import('./pages/Products'));

// With preload
const ProductsImport = () => import('./pages/Products');
const Products = lazy(ProductsImport);
βœ… Solution
// App.tsx
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Link } from 'react-router';

// Eager-loaded pages
import Home from './pages/Home';
import About from './pages/About';

// Lazy-loaded pages with preload functions
const ProductsImport = () => import('./pages/Products');
const Products = lazy(ProductsImport);

const ContactImport = () => import('./pages/Contact');
const Contact = lazy(ContactImport);

const DashboardImport = () => import('./pages/Dashboard');
const Dashboard = lazy(DashboardImport);

// Loading component
function LoadingSpinner() {
  return (
    <div style={{ 
      display: 'flex', 
      justifyContent: 'center', 
      alignItems: 'center',
      height: '50vh' 
    }}>
      <div className="spinner"></div>
      <p>Loading...</p>
    </div>
  );
}

// Navigation with preloading
function Navigation() {
  return (
    <nav>
      <Link to="/">Home</Link>
      <Link to="/about">About</Link>
      <Link 
        to="/products"
        onMouseEnter={() => ProductsImport()}
      >
        Products
      </Link>
      <Link 
        to="/contact"
        onMouseEnter={() => ContactImport()}
      >
        Contact
      </Link>
      <Link 
        to="/dashboard"
        onMouseEnter={() => DashboardImport()}
      >
        Dashboard
      </Link>
    </nav>
  );
}

function App() {
  return (
    <BrowserRouter>
      <Navigation />
      
      <Suspense fallback={<LoadingSpinner />}>
        <Routes>
          {/* Eager-loaded routes */}
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          
          {/* Lazy-loaded routes */}
          <Route path="/products" element={<Products />} />
          <Route path="/contact" element={<Contact />} />
          <Route path="/dashboard" element={<Dashboard />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

export default App;

⏳ React Suspense for Code Splitting

Here's the mental model: <Suspense> is a component that says, "if anything inside me isn't ready yet, show this fallback in the meantime β€” and swap in the real thing the moment it's ready." You wrap it around anything that might need to wait, and React handles the timing for you. No manual isLoading flags, no juggling booleans; you just declare "loading looks like this," and React does the choreography.

πŸ“– What is Suspense?

Suspense is a React component that lets you "wait" for something to be ready and declaratively specify a loading state while waiting. As of React 19 it's the standard way to handle:

  • Lazy-loaded components with React.lazy() (our focus in this lesson)
  • Reading a promise with the new use() hook
  • Data fetching in Suspense-enabled frameworks and libraries (React Router's deferred loaders, Next.js, React Query, Relay, and more)

Basic Suspense Usage

The simplest Suspense implementation:

import { Suspense, lazy } from 'react';

const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <HeavyComponent />
    </Suspense>
  );
}

Nested Suspense Boundaries

You can have multiple Suspense boundaries for fine-grained loading control:

import { Suspense, lazy } from 'react';

const Sidebar = lazy(() => import('./Sidebar'));
const MainContent = lazy(() => import('./MainContent'));
const Comments = lazy(() => import('./Comments'));

function Page() {
  return (
    <div className="page">
      {/* Separate loading state for sidebar */}
      <Suspense fallback={<div>Loading sidebar...</div>}>
        <Sidebar />
      </Suspense>
      
      <div className="main">
        {/* Separate loading state for main content */}
        <Suspense fallback={<div>Loading content...</div>}>
          <MainContent />
        </Suspense>
        
        {/* Separate loading state for comments */}
        <Suspense fallback={<div>Loading comments...</div>}>
          <Comments />
        </Suspense>
      </div>
    </div>
  );
}
flowchart TD A[Page Component] --> B[Suspense: Sidebar] A --> C[Suspense: Main Content] A --> D[Suspense: Comments] B --> E{"Sidebar Loaded?"} C --> F{"Content Loaded?"} D --> G{"Comments Loaded?"} E -->|No| H[Show Sidebar Fallback] E -->|Yes| I[Render Sidebar] F -->|No| J[Show Content Fallback] F -->|Yes| K[Render Content] G -->|No| L[Show Comments Fallback] G -->|Yes| M[Render Comments] style A fill:#667eea,color:#fff style I fill:#48bb78,color:#fff style K fill:#48bb78,color:#fff style M fill:#48bb78,color:#fff

Suspense with Multiple Components

A single Suspense boundary can handle multiple lazy components:

import { Suspense, lazy } from 'react';

const UserProfile = lazy(() => import('./UserProfile'));
const UserPosts = lazy(() => import('./UserPosts'));
const UserActivity = lazy(() => import('./UserActivity'));

function UserDashboard() {
  return (
    <Suspense fallback={<div>Loading dashboard...</div>}>
      {/* All three components share the same loading state */}
      <UserProfile />
      <UserPosts />
      <UserActivity />
    </Suspense>
  );
}

// This will show "Loading dashboard..." until ALL three components are loaded

Advanced Suspense Patterns

Create a reusable SuspenseRoute component:

// components/SuspenseRoute.tsx
import { Suspense, ComponentType } from 'react';

interface SuspenseRouteProps {
  component: ComponentType<any>;
  fallback?: React.ReactNode;
}

export function SuspenseRoute({ 
  component: Component, 
  fallback = <LoadingFallback /> 
}: SuspenseRouteProps) {
  return (
    <Suspense fallback={fallback}>
      <Component />
    </Suspense>
  );
}

// Usage
const Dashboard = lazy(() => import('./pages/Dashboard'));

<Route 
  path="/dashboard" 
  element={<SuspenseRoute component={Dashboard} />} 
/>

Suspense with Error Boundaries

Combine Suspense with Error Boundaries for robust loading:

import { Suspense, lazy } from 'react';
import { ErrorBoundary } from './ErrorBoundary';

const Dashboard = lazy(() => import('./pages/Dashboard'));

function App() {
  return (
    <ErrorBoundary fallback={<ErrorPage />}>
      <Suspense fallback={<LoadingPage />}>
        <Dashboard />
      </Suspense>
    </ErrorBoundary>
  );
}

// If Dashboard fails to load: ErrorPage is shown
// While Dashboard is loading: LoadingPage is shown
// When Dashboard loads successfully: Dashboard is rendered

Transitions with Suspense

Use useTransition to keep old UI visible during loading:

import { Suspense, lazy, useTransition, useState } from 'react';

const TabA = lazy(() => import('./TabA'));
const TabB = lazy(() => import('./TabB'));
const TabC = lazy(() => import('./TabC'));

function TabbedInterface() {
  const [activeTab, setActiveTab] = useState('a');
  const [isPending, startTransition] = useTransition();
  
  const selectTab = (tab: string) => {
    startTransition(() => {
      setActiveTab(tab);
    });
  };
  
  return (
    <div>
      <div className="tabs">
        <button 
          onClick={() => selectTab('a')}
          disabled={isPending}
        >
          Tab A {isPending && activeTab === 'a' && '⏳'}
        </button>
        <button 
          onClick={() => selectTab('b')}
          disabled={isPending}
        >
          Tab B {isPending && activeTab === 'b' && '⏳'}
        </button>
        <button 
          onClick={() => selectTab('c')}
          disabled={isPending}
        >
          Tab C {isPending && activeTab === 'c' && '⏳'}
        </button>
      </div>
      
      <Suspense fallback={<div>Loading tab...</div>}>
        {activeTab === 'a' && <TabA />}
        {activeTab === 'b' && <TabB />}
        {activeTab === 'c' && <TabC />}
      </Suspense>
    </div>
  );
}

βœ… Suspense Best Practices

  • Granular boundaries - Use multiple Suspense for independent sections
  • Meaningful fallbacks - Show context-appropriate loading states
  • Avoid too many boundaries - Don't overuse, it can feel janky
  • Position carefully - Place Suspense where loading makes sense
  • Combine with ErrorBoundary - Always handle loading failures
  • Test slow networks - See how Suspense feels on 3G

πŸ“Š Loading States and User Feedback

A loading state is a promise you make to the user: "I heard you, something's happening, hang tight." Skip it and your app feels broken β€” the user clicks, nothing visibly changes, and they click again (now you're double-submitting). Get it right and even a slow network feels intentional and calm. In this section we'll cover the toolbox of loading indicators, then wire up a global pending bar using React Router's data router so navigations always feel responsive.

Global Pending UI with useNavigation()

When you use the data router, React Router tracks navigation state for you. The useNavigation() hook exposes it, so you can show a single top-level indicator whenever any route is transitioning β€” no per-page bookkeeping required. Put this in your root layout and every navigation gets a progress cue for free:

// RootLayout.tsx
import { Outlet, useNavigation } from 'react-router';

export function RootLayout() {
  const navigation = useNavigation();
  // 'idle' | 'loading' | 'submitting'
  const isNavigating = navigation.state !== 'idle';

  return (
    <div>
      {/* A slim top bar that only appears mid-navigation */}
      {isNavigating && (
        <div className="global-progress" role="status" aria-live="polite">
          Loading…
        </div>
      )}

      {/* Child route renders here */}
      <Outlet />
    </div>
  );
}

πŸ’‘ Why this beats a per-page spinner

With a data-router loader, React Router keeps the old page on screen while the next page's loader runs, then swaps in one clean render. A global useNavigation() bar signals "working on it" during that gap without the jarring blank-screen flash you get when you unmount a page and show a full-screen spinner.

Streaming Slow Data with Deferred Loaders

Sometimes one part of a page is fast (the user's name) and another is slow (their analytics). Instead of making the whole page wait for the slow part, the data router lets you defer it: return the fast data immediately and hand the slow data off as a promise. You render the page right away, and <Suspense> + <Await> fill in the slow bit when it arrives:

// dashboard.tsx
import { Suspense } from 'react';
import { Await, useLoaderData } from 'react-router';

// Loader returns fast data now, slow data as a promise (no await!)
export async function loader() {
  return {
    user: await getUser(),        // fast β€” awaited
    stats: getDashboardStats(),   // slow β€” a promise, deferred
  };
}

export default function Dashboard() {
  const { user, stats } = useLoaderData() as {
    user: User;
    stats: Promise<Stats>;
  };

  return (
    <div>
      <h1>Welcome back, {user.name}!</h1>

      <Suspense fallback={<p>Crunching your numbers…</p>}>
        <Await resolve={stats}>
          {(resolved) => <StatsPanel stats={resolved} />}
        </Await>
      </Suspense>
    </div>
  );
}

The user sees their dashboard shell instantly, with a friendly placeholder where the stats will land. That's a far better experience than staring at a blank spinner until the slowest query finishes. πŸš€

Types of Loading Indicators

Type When to Use Example
Spinner Unknown duration, small area Loading a component or API call
Progress Bar Known duration or percentage File upload, multi-step process
Skeleton Screen Content with known layout Social media feeds, cards
Shimmer Effect Content loading, modern look Lists, grids, cards
Text Message Complex operations "Processing payment...", "Saving..."
Inline Indicator Button actions Saving button, submit forms

Simple Spinner Component

A basic, reusable loading spinner:

// components/LoadingSpinner.tsx
interface LoadingSpinnerProps {
  size?: 'small' | 'medium' | 'large';
  message?: string;
}

export function LoadingSpinner({ 
  size = 'medium', 
  message 
}: LoadingSpinnerProps) {
  const sizeClasses = {
    small: 'w-4 h-4',
    medium: 'w-8 h-8',
    large: 'w-12 h-12'
  };
  
  return (
    <div className="flex flex-col items-center justify-center p-4">
      <div 
        className={`
          ${sizeClasses[size]} 
          border-4 
          border-gray-200 
          border-t-blue-500 
          rounded-full 
          animate-spin
        `}
        role="status"
        aria-label="Loading"
      ></div>
      {message && (
        <p className="mt-2 text-sm text-gray-600">{message}</p>
      )}
    </div>
  );
}

// Usage
<LoadingSpinner size="large" message="Loading your dashboard..." />

Skeleton Screen Component

Create content placeholders that match your layout:

// components/SkeletonCard.tsx
export function SkeletonCard() {
  return (
    <div className="border rounded-lg p-4 animate-pulse">
      {/* Header skeleton */}
      <div className="flex items-center space-x-3 mb-4">
        <div className="w-10 h-10 bg-gray-300 rounded-full"></div>
        <div className="flex-1">
          <div className="h-4 bg-gray-300 rounded w-3/4 mb-2"></div>
          <div className="h-3 bg-gray-200 rounded w-1/2"></div>
        </div>
      </div>
      
      {/* Content skeleton */}
      <div className="space-y-2">
        <div className="h-3 bg-gray-300 rounded"></div>
        <div className="h-3 bg-gray-300 rounded"></div>
        <div className="h-3 bg-gray-300 rounded w-5/6"></div>
      </div>
      
      {/* Image skeleton */}
      <div className="mt-4 h-48 bg-gray-300 rounded"></div>
      
      {/* Footer skeleton */}
      <div className="mt-4 flex justify-between">
        <div className="h-8 bg-gray-300 rounded w-20"></div>
        <div className="h-8 bg-gray-300 rounded w-20"></div>
      </div>
    </div>
  );
}

// Usage
function PostList() {
  const { posts, isLoading } = usePosts();
  
  if (isLoading) {
    return (
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        <SkeletonCard />
        <SkeletonCard />
        <SkeletonCard />
      </div>
    );
  }
  
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

Progress Bar Component

Show progress for operations with known completion:

// components/ProgressBar.tsx
interface ProgressBarProps {
  progress: number; // 0-100
  message?: string;
  showPercentage?: boolean;
}

export function ProgressBar({ 
  progress, 
  message,
  showPercentage = true 
}: ProgressBarProps) {
  return (
    <div className="w-full">
      {message && (
        <div className="flex justify-between mb-1">
          <span className="text-sm font-medium">{message}</span>
          {showPercentage && (
            <span className="text-sm font-medium">{progress}%</span>
          )}
        </div>
      )}
      
      <div className="w-full bg-gray-200 rounded-full h-2.5">
        <div 
          className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
          style={{ width: `${progress}%` }}
          role="progressbar"
          aria-valuenow={progress}
          aria-valuemin={0}
          aria-valuemax={100}
        ></div>
      </div>
    </div>
  );
}

// Usage
function FileUpload() {
  const [uploadProgress, setUploadProgress] = useState(0);
  
  return (
    <ProgressBar 
      progress={uploadProgress} 
      message="Uploading file..."
    />
  );
}

Full Page Loading Component

For route transitions and major loading states:

// components/PageLoading.tsx
interface PageLoadingProps {
  message?: string;
}

export function PageLoading({ 
  message = 'Loading...' 
}: PageLoadingProps) {
  return (
    <div className="fixed inset-0 bg-white flex items-center justify-center z-50">
      <div className="text-center">
        {/* Logo or brand */}
        <div className="mb-6">
          <img 
            src="/logo.svg" 
            alt="Logo" 
            className="w-16 h-16 mx-auto"
          />
        </div>
        
        {/* Spinner */}
        <div className="flex justify-center mb-4">
          <div className="w-12 h-12 border-4 border-gray-200 border-t-blue-500 rounded-full animate-spin"></div>
        </div>
        
        {/* Message */}
        <p className="text-lg font-medium text-gray-700">{message}</p>
        <p className="text-sm text-gray-500 mt-2">
          This won't take long...
        </p>
      </div>
    </div>
  );
}

Loading Button States

Show loading state on buttons during async operations:

// components/LoadingButton.tsx
interface LoadingButtonProps {
  isLoading: boolean;
  children: React.ReactNode;
  onClick?: () => void;
  disabled?: boolean;
  type?: 'button' | 'submit' | 'reset';
}

export function LoadingButton({ 
  isLoading, 
  children, 
  onClick,
  disabled,
  type = 'button'
}: LoadingButtonProps) {
  return (
    <button
      type={type}
      onClick={onClick}
      disabled={isLoading || disabled}
      className={`
        px-4 py-2 rounded 
        ${isLoading ? 'opacity-75 cursor-not-allowed' : ''}
        bg-blue-500 hover:bg-blue-600 text-white
        flex items-center justify-center
      `}
    >
      {isLoading && (
        <span className="mr-2">
          <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
        </span>
      )}
      {children}
    </button>
  );
}

// Usage
function SaveForm() {
  const [isSaving, setIsSaving] = useState(false);
  
  const handleSave = async () => {
    setIsSaving(true);
    await saveData();
    setIsSaving(false);
  };
  
  return (
    <LoadingButton isLoading={isSaving} onClick={handleSave}>
      {isSaving ? 'Saving...' : 'Save Changes'}
    </LoadingButton>
  );
}

πŸ’‘ Loading UX Best Practices

  • Be specific - "Loading products..." vs "Loading..."
  • Match the content - Use skeletons that look like final content
  • Avoid flash - Don't show spinners for very quick operations (<200ms)
  • Provide context - Explain what's happening and why
  • Keep UI responsive - Allow users to cancel or navigate away
  • Progressive disclosure - Show content as it loads when possible

πŸ›‘ Error Boundaries with Routing

Without an error boundary, a single thrown error in one component takes down your entire React tree β€” the user gets a blank white screen and no idea what happened. An error boundary is like a circuit breaker in your house: when one appliance short-circuits, the breaker trips for that circuit only, and the rest of the lights stay on. It catches the error, shows a friendly fallback, and keeps the surrounding app alive.

πŸ“– What Are Error Boundaries?

Error boundaries are React components that:

  • Catch JavaScript errors thrown while rendering child components
  • Log error information for later diagnosis
  • Display fallback UI instead of crashing the whole app
  • Let the rest of the app keep working normally

Note: React still has no built-in hook for error boundaries, even in React 19 β€” a boundary must be a class component (or you use the popular react-error-boundary library, which wraps a class for you). If you're on the data router, React Router also lets you attach an errorElement/ErrorBoundary per route, which catches loader and render errors for that branch.

Creating an Error Boundary

Build a reusable error boundary component:

// components/ErrorBoundary.tsx
import { Component, ReactNode } from 'react';

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
  onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}

interface State {
  hasError: boolean;
  error: Error | null;
}

export class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = {
      hasError: false,
      error: null
    };
  }
  
  static getDerivedStateFromError(error: Error): State {
    // Update state so next render shows fallback UI
    return {
      hasError: true,
      error
    };
  }
  
  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    // Log error to error reporting service
    console.error('Error boundary caught:', error, errorInfo);
    
    // Call optional error handler
    this.props.onError?.(error, errorInfo);
    
    // You can also log to services like Sentry
    // logErrorToService(error, errorInfo);
  }
  
  handleReset = () => {
    this.setState({
      hasError: false,
      error: null
    });
  };
  
  render() {
    if (this.state.hasError) {
      // Custom fallback UI if provided
      if (this.props.fallback) {
        return this.props.fallback;
      }
      
      // Default fallback UI
      return (
        <div className="error-container">
          <h1>⚠️ Something went wrong</h1>
          <p>We're sorry, but something unexpected happened.</p>
          
          {this.state.error && (
            <details className="error-details">
              <summary>Error details</summary>
              <pre>{this.state.error.toString()}</pre>
            </details>
          )}
          
          <button onClick={this.handleReset}>
            Try Again
          </button>
        </div>
      );
    }
    
    return this.props.children;
  }
}

Using Error Boundaries with Routes

Wrap routes in error boundaries to catch loading and rendering errors:

// App.tsx
import { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router';
import { ErrorBoundary } from './components/ErrorBoundary';

const Dashboard = lazy(() => import('./pages/Dashboard'));
const Profile = lazy(() => import('./pages/Profile'));

function App() {
  return (
    <BrowserRouter>
      <ErrorBoundary>
        <Suspense fallback={<LoadingPage />}>
          <Routes>
            <Route path="/" element={<Home />} />
            
            {/* Each route is protected by the error boundary */}
            <Route path="/dashboard" element={<Dashboard />} />
            <Route path="/profile" element={<Profile />} />
          </Routes>
        </Suspense>
      </ErrorBoundary>
    </BrowserRouter>
  );
}

Route-Specific Error Boundaries

Create error boundaries for individual routes or route groups:

// Different error handling for different sections
function App() {
  return (
    <BrowserRouter>
      <Routes>
        {/* Public routes - simple error handling */}
        <Route 
          path="/" 
          element={
            <ErrorBoundary fallback={<PublicErrorPage />}>
              <Home />
            </ErrorBoundary>
          } 
        />
        
        {/* User routes - show support contact */}
        <Route 
          path="/dashboard" 
          element={
            <ErrorBoundary 
              fallback={<UserErrorPage />}
              onError={(error) => logToAnalytics(error)}
            >
              <Suspense fallback={<Loading />}>
                <Dashboard />
              </Suspense>
            </ErrorBoundary>
          } 
        />
        
        {/* Admin routes - detailed error info */}
        <Route 
          path="/admin/*" 
          element={
            <ErrorBoundary fallback={<AdminErrorPage />}>
              <AdminLayout />
            </ErrorBoundary>
          } 
        />
      </Routes>
    </BrowserRouter>
  );
}

Enhanced Error Page Component

Create a user-friendly error page with recovery options:

// pages/ErrorPage.tsx
import { useNavigate } from 'react-router';

interface ErrorPageProps {
  error?: Error;
  resetError?: () => void;
}

export function ErrorPage({ error, resetError }: ErrorPageProps) {
  const navigate = useNavigate();
  
  const handleGoHome = () => {
    resetError?.();
    navigate('/');
  };
  
  const handleReload = () => {
    resetError?.();
    window.location.reload();
  };
  
  return (
    <div className="error-page">
      <div className="error-content">
        <div className="error-icon">⚠️</div>
        
        <h1>Oops! Something went wrong</h1>
        
        <p className="error-message">
          We encountered an unexpected error. Don't worry, 
          your data is safe.
        </p>
        
        {error && import.meta.env.DEV && (
          <details className="error-details">
            <summary>Technical Details</summary>
            <pre>
              {error.name}: {error.message}
              {'\n\n'}
              {error.stack}
            </pre>
          </details>
        )}
        
        <div className="error-actions">
          <button onClick={handleGoHome} className="btn-primary">
            🏠 Go to Home
          </button>
          
          <button onClick={handleReload} className="btn-secondary">
            πŸ”„ Reload Page
          </button>
          
          {resetError && (
            <button onClick={resetError} className="btn-secondary">
              ↩️ Try Again
            </button>
          )}
        </div>
        
        <div className="error-help">
          <p>Need help?</p>
          <a href="/support">Contact Support</a>
          <span> β€’ </span>
          <a href="/status">System Status</a>
        </div>
      </div>
    </div>
  );
}

Handling Lazy Loading Errors

Specifically handle errors that occur during lazy loading:

// utils/lazyWithErrorHandling.ts
import { lazy, ComponentType } from 'react';

export function lazyWithErrorHandling<T extends ComponentType<any>>(
  importFunc: () => Promise<{ default: T }>,
  componentName: string
) {
  return lazy(() =>
    importFunc().catch((error) => {
      console.error(`Failed to load ${componentName}:`, error);
      
      // Return a fallback component
      return {
        default: (() => (
          <div className="lazy-error">
            <h2>Failed to load {componentName}</h2>
            <p>Please check your internet connection and try again.</p>
            <button onClick={() => window.location.reload()}>
              Reload Page
            </button>
          </div>
        )) as T
      };
    })
  );
}

// Usage
const Dashboard = lazyWithErrorHandling(
  () => import('./pages/Dashboard'),
  'Dashboard'
);

βœ… Error Boundary Best Practices

  • Place strategically - Wrap major sections, not every component
  • Log errors - Send to error tracking service (Sentry, LogRocket)
  • Provide recovery - Allow users to retry or navigate away
  • Show less in production - Hide stack traces from users
  • Test error states - Throw test errors to verify boundaries work
  • Don't catch everything - Event handlers need try-catch

What Error Boundaries Don't Catch

⚠️ Limitations

Error boundaries do NOT catch errors in:

  • Event handlers (use try-catch)
  • Asynchronous code (setTimeout, promises)
  • Server-side rendering
  • Errors thrown in the error boundary itself

For these cases, use traditional try-catch blocks.

πŸ‘₯ Role-Based Access Control

Authentication answers "who are you?" Authorization answers "what are you allowed to do?" β€” and Role-Based Access Control (RBAC) is the most common way to answer that second question. Think of a concert: a general-admission ticket gets you in the door (authenticated), but only a backstage wristband lets you into the green room (authorized). A regular user, a moderator, and an admin all logged in successfully, yet each should see a different set of doors.

RBAC layers a role check on top of the authentication check: first confirm the user is logged in, then confirm their role is on the allowed list for this route. Miss either check and they're redirected β€” to login if they're not authenticated, to an "access denied" page if they're logged in but under-privileged.

Understanding RBAC

flowchart TD A[User Accesses Route] --> B{Check Authentication} B -->|Not Authenticated| C[Redirect to Login] B -->|Authenticated| D{Check Role} D -->|Has Required Role| E[Render Protected Content] D -->|Missing Role| F[Show Unauthorized Page] style E fill:#48bb78,color:#fff style C fill:#fc8181,color:#fff style F fill:#fc8181,color:#fff

Role-Based Protected Route

Create a component that checks both authentication and roles:

// components/RoleProtectedRoute.tsx
import { Navigate, useLocation } from 'react-router';
import { useAuth } from '../contexts/AuthContext';

interface RoleProtectedRouteProps {
  children: React.ReactNode;
  allowedRoles: string[];
  redirectTo?: string;
}

export function RoleProtectedRoute({ 
  children, 
  allowedRoles,
  redirectTo = '/unauthorized'
}: RoleProtectedRouteProps) {
  const { user, isAuthenticated, isLoading } = useAuth();
  const location = useLocation();
  
  // Show loading while checking auth
  if (isLoading) {
    return <LoadingPage />;
  }
  
  // Not authenticated - redirect to login
  if (!isAuthenticated) {
    return (
      <Navigate 
        to="/login" 
        state={{ from: location }} 
        replace 
      />
    );
  }
  
  // Authenticated but wrong role - show unauthorized
  if (!allowedRoles.includes(user!.role)) {
    return (
      <Navigate 
        to={redirectTo} 
        state={{ requiredRoles: allowedRoles }}
        replace 
      />
    );
  }
  
  // User has correct role
  return <>{children}</>;
}

Using Role-Based Routes

Apply role restrictions to your routes:

// App.tsx
import { RoleProtectedRoute } from './components/RoleProtectedRoute';

function App() {
  return (
    <BrowserRouter>
      <Routes>
        {/* Public routes */}
        <Route path="/" element={<Home />} />
        <Route path="/login" element={<Login />} />
        
        {/* User routes - any authenticated user */}
        <Route 
          path="/dashboard" 
          element={
            <RoleProtectedRoute allowedRoles={['user', 'moderator', 'admin']}>
              <Dashboard />
            </RoleProtectedRoute>
          } 
        />
        
        {/* Moderator routes */}
        <Route 
          path="/moderation" 
          element={
            <RoleProtectedRoute allowedRoles={['moderator', 'admin']}>
              <ModerationPanel />
            </RoleProtectedRoute>
          } 
        />
        
        {/* Admin-only routes */}
        <Route 
          path="/admin/*" 
          element={
            <RoleProtectedRoute allowedRoles={['admin']}>
              <AdminLayout />
            </RoleProtectedRoute>
          } 
        />
        
        {/* Unauthorized page */}
        <Route path="/unauthorized" element={<UnauthorizedPage />} />
      </Routes>
    </BrowserRouter>
  );
}

Unauthorized Page

Create a clear unauthorized access page:

// pages/UnauthorizedPage.tsx
import { useLocation, useNavigate } from 'react-router';
import { useAuth } from '../contexts/AuthContext';

interface LocationState {
  requiredRoles?: string[];
}

export function UnauthorizedPage() {
  const location = useLocation();
  const navigate = useNavigate();
  const { user, logout } = useAuth();
  
  const state = location.state as LocationState;
  
  const handleGoBack = () => {
    navigate(-1);
  };
  
  const handleGoHome = () => {
    navigate('/');
  };
  
  const handleSwitchAccount = () => {
    logout();
    navigate('/login');
  };
  
  return (
    <div className="unauthorized-page">
      <div className="unauthorized-content">
        <div className="icon">🚫</div>
        
        <h1>Access Denied</h1>
        
        <p>
          You don't have permission to access this page.
        </p>
        
        {state?.requiredRoles && (
          <div className="required-roles">
            <p><strong>Required roles:</strong></p>
            <ul>
              {state.requiredRoles.map(role => (
                <li key={role}>{role}</li>
              ))}
            </ul>
          </div>
        )}
        
        {user && (
          <div className="current-role">
            <p>Your current role: <strong>{user.role}</strong></p>
          </div>
        )}
        
        <div className="actions">
          <button onClick={handleGoBack} className="btn-secondary">
            ← Go Back
          </button>
          
          <button onClick={handleGoHome} className="btn-primary">
            🏠 Go Home
          </button>
          
          <button onClick={handleSwitchAccount} className="btn-secondary">
            πŸ”„ Switch Account
          </button>
        </div>
        
        <div className="help-text">
          <p>
            Need higher access? Contact your administrator.
          </p>
        </div>
      </div>
    </div>
  );
}

Permission-Based Access Control

For more granular control, use permissions instead of roles:

// types/auth.ts
export interface User {
  id: number;
  email: string;
  name: string;
  role: 'user' | 'moderator' | 'admin';
  permissions: string[]; // e.g., ['users.read', 'users.write', 'posts.delete']
}

// components/PermissionProtectedRoute.tsx
interface PermissionProtectedRouteProps {
  children: React.ReactNode;
  requiredPermissions: string[];
  requireAll?: boolean; // true = need all permissions, false = need any
}

export function PermissionProtectedRoute({ 
  children, 
  requiredPermissions,
  requireAll = true
}: PermissionProtectedRouteProps) {
  const { user, isAuthenticated, isLoading } = useAuth();
  const location = useLocation();
  
  if (isLoading) return <LoadingPage />;
  
  if (!isAuthenticated) {
    return <Navigate to="/login" state={{ from: location }} replace />;
  }
  
  // Check permissions
  const hasPermission = requireAll
    ? requiredPermissions.every(perm => user!.permissions.includes(perm))
    : requiredPermissions.some(perm => user!.permissions.includes(perm));
  
  if (!hasPermission) {
    return (
      <Navigate 
        to="/unauthorized" 
        state={{ requiredPermissions }}
        replace 
      />
    );
  }
  
  return <>{children}</>;
}

// Usage
<Route 
  path="/users/edit/:id" 
  element={
    <PermissionProtectedRoute 
      requiredPermissions={['users.write']}
    >
      <EditUser />
    </PermissionProtectedRoute>
  } 
/>

Conditional Navigation Based on Role

Show/hide navigation items based on user permissions:

// components/Navigation.tsx
import { Link } from 'react-router';
import { useAuth } from '../contexts/AuthContext';

export function Navigation() {
  const { user, isAuthenticated } = useAuth();
  
  const hasRole = (roles: string[]) => {
    return user && roles.includes(user.role);
  };
  
  return (
    <nav>
      <Link to="/">Home</Link>
      
      {isAuthenticated && (
        <>
          <Link to="/dashboard">Dashboard</Link>
          <Link to="/profile">Profile</Link>
        </>
      )}
      
      {hasRole(['moderator', 'admin']) && (
        <Link to="/moderation">Moderation</Link>
      )}
      
      {hasRole(['admin']) && (
        <Link to="/admin">Admin Panel</Link>
      )}
    </nav>
  );
}

See the Role Check in Action

The interactive below runs the exact logic from RoleProtectedRoute for the route /admin, which requires allowedRoles={['admin']}. Click a visitor to see what the guard decides β€” render the page, bounce to login, or show "access denied":

<RoleProtectedRoute allowedRoles={['admin']}> — route: /admin

πŸ‘† Pick a visitor to see how the guard responds.

⚠️ Security Warning

Client-side route protection is NOT enough!

  • Always validate permissions on the server/API
  • Client-side checks are for UX only
  • Malicious users can bypass client-side restrictions
  • Backend must enforce all authorization rules

πŸ‹οΈ Exercise: Implement Role-Based Dashboard

Create a multi-role application with role-specific access.

Requirements:

  1. Create routes for: /dashboard, /users, /reports, /admin
  2. Dashboard: accessible to all authenticated users
  3. Users: accessible to moderators and admins
  4. Reports: accessible to admins only
  5. Create an unauthorized page
  6. Show different navigation items based on role
πŸ’‘ Hint

Use the RoleProtectedRoute component with different role arrays for each route.

βœ… Solution
// See the complete implementation in the downloadable course files

🎯 Summary and Next Steps

What You've Learned

In this lesson, you've mastered route protection, lazy loading, and error handling:

πŸ“š Key Concepts Covered

  • Protected Routes - Securing routes with authentication
  • Authentication Guards - Implementing auth context and redirect logic
  • Loader Redirects - Guarding routes before render with redirect() in the data router
  • Redirect Patterns - Handling various redirect scenarios
  • Lazy Loading - Code splitting with React.lazy()
  • Suspense - Managing loading states declaratively, including deferred data
  • Loading UX - Global pending UI with useNavigation(), plus spinners, skeletons, and progress bars
  • Error Boundaries - Catching and handling route errors
  • Role-Based Access - Implementing RBAC and permission checks for routes

Quick Reference

Concept Use Case Key Component
Protected Routes Require authentication ProtectedRoute wrapper
Role-Based Routes Restrict by user role RoleProtectedRoute
Lazy Loading Split code, improve performance React.lazy()
Suspense Handle loading states <Suspense fallback={...}>
Error Boundaries Catch route errors ErrorBoundary class

Best Practices Checklist

βœ… Production Checklist

  • βœ… Protected routes redirect to login with return path
  • βœ… Loading states for all async operations
  • βœ… Error boundaries around major app sections
  • βœ… Lazy loading for routes and large components
  • βœ… Role-based access control where needed
  • βœ… Server-side auth validation (never trust client)
  • βœ… Clear unauthorized and error pages
  • βœ… Proper TypeScript typing throughout
  • βœ… Testing for protected routes and error states

Common Patterns Summary

// Protected Route Pattern
<Route 
  path="/dashboard" 
  element={
    <ProtectedRoute>
      <Dashboard />
    </ProtectedRoute>
  } 
/>

// Lazy Loading Pattern
const Dashboard = lazy(() => import('./Dashboard'));

<Suspense fallback={<Loading />}>
  <Dashboard />
</Suspense>

// Error Boundary Pattern
<ErrorBoundary fallback={<ErrorPage />}>
  <Routes>
    {/* routes */}
  </Routes>
</ErrorBoundary>

// Role-Based Pattern
<RoleProtectedRoute allowedRoles={['admin']}>
  <AdminPanel />
</RoleProtectedRoute>

Performance Tips

⚑ Optimization Strategies

  • Lazy load by route - Each route = separate chunk
  • Preload on hover - Load next likely route
  • Use skeleton screens - Better than spinners
  • Minimize error boundaries - Don't wrap everything
  • Cache auth state - Reduce auth checks
  • Progressive loading - Show content as it loads

Next Lesson Preview

πŸ”œ Coming Up: Lesson 6.4 - Search and Query Parameters

In the next lesson, you'll learn:

  • Using the useSearchParams hook
  • Managing URL query strings
  • Building search and filter functionality
  • Syncing state with URL parameters
  • Pagination with URL state
  • TypeScript patterns for search params

Practice Projects

  1. Authenticated Blog Platform
    • Public blog listing (no auth required)
    • Protected post creation/editing
    • Admin-only user management
    • Lazy loaded dashboard and editor
  2. E-commerce Admin Panel
    • Role-based product management
    • Order processing (moderator+)
    • Analytics dashboard (admin only)
    • Error handling for failed operations
  3. Team Collaboration Tool
    • Project-based permissions
    • Lazy loaded project views
    • Session management and expiration
    • Comprehensive error boundaries

Additional Resources

πŸŽ‰ Congratulations!

You've completed Lesson 6.3 and now have a comprehensive understanding of route protection and performance optimization. You can:

  • βœ… Implement secure authentication flows
  • βœ… Create role-based access control systems
  • βœ… Optimize applications with lazy loading
  • βœ… Handle errors gracefully with boundaries
  • βœ… Build production-ready routing architectures
  • βœ… Provide excellent loading UX for users

Ready to add search and filtering? Let's continue!