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 reloadSetup and Basic Routing
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— useshistory.pushState, gives clean URLs (/products/123). Requires server to serveindex.htmlfor all routes.HashRouter— uses URL hash (/#/products/123). Works without server config. Used for static hosting.
Dynamic Route Parameters
// 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.
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
// 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.
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
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
// 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
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.