π 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:
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
loaderchecks auth and throwsredirect()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:
- Create a UserProfile component that displays user information
- Wrap it in a ProtectedRoute
- Show loading state while checking authentication
- Redirect to /login if not authenticated
- After login, redirect back to profile page
- 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.
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:
- Create 5 different page components (Home, About, Products, Contact, Dashboard)
- Eager load Home and About pages
- Lazy load Products, Contact, and Dashboard
- Add a custom loading fallback with a spinner
- Wrap lazy routes in Suspense
- 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>
);
}
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
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:
- Create routes for: /dashboard, /users, /reports, /admin
- Dashboard: accessible to all authenticated users
- Users: accessible to moderators and admins
- Reports: accessible to admins only
- Create an unauthorized page
- 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
-
Authenticated Blog Platform
- Public blog listing (no auth required)
- Protected post creation/editing
- Admin-only user management
- Lazy loaded dashboard and editor
-
E-commerce Admin Panel
- Role-based product management
- Order processing (moderator+)
- Analytics dashboard (admin only)
- Error handling for failed operations
-
Team Collaboration Tool
- Project-based permissions
- Lazy loaded project views
- Session management and expiration
- Comprehensive error boundaries
Additional Resources
- React: lazy() Reference
- React: Suspense Reference
- React: Error Boundaries
- React Router v7: Data Router & Loaders
- Web.dev: Code Splitting with Suspense
π 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!