React Router Fundamentals

Understanding Client-Side Routing in React Applications

Introduction to Routing

In traditional websites, navigation between different pages requires sending a request to the server, which responds with the new HTML page. This process causes a full page reload each time a user navigates to a different page.

Single-Page Applications (SPAs) like those built with React work differently. They load a single HTML page and then dynamically update the content as users interact with the app, without requiring full page reloads. This approach provides a smoother, more app-like user experience.

flowchart TD subgraph "Traditional Multi-Page App" URL1[URL: /home] --> Server1[Server] Server1 --> HTML1[home.html] URL2[URL: /about] --> Server2[Server] Server2 --> HTML2[about.html] URL3[URL: /contact] --> Server3[Server] Server3 --> HTML3[contact.html] end subgraph "Single-Page App" SPAURL[URL Changes] --> Router[Client-Side Router] Router --> React[React App] React --> |Updates| DOM[DOM] end classDef server fill:#f9f,stroke:#333,stroke-width:1px; classDef page fill:#bbf,stroke:#33b,stroke-width:1px; classDef router fill:#bfb,stroke:#383,stroke-width:1px; class Server1,Server2,Server3 server; class HTML1,HTML2,HTML3,DOM page; class Router router;

Client-side routing allows a React application to:

Real-World Analogy: TV Channels vs. Streaming Service

Think of the traditional web like a TV with different channels:

  • Each channel (URL) is a completely separate content source
  • Changing channels (navigating) causes a moment of "blank screen" (page reload)
  • Each channel request goes through the broadcasting station (server)

Client-side routing in SPAs is more like a modern streaming service:

  • The app itself (streaming device) is already loaded
  • Navigation just tells the app which content to display
  • Transitions between content are smooth and controlled
  • Your viewing history is maintained within the app
  • You can quickly switch between recently viewed content

Introducing React Router

React Router is the standard routing library for React applications. It keeps your UI in sync with the URL by declaratively rendering components based on the current URL path.

Key features of React Router include:

Let's install React Router in your project:


# npm
npm install react-router-dom

# yarn
yarn add react-router-dom
        

The current stable version of React Router is v6, which introduced several changes and improvements over v5. This lecture will focus on React Router v6.

React Router Core Concepts

Understanding React Router requires familiarity with a few core concepts:

