Skip to main content

🔒 Lesson 6.3: Route Protection and Loading

Secure your application routes with authentication guards, optimize performance with lazy loading, and create smooth user experiences with loading states and error boundaries. Build production-ready routing architectures for real-world applications.

đŸŽ¯ Learning Objectives

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

  • Implement protected routes that require authentication
  • Create flexible redirect patterns for unauthorized access
  • Use React.lazy() and Suspense for code splitting
  • Optimize bundle size with lazy-loaded routes
  • Implement loading states for better user experience
  • Handle routing errors with error boundaries
  • Build role-based access control for routes
  • Type authentication and loading states properly

Estimated Time: 75-90 minutes

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

📑 In This Lesson

đŸ›Ąī¸ Protected Routes Overview

Protected routes are essential for securing parts of your application that require authentication or specific permissions. They control access to sensitive content and redirect unauthorized users appropriately.

📖 Why Protected Routes Matter

  • Security - Prevent unauthorized access to sensitive data
  • User Experience - Guide users to login when needed
  • Authorization - Control access based on user roles or permissions
  • Data Protection - Ensure only authenticated users can view/modify data
  • Session Management - Handle expired sessions gracefully

The Protected Route Pattern

A protected route checks authentication status before rendering. If the user is not authenticated, they're redirected to a login page:

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

Let's build a production-ready protected route system with TypeScript, proper authentication checks, and redirect handling.

Setting Up Authentication Context

First, we'll create an authentication context to manage auth state across the application:

// 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-dom';
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-dom';
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-dom';
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-dom';

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

Beyond basic authentication, there are many scenarios where you need to redirect users. Let's explore common redirect patterns and best practices.

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-dom';
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-dom';
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-dom';
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-dom';

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

Lazy loading splits your application into smaller chunks that are loaded on-demand, significantly improving initial load time and performance.

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
  • Bandwidth Savings - Users only download what they use
  • Progressive Loading - App becomes interactive faster

Basic React.lazy() Usage

Use React.lazy() to dynamically import components:

// 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-dom';
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-dom';

// 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

React Suspense is a powerful feature that lets you declaratively specify loading states while waiting for code to load. It's essential for lazy loading and provides a clean way to handle asynchronous operations.

📖 What is Suspense?

Suspense is a React component that lets you "wait" for some code to load and declaratively specify a loading state while waiting. It's currently supported for:

  • Lazy loading components with React.lazy()
  • Data fetching with frameworks like Relay or Next.js
  • Loading any async resource (experimental)

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

Providing clear, informative loading states is crucial for good user experience. Let's explore various techniques for communicating loading status to users.

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

Error boundaries catch JavaScript errors anywhere in the component tree and display fallback UI instead of crashing the entire application. They're essential for handling route loading errors.

📖 What Are Error Boundaries?

Error boundaries are React components that:

  • Catch JavaScript errors in child components
  • Log error information
  • Display fallback UI instead of crashing
  • Allow the rest of the app to continue working

Note: Error boundaries must be class components in React (until React 19).

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-dom';
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-dom';

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 && process.env.NODE_ENV === 'development' && (
          <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

Role-Based Access Control (RBAC) restricts route access based on user roles or permissions. This is essential for applications with different user types (admin, moderator, user, etc.).

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-dom';
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-dom';
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-dom';
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>
  );
}

âš ī¸ 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
  • Redirect Patterns - Handling various redirect scenarios
  • Lazy Loading - Code splitting with React.lazy()
  • Suspense - Managing loading states declaratively
  • Loading UX - Creating spinners, skeletons, and progress indicators
  • Error Boundaries - Catching and handling route errors
  • Role-Based Access - Implementing RBAC 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!