React Development · Lesson 5 of 15

React Router

How Client-Side Routing Works

In a traditional multi-page app, the server handles every URL — each navigation is a full page load. React apps use client-side routing: JavaScript intercepts navigation, updates the URL with the History API, and re-renders the component tree. No server round-trip.

User clicks Link → React Router intercepts → URL updates → Components re-render
                                                        ↗ No page reload

Setup and Basic Routing

JSX
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";

function App() {
  return (
    <BrowserRouter>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/products">Products</Link>
        <Link to="/about">About</Link>
      </nav>

      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/products" element={<ProductsPage />} />
        <Route path="/products/:id" element={<ProductDetailPage />} />
        <Route path="/about" element={<AboutPage />} />
        <Route path="*" element={<NotFoundPage />} />
      </Routes>
    </BrowserRouter>
  );
}

BrowserRouter vs HashRouter:

  • BrowserRouter — uses history.pushState, gives clean URLs (/products/123). Requires server to serve index.html for all routes.
  • HashRouter — uses URL hash (/#/products/123). Works without server config. Used for static hosting.

Dynamic Route Parameters

JSX
// Route definition
<Route path="/users/:userId/posts/:postId" element={<PostDetail />} />

// Accessing parameters
import { useParams, useNavigate, useLocation, useSearchParams } from "react-router-dom";

function PostDetail() {
  const { userId, postId } = useParams();
  const navigate = useNavigate();
  const location = useLocation();
  const [searchParams, setSearchParams] = useSearchParams();

  // URL: /users/42/posts/7?tab=comments&page=2
  console.log(userId);     // "42"
  console.log(postId);     // "7"
  console.log(location.pathname);  // "/users/42/posts/7"
  console.log(searchParams.get("tab"));   // "comments"
  console.log(searchParams.get("page"));  // "2"

  // Programmatic navigation
  const goBack = () => navigate(-1);
  const goToUser = () => navigate(`/users/${userId}`);
  const replaceHistory = () => navigate("/login", { replace: true });

  // Navigate with state (not in URL)
  const goWithState = () => navigate("/checkout", {
    state: { from: location.pathname, items: cart }
  });

  // Updating search params without full navigation
  const switchTab = (tab) => {
    setSearchParams({ tab, page: "1" });
  };

  return (
    <div>
      <button onClick={goBack}>← Back</button>
      <h1>Post {postId} by User {userId}</h1>
      <div className="tabs">
        {["content", "comments", "likes"].map((tab) => (
          <button
            key={tab}
            className={searchParams.get("tab") === tab ? "active" : ""}
            onClick={() => switchTab(tab)}
          >
            {tab}
          </button>
        ))}
      </div>
    </div>
  );
}

Nested Routes: Real Dashboard Layout

Nested routes let child routes render inside a parent layout — the most common pattern for dashboards.

JSX
function App() {
  return (
    <BrowserRouter>
      <Routes>
        {/* Public routes */}
        <Route path="/login" element={<LoginPage />} />
        <Route path="/signup" element={<SignupPage />} />

        {/* Dashboard routes — all share the DashboardLayout */}
        <Route path="/dashboard" element={<DashboardLayout />}>
          <Route index element={<DashboardHome />} />
          <Route path="analytics" element={<Analytics />} />
          <Route path="users" element={<UsersLayout />}>
            <Route index element={<UsersList />} />
            <Route path=":userId" element={<UserDetail />} />
            <Route path=":userId/edit" element={<EditUser />} />
          </Route>
          <Route path="settings" element={<Settings />}>
            <Route index element={<Navigate to="profile" replace />} />
            <Route path="profile" element={<ProfileSettings />} />
            <Route path="security" element={<SecuritySettings />} />
            <Route path="billing" element={<BillingSettings />} />
          </Route>
        </Route>

        <Route path="/" element={<Navigate to="/dashboard" replace />} />
        <Route path="*" element={<NotFound />} />
      </Routes>
    </BrowserRouter>
  );
}

// Parent layout renders children with <Outlet />
function DashboardLayout() {
  return (
    <div className="dashboard">
      <Sidebar />
      <main className="dashboard__main">
        <Header />
        <Outlet />  {/* Child routes render here */}
      </main>
    </div>
  );
}

// Sidebar with active link highlighting
function Sidebar() {
  const navItems = [
    { to: "/dashboard", label: "Overview", icon: HomeIcon, end: true },
    { to: "/dashboard/analytics", label: "Analytics", icon: ChartIcon },
    { to: "/dashboard/users", label: "Users", icon: UsersIcon },
    { to: "/dashboard/settings", label: "Settings", icon: SettingsIcon },
  ];

  return (
    <aside className="sidebar">
      {navItems.map(({ to, label, icon: Icon, end }) => (
        <NavLink
          key={to}
          to={to}
          end={end}  // Only match exact path for "/"
          className={({ isActive }) =>
            `sidebar__link ${isActive ? "sidebar__link--active" : ""}`
          }
        >
          <Icon size={20} />
          <span>{label}</span>
        </NavLink>
      ))}
    </aside>
  );
}

Protected Routes: Auth Guard Pattern

JSX
// Reusable protected route wrapper
function RequireAuth({ children, allowedRoles }) {
  const { user, isLoading } = useAuth();
  const location = useLocation();

  if (isLoading) {
    return <FullPageSpinner />;
  }

  if (!user) {
    // Redirect to login, saving the attempted URL
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  if (allowedRoles && !allowedRoles.includes(user.role)) {
    return <Navigate to="/unauthorized" replace />;
  }

  return children;
}

// Usage in route config
function App() {
  return (
    <Routes>
      <Route path="/login" element={<LoginPage />} />
      <Route path="/unauthorized" element={<UnauthorizedPage />} />

      {/* Any logged-in user */}
      <Route
        path="/dashboard/*"
        element={
          <RequireAuth>
            <DashboardLayout />
          </RequireAuth>
        }
      >
        <Route index element={<DashboardHome />} />
        <Route path="profile" element={<Profile />} />

        {/* Admin only */}
        <Route
          path="admin"
          element={
            <RequireAuth allowedRoles={["admin", "superadmin"]}>
              <AdminPanel />
            </RequireAuth>
          }
        />
      </Route>
    </Routes>
  );
}

// After login, redirect back to where user was trying to go
function LoginPage() {
  const navigate = useNavigate();
  const location = useLocation();
  const { login } = useAuth();

  const from = location.state?.from?.pathname || "/dashboard";

  const handleLogin = async (credentials) => {
    await login(credentials);
    navigate(from, { replace: true });  // Go back to where they were
  };

  return <LoginForm onSubmit={handleLogin} />;
}

Data Loading with React Router 6.4+

React Router 6.4 introduced loaders and actions — colocate data fetching with routes.

JSX
import { createBrowserRouter, RouterProvider } from "react-router-dom";

const router = createBrowserRouter([
  {
    path: "/dashboard",
    element: <DashboardLayout />,
    loader: dashboardLoader,
    children: [
      {
        path: "users",
        element: <UsersList />,
        loader: usersLoader,
      },
      {
        path: "users/:userId",
        element: <UserDetail />,
        loader: userDetailLoader,
        action: updateUserAction,
      },
    ],
  },
]);

// Loaders run before the component renders
async function usersLoader({ request }) {
  const url = new URL(request.url);
  const page = url.searchParams.get("page") || "1";

  const response = await fetch(`/api/users?page=${page}`, {
    headers: { Authorization: `Bearer ${getToken()}` },
    signal: request.signal,  // Handles navigation cancellation
  });

  if (response.status === 401) {
    throw redirect("/login");
  }

  if (!response.ok) {
    throw new Response("Failed to load users", { status: response.status });
  }

  return response.json();
}

// Actions handle form submissions
async function updateUserAction({ request, params }) {
  const formData = await request.formData();
  const data = Object.fromEntries(formData);

  const response = await fetch(`/api/users/${params.userId}`, {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
  });

  if (!response.ok) {
    return { error: "Failed to update user" };
  }

  return redirect(`/dashboard/users/${params.userId}`);
}

// Component uses loader data
function UsersList() {
  const { users, pagination } = useLoaderData();
  const navigation = useNavigation();

  return (
    <div>
      {navigation.state === "loading" && <LoadingBar />}
      <ul>
        {users.map((user) => (
          <li key={user.id}>
            <Link to={`${user.id}`}>{user.name}</Link>
          </li>
        ))}
      </ul>
      <Pagination {...pagination} />
    </div>
  );
}

// Form submission through Router's action system
function EditUserForm({ user }) {
  const actionData = useActionData();
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";

  return (
    <Form method="put">
      <input name="name" defaultValue={user.name} />
      <input name="email" defaultValue={user.email} />
      {actionData?.error && <Alert message={actionData.error} />}
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Saving..." : "Save Changes"}
      </button>
    </Form>
  );
}

function App() {
  return <RouterProvider router={router} />;
}

Navigation Patterns

Breadcrumbs

JSX
function Breadcrumbs() {
  const location = useLocation();
  const pathnames = location.pathname.split("/").filter(Boolean);

  return (
    <nav aria-label="breadcrumb">
      <ol className="breadcrumbs">
        <li>
          <Link to="/">Home</Link>
        </li>
        {pathnames.map((segment, index) => {
          const to = `/${pathnames.slice(0, index + 1).join("/")}`;
          const isLast = index === pathnames.length - 1;

          return (
            <li key={to}>
              {isLast ? (
                <span aria-current="page">
                  {formatSegment(segment)}
                </span>
              ) : (
                <Link to={to}>{formatSegment(segment)}</Link>
              )}
            </li>
          );
        })}
      </ol>
    </nav>
  );
}

function formatSegment(segment) {
  // Convert route segment to readable label
  return segment
    .replace(/-/g, " ")
    .replace(/\b\w/g, (c) => c.toUpperCase());
}

Scroll Restoration

JSX
// Scroll to top on route change
function ScrollToTop() {
  const { pathname } = useLocation();

  useEffect(() => {
    window.scrollTo(0, 0);
  }, [pathname]);

  return null;
}

function App() {
  return (
    <BrowserRouter>
      <ScrollToTop />
      <Routes>{/* ... */}</Routes>
    </BrowserRouter>
  );
}

Unsaved Changes Guard

JSX
function useBlocker(shouldBlock) {
  const blocker = useBlocker(shouldBlock);

  useEffect(() => {
    if (blocker.state === "blocked") {
      const confirmed = window.confirm(
        "You have unsaved changes. Leave anyway?"
      );
      if (confirmed) {
        blocker.proceed();
      } else {
        blocker.reset();
      }
    }
  }, [blocker]);
}

function EditForm() {
  const [isDirty, setIsDirty] = useState(false);

  useBlocker(isDirty);

  return (
    <form onChange={() => setIsDirty(true)}>
      {/* form fields */}
    </form>
  );
}

Interview Questions Answered

Q: What is the difference between <Link> and <NavLink>?

Both render an <a> tag. NavLink adds an isActive prop to its className and style callbacks, letting you style the active link differently. Use NavLink for navigation menus, Link for inline links.

Q: What does the end prop on NavLink do?

By default, /dashboard matches /dashboard/users (prefix match). The end prop makes it only match exact paths — important for the root route so "Home" isn't always active.

Q: What is the difference between replace and push in navigation?

navigate("/path") pushes a new entry — the back button goes back. navigate("/path", { replace: true }) replaces the current entry — the back button skips over it. Use replace for redirects (login → dashboard) so users don't navigate "back" to the login page.

Q: How does React Router handle 404s?

Add a route with path="*" as the last route. It matches any URL that didn't match previous routes. For nested routes, you typically want a catch-all at each nesting level or rely on the top-level catch-all.