graph TD Browser[BrowserRouter] --> Routes Routes --> Route1[Route: /] Routes --> Route2[Route: /about] Routes --> Route3[Route: /products/*] Route1 --> Home[Home Component] Route2 --> About[About Component] Route3 --> Products[Products Component] Products --> ProductRoutes[Nested Routes] ProductRoutes --> PR1[Route: /] ProductRoutes --> PR2[Route: /:id] PR1 --> ProductList[ProductList Component] PR2 --> ProductDetail[ProductDetail Component] classDef router fill:#ffd,stroke:#aa3,stroke-width:2px; classDef component fill:#dfd,stroke:#3a3,stroke-width:2px; class Browser,Routes,Route1,Route2,Route3,ProductRoutes,PR1,PR2 router; class Home,About,Products,ProductList,ProductDetail component;

Setting Up Basic Routing

Let's set up a simple routing system for a React application:


// index.js or App.js
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Home from './pages/Home';
import About from './pages/About';
import Contact from './pages/Contact';
import NotFound from './pages/NotFound';
import Navbar from './components/Navbar';

function App() {
  return (
    <BrowserRouter>
      <div>
        <Navbar />
        <main>
          <Routes>
            <Route path="/" element={<Home />} />
            <Route path="/about" element={<About />} />
            <Route path="/contact" element={<Contact />} />
            <Route path="*" element={<NotFound />} />
          </Routes>
        </main>
      </div>
    </BrowserRouter>
  );
}

export default App;
        

In this example:

Creating Navigation Links

The Link component creates navigation links that use client-side routing instead of traditional page navigation:


// components/Navbar.js
import React from 'react';
import { Link } from 'react-router-dom';

function Navbar() {
  return (
    <nav>
      <ul>
        <li><Link to="/">Home</Link></li>
        <li><Link to="/about">About</Link></li>
        <li><Link to="/contact">Contact</Link></li>
      </ul>
    </nav>
  );
}

export default Navbar;
        

The Link component:

NavLink: Enhanced Links with Active Styling

React Router also provides a NavLink component that extends Link with the ability to style the active link:


import { NavLink } from 'react-router-dom';

function Navbar() {
  // Function to determine active styles
  const navLinkStyles = ({ isActive }) => ({
    fontWeight: isActive ? 'bold' : 'normal',
    color: isActive ? '#0077cc' : '#333333',
    textDecoration: isActive ? 'underline' : 'none'
  });

  return (
    <nav>
      <ul>
        <li>
          <NavLink to="/" style={navLinkStyles}>
            Home
          </NavLink>
        </li>
        <li>
          <NavLink to="/about" style={navLinkStyles}>
            About
          </NavLink>
        </li>
        <li>
          <NavLink to="/contact" style={navLinkStyles}>
            Contact
          </NavLink>
        </li>
      </ul>
    </nav>
  );
}
          

Alternatively, you can use the className prop:


<NavLink 
  to="/" 
  className={({ isActive }) => 
    isActive ? "nav-link active" : "nav-link"
  }
>
  Home
</NavLink>
          

URL Parameters and Dynamic Routes

Most applications need dynamic routes that can match different URLs with the same pattern. For example, product detail pages often have URLs like /products/1, /products/2, etc.

React Router handles this with URL parameters, denoted by a colon in the path:


<Route path="/products/:id" element={<ProductDetail />} />
        

Inside the ProductDetail component, we can access this parameter using the useParams hook:


// pages/ProductDetail.js
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';

function ProductDetail() {
  // Extract the id parameter from the URL
  const { id } = useParams();
  const [product, setProduct] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Fetch product data based on the ID
    async function fetchProduct() {
      try {
        setLoading(true);
        const response = await fetch(`/api/products/${id}`);
        const data = await response.json();
        setProduct(data);
      } catch (error) {
        console.error('Error fetching product:', error);
      } finally {
        setLoading(false);
      }
    }

    fetchProduct();
  }, [id]); // Re-fetch when the ID changes

  if (loading) {
    return <div>Loading...</div>;
  }

  if (!product) {
    return <div>Product not found</div>;
  }

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <div>Price: ${product.price}</div>
    </div>
  );
}

export default ProductDetail;
        

You can have multiple parameters in a single route:


<Route path="/categories/:categoryId/products/:productId" element={<CategoryProduct />} />

// In the component
function CategoryProduct() {
  const { categoryId, productId } = useParams();
  // ...
}
        

Practical Example: E-commerce Product Pages

In an e-commerce application, you might have routes like:


<Routes>
  <Route path="/" element={<HomePage />} />
  <Route path="/products" element={<ProductsPage />} />
  <Route path="/products/:productId" element={<ProductDetailPage />} />
  <Route path="/categories/:categoryId" element={<CategoryPage />} />
  <Route path="/checkout" element={<CheckoutPage />} />
  <Route path="/order-confirmation/:orderId" element={<OrderConfirmationPage />} />
</Routes>
          

Creating links to dynamic routes:


function ProductCard({ product }) {
  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <Link to={`/products/${product.id}`}>View Details</Link>
    </div>
  );
}
          

Query Parameters

Unlike URL parameters, query parameters (the part after ? in a URL) don't affect route matching. They're optional and often used for filtering, sorting, or pagination.

React Router provides the useSearchParams hook to work with query parameters:


// pages/ProductList.js
import React from 'react';
import { useSearchParams } from 'react-router-dom';

function ProductList() {
  // Access query parameters (e.g., ?category=electronics&sort=price)
  const [searchParams, setSearchParams] = useSearchParams();
  
  // Get individual parameters
  const category = searchParams.get('category') || 'all';
  const sort = searchParams.get('sort') || 'name';
  
  // Update query parameters
  const handleCategoryChange = (e) => {
    const newCategory = e.target.value;
    setSearchParams({ category: newCategory, sort });
  };
  
  const handleSortChange = (e) => {
    const newSort = e.target.value;
    setSearchParams({ category, sort: newSort });
  };
  
  return (
    <div>
      <h1>Products</h1>
      
      <div>
        <label>
          Category:
          <select value={category} onChange={handleCategoryChange}>
            <option value="all">All Categories</option>
            <option value="electronics">Electronics</option>
            <option value="clothing">Clothing</option>
            <option value="books">Books</option>
          </select>
        </label>
        
        <label>
          Sort by:
          <select value={sort} onChange={handleSortChange}>
            <option value="name">Name</option>
            <option value="price">Price</option>
            <option value="newest">Newest</option>
          </select>
        </label>
      </div>
      
      <div>
        <p>Showing {category} products sorted by {sort}</p>
        {/* Product list would go here */}
      </div>
    </div>
  );
}

export default ProductList;
        

When the user changes a filter, setSearchParams updates the URL, which allows users to:

Creating links with query parameters

You can also create links with predefined query parameters:


<Link to="/products?category=electronics&sort=price">
  View Electronics (Price Low-High)
</Link>

// Or build them dynamically
function CategoryLink({ category, sort = 'name', children }) {
  return (
    <Link to={`/products?category=${category}&sort=${sort}`}>
      {children}
    </Link>
  );
}

// Usage
<CategoryLink category="electronics">
  View All Electronics
</CategoryLink>
          

For more complex query parameters, you can use URLSearchParams:


function buildProductsUrl(filters) {
  const params = new URLSearchParams();
  
  if (filters.category) {
    params.append('category', filters.category);
  }
  
  if (filters.sort) {
    params.append('sort', filters.sort);
  }
  
  if (filters.minPrice) {
    params.append('minPrice', filters.minPrice);
  }
  
  if (filters.maxPrice) {
    params.append('maxPrice', filters.maxPrice);
  }
  
  // Convert to string: "category=electronics&sort=price&minPrice=100"
  const queryString = params.toString();
  
  // Return the full path
  return `/products${queryString ? `?${queryString}` : ''}`;
}

// Usage
const filtersUrl = buildProductsUrl({
  category: 'electronics',
  sort: 'price',
  minPrice: 100
});

<Link to={filtersUrl}>Filtered Electronics</Link>
          

Programmatic Navigation

While Link components are perfect for declarative navigation, sometimes you need to navigate programmatically, such as after form submissions or based on certain conditions.

React Router provides the useNavigate hook for this purpose:


// pages/LoginForm.js
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';

function LoginForm() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const navigate = useNavigate();

  const handleSubmit = async (e) => {
    e.preventDefault();
    
    try {
      // Call login API
      const response = await fetch('/api/login', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ username, password })
      });
      
      const data = await response.json();
      
      if (response.ok) {
        // Login successful
        // Store user data, token, etc.
        localStorage.setItem('token', data.token);
        
        // Redirect to dashboard
        navigate('/dashboard');
      } else {
        // Handle login error
        alert(data.message || 'Login failed');
      }
    } catch (error) {
      console.error('Login error:', error);
      alert('An error occurred. Please try again.');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="username">Username:</label>
        <input
          type="text"
          id="username"
          value={username}
          onChange={(e) => setUsername(e.target.value)}
          required
        />
      </div>
      
      <div>
        <label htmlFor="password">Password:</label>
        <input
          type="password"
          id="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
        />
      </div>
      
      <button type="submit">Log In</button>
      
      <p>
        <button 
          type="button" 
          onClick={() => navigate('/signup')}
        >
          Create Account
        </button>
      </p>
    </form>
  );
}

export default LoginForm;
        

Advanced Navigation Options

The navigate function provides several options:


// Basic navigation
navigate('/dashboard');

// Navigate with replacement (no back button entry)
navigate('/dashboard', { replace: true });

// Navigate relative to the current route
navigate('../parent'); // Go up one level
navigate('child');     // Go to a child route
navigate('.');         // Stay at the same route but refresh

// Navigate with state (data that persists with navigation)
navigate('/product-detail', { 
  state: { productData: product } 
});

// Navigate back
navigate(-1);

// Navigate forward
navigate(1);

// Go back two pages
navigate(-2);
        

Real-World Navigation Scenarios

Here are some common scenarios where programmatic navigation is useful:

  • Form Submissions: Redirect after successful form submission
  • Authentication: Redirect to login or dashboard based on auth state
  • Checkout Flow: Guide users through a multi-step process
  • Access Control: Redirect unauthorized users away from protected routes
  • Timeouts: Redirect after a period of inactivity
  • Confirmation Pages: Navigate to confirmation after completing an action

Example: Redirecting to a confirmation page after an order is placed:


function CheckoutPage() {
  const navigate = useNavigate();
  const [cart, setCart] = useState(/* ... */);
  
  const handlePlaceOrder = async () => {
    try {
      const response = await fetch('/api/orders', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${localStorage.getItem('token')}`
        },
        body: JSON.stringify({ items: cart.items })
      });
      
      const order = await response.json();
      
      if (response.ok) {
        // Clear the cart
        setCart({ items: [] });
        
        // Navigate to order confirmation page with order data
        navigate(`/order-confirmation/${order.id}`, {
          state: { orderDetails: order }
        });
      } else {
        alert(order.message || 'Failed to place order');
      }
    } catch (error) {
      console.error('Order error:', error);
      alert('An error occurred while placing your order');
    }
  };
  
  // Rest of component...
}
          

Accessing Route Information

React Router provides several hooks to access information about the current route:

useLocation

The useLocation hook returns the current location object, which contains information about the current URL:


import { useLocation } from 'react-router-dom';

function Analytics() {
  const location = useLocation();
  
  // location object contains:
  // - pathname: The path section of the URL
  // - search: The query string (including '?')
  // - hash: The hash (including '#')
  // - state: Any state passed with navigate()
  // - key: A unique ID for this location
  
  useEffect(() => {
    // Track page views
    analytics.trackPageView({
      path: location.pathname,
      search: location.search,
      timestamp: new Date().toISOString()
    });
  }, [location]);
  
  return null; // This component doesn't render anything
}
        

useRouteMatch

The useMatch hook tests if the current URL matches a specific pattern:


import { useMatch } from 'react-router-dom';

function ProductBreadcrumb() {
  // Check if we're on a product detail page
  const productMatch = useMatch('/products/:productId');
  
  if (!productMatch) {
    return <span>Products</span>;
  }
  
  // We are on a product detail page
  const productId = productMatch.params.productId;
  
  return (
    <div className="breadcrumb">
      <Link to="/products">Products</Link> > 
      <span>Product {productId}</span>
    </div>
  );
}
        

Accessing State from Navigation

When you navigate with state, you can access it in the destination component:


// Component that navigates with state
function ProductList() {
  const navigate = useNavigate();
  
  const handleProductClick = (product) => {
    navigate(`/products/${product.id}`, {
      state: { 
        productData: product,
        fromSearch: true
      }
    });
  };
  
  // ...
}

// Component that receives the state
function ProductDetail() {
  const location = useLocation();
  const { productId } = useParams();
  
  // Access the state passed during navigation
  const productData = location.state?.productData;
  const fromSearch = location.state?.fromSearch;
  
  useEffect(() => {
    // If we didn't receive product data with navigation,
    // we need to fetch it
    if (!productData) {
      fetchProductData(productId);
    }
  }, [productId, productData]);
  
  // Render different back links based on where user came from
  const backLink = fromSearch ? (
    <Link to="/search">Back to Search Results</Link>
  ) : (
    <Link to="/products">Back to All Products</Link>
  );
  
  // ...
}
        

Practical Example: Product Detail with Location State

Let's see a complete example of passing product data via navigation state to avoid unnecessary API calls:


// ProductList.js
function ProductList() {
  const [products, setProducts] = useState([]);
  const navigate = useNavigate();
  
  useEffect(() => {
    // Fetch products when component mounts
    async function fetchProducts() {
      const response = await fetch('/api/products');
      const data = await response.json();
      setProducts(data);
    }
    
    fetchProducts();
  }, []);
  
  const handleProductClick = (product) => {
    // Navigate to product detail with the full product data
    navigate(`/products/${product.id}`, {
      state: { productData: product }
    });
  };
  
  return (
    <div>
      <h1>Products</h1>
      <div className="product-grid">
        {products.map(product => (
          <div 
            key={product.id} 
            className="product-card"
            onClick={() => handleProductClick(product)}
          >
            <img src={product.image} alt={product.name} />
            <h3>{product.name}</h3>
            <p>${product.price}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

// ProductDetail.js
function ProductDetail() {
  const { productId } = useParams();
  const location = useLocation();
  const navigate = useNavigate();
  
  // Check if product data was passed in location state
  const [product, setProduct] = useState(location.state?.productData || null);
  const [loading, setLoading] = useState(!product);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    // Only fetch if we don't have product data already
    if (!product) {
      async function fetchProduct() {
        try {
          setLoading(true);
          const response = await fetch(`/api/products/${productId}`);
          
          if (!response.ok) {
            throw new Error('Product not found');
          }
          
          const data = await response.json();
          setProduct(data);
          setError(null);
        } catch (err) {
          setError(err.message);
          setProduct(null);
        } finally {
          setLoading(false);
        }
      }
      
      fetchProduct();
    }
  }, [productId, product]);
  
  if (loading) {
    return <div>Loading product details...</div>;
  }
  
  if (error) {
    return (
      <div>
        <p>Error: {error}</p>
        <button onClick={() => navigate('/products')}>
          Back to Products
        </button>
      </div>
    );
  }
  
  if (!product) {
    return <div>Product not found</div>;
  }
  
  return (
    <div>
      <h1>{product.name}</h1>
      <div className="product-detail">
        <div className="product-image">
          <img src={product.image} alt={product.name} />
        </div>
        <div className="product-info">
          <p className="price">${product.price}</p>
          <p className="description">{product.description}</p>
          <button>Add to Cart</button>
        </div>
      </div>
      <Link to="/products">Back to Products</Link>
    </div>
  );
}
          

This pattern provides a smoother user experience by:

  • Eliminating loading states when navigating from a list to a detail view
  • Reducing unnecessary API calls
  • Gracefully falling back to API fetching when needed (e.g., when accessing the detail page directly)

Practice Activities

Activity 1: Basic Routing Setup

Create a simple React application with the following pages:

  1. Home page with a welcome message
  2. About page with information about a fictional company
  3. Contact page with a contact form (no need to make it functional)
  4. 404 page for routes that don't match

Implement a navigation bar with links to each page and style the active link differently.

Activity 2: URL Parameters and Query Strings

Extend your application with:

  1. A products page that displays a list of products (mock data is fine)
  2. Each product should link to a product detail page using a URL parameter
  3. Add filtering capabilities to the products page using query parameters (category, sort, etc.)
  4. Implement UI controls to change these filters

Activity 3: Programmatic Navigation

Add the following features to your application:

  1. A login form that redirects to a dashboard page on successful login (mock the authentication)
  2. A checkout flow that guides users through multiple steps (cart → shipping → payment → confirmation)
  3. Add navigation buttons (Next/Back) between these steps
  4. Implement a "Continue Shopping" button on the confirmation page that navigates back to products

Summary

Further Resources