React Router: Complete Navigation Guide
March 7, 2025 · 8 min read
React, React Router, JavaScript, Navigation, Interview Prep
Introduction
Single-page applications need client-side routing to handle navigation without full page reloads. React Router is the standard solution, and understanding it deeply is essential for any React developer.
This guide covers React Router v6, which introduced significant API changes from v5.
Basic Setup
import { BrowserRouter, Routes, Route } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}
Navigation with Link
import { Link, NavLink } from 'react-router-dom';
function Navigation() {
return (
<nav>
{/* Basic link */}
<Link to="/">Home</Link>
{/* NavLink - adds active class automatically */}
<NavLink
to="/about"
className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}
>
About
</NavLink>
{/* NavLink with style */}
<NavLink
to="/contact"
style={({ isActive }) => ({
fontWeight: isActive ? 'bold' : 'normal'
})}
>
Contact
</NavLink>
</nav>
);
}
Dynamic Routes and Parameters
// Route definition
<Route path="/users/:userId" element={<UserProfile />} />
<Route path="/posts/:category/:postId" element={<Post />} />
// Accessing parameters
import { useParams } from 'react-router-dom';
function UserProfile() {
const { userId } = useParams();
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
if (!user) return <Loading />;
return <Profile user={user} />;
}
function Post() {
const { category, postId } = useParams();
// Both parameters available
}
Optional Parameters
// React Router v6 handles this with separate routes
<Route path="/search" element={<Search />} />
<Route path="/search/:query" element={<Search />} />
function Search() {
const { query } = useParams();
// query is undefined if not in URL
}
Nested Routes
Nested routes are powerful for layouts and sub-navigation.
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="dashboard" element={<Dashboard />}>
<Route index element={<DashboardHome />} />
<Route path="analytics" element={<Analytics />} />
<Route path="settings" element={<Settings />} />
</Route>
<Route path="users" element={<Users />}>
<Route index element={<UserList />} />
<Route path=":userId" element={<UserProfile />} />
</Route>
</Route>
</Routes>
</BrowserRouter>
);
}
// Layout renders children via Outlet
import { Outlet } from 'react-router-dom';
function Layout() {
return (
<div className="app">
<Header />
<main>
<Outlet /> {/* Child routes render here */}
</main>
<Footer />
</div>
);
}
function Dashboard() {
return (
<div className="dashboard">
<DashboardSidebar />
<div className="dashboard-content">
<Outlet /> {/* Nested dashboard routes */}
</div>
</div>
);
}
Index Routes
The index route renders when the parent path matches exactly. It's the "default" child route - like a welcome page before selecting sub-navigation.
Programmatic Navigation
import { useNavigate, useLocation } from 'react-router-dom';
function LoginForm() {
const navigate = useNavigate();
const location = useLocation();
// Get the page user was trying to access
const from = location.state?.from?.pathname || '/dashboard';
const handleLogin = async (credentials) => {
await login(credentials);
// Navigate with replace (can't go back to login)
navigate(from, { replace: true });
};
const handleCancel = () => {
navigate(-1); // Go back
};
return (
<form onSubmit={handleLogin}>
{/* form fields */}
<button type="button" onClick={handleCancel}>Cancel</button>
<button type="submit">Login</button>
</form>
);
}
Passing State
// Passing state
navigate('/user/123', {
state: { from: 'search', query: 'john' }
});
// Reading state
function UserProfile() {
const location = useLocation();
const { from, query } = location.state || {};
return (
<div>
{from === 'search' && (
<Link to={`/search?q=${query}`}>← Back to search</Link>
)}
</div>
);
}
Query Parameters
import { useSearchParams } from 'react-router-dom';
function ProductList() {
const [searchParams, setSearchParams] = useSearchParams();
// Read params
const category = searchParams.get('category');
const sort = searchParams.get('sort') || 'name';
const page = parseInt(searchParams.get('page') || '1');
// Update params
const handleCategoryChange = (newCategory) => {
setSearchParams(prev => {
prev.set('category', newCategory);
prev.set('page', '1'); // Reset to first page
return prev;
});
};
const handlePageChange = (newPage) => {
setSearchParams(prev => {
prev.set('page', String(newPage));
return prev;
});
};
return (
<div>
<CategoryFilter
value={category}
onChange={handleCategoryChange}
/>
<SortDropdown value={sort} onChange={/* ... */} />
<ProductGrid category={category} sort={sort} page={page} />
<Pagination
currentPage={page}
onChange={handlePageChange}
/>
</div>
);
}
Protected Routes
Implement authentication-based route protection:
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
function ProtectedRoute({ children }) {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return <LoadingScreen />;
}
if (!isAuthenticated) {
// Redirect to login, saving the attempted URL
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
// Usage in routes
function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
{/* Protected routes */}
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
{/* Protected route with nested routes */}
<Route
path="/admin"
element={
<ProtectedRoute>
<AdminLayout />
</ProtectedRoute>
}
>
<Route index element={<AdminHome />} />
<Route path="users" element={<AdminUsers />} />
</Route>
</Routes>
);
}
Role-Based Protection
function RequireRole({ children, allowedRoles }) {
const { user, isAuthenticated } = useAuth();
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (!allowedRoles.includes(user.role)) {
return <Navigate to="/unauthorized" replace />;
}
return children;
}
// Usage
<Route
path="/admin"
element={
<RequireRole allowedRoles={['admin', 'superadmin']}>
<AdminPanel />
</RequireRole>
}
/>
UX Tip for Protected Routes
When building Glucoplate's premium features, I learned to always save the intended destination in location state. After login or upgrade, users should land where they were trying to go - not a generic dashboard. Small UX detail, big impact on conversion.
Route Loaders (React Router v6.4+)
Data fetching integrated with routing:
import {
createBrowserRouter,
RouterProvider,
useLoaderData
} from 'react-router-dom';
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{
path: 'users/:userId',
element: <UserProfile />,
loader: async ({ params }) => {
const user = await fetchUser(params.userId);
return user;
},
errorElement: <UserError />
}
]
}
]);
function UserProfile() {
const user = useLoaderData(); // Data from loader
return <Profile user={user} />;
}
function App() {
return <RouterProvider router={router} />;
}
When to Use Loaders
Route loaders are ideal when data fetching is tightly coupled to the URL. They enable parallel data fetching and better loading states. For complex apps, consider combining with React Query for caching and background updates.
Navigation Hooks Summary
| Hook | Purpose |
|---|---|
useNavigate() | Programmatic navigation |
useLocation() | Current location object (pathname, search, state) |
useParams() | URL parameters from dynamic segments |
useSearchParams() | Query string parameters |
useMatch(pattern) | Check if current URL matches pattern |
useLoaderData() | Data from route loader |
Interview Questions
Q1: How do you handle 404 pages in React Router?
Answer: Use a catch-all route with path="*" as the last route:
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Routes>
The * matches any path not matched by previous routes.
Q2: What's the difference between Link and Navigate?
Answer:
<Link>is a component for declarative navigation - renders an anchor tag for user clicks<Navigate>is a component that redirects immediately when rendered - used for programmatic redirects in JSXuseNavigate()hook provides a function for programmatic navigation in event handlers
Q3: How do you persist state across navigation?
Answer: Several options:
- URL state - Query params (
useSearchParams) - Location state -
navigate('/path', { state: data })anduseLocation().state - Global state - Context, Redux, Zustand
- Session/Local storage - For persistence across refreshes
URL state is preferred for shareable/bookmarkable state; location state for temporary navigation context.
Q4: How do you prevent navigation (e.g., unsaved form)?
Answer: Use the useBlocker hook (v6.4+) or <Prompt> (v5):
import { useBlocker } from 'react-router-dom';
function FormPage() {
const [isDirty, setIsDirty] = useState(false);
useBlocker(
({ currentLocation, nextLocation }) =>
isDirty && currentLocation.pathname !== nextLocation.pathname
);
return <form>{/* ... */}</form>;
}
Common Mistakes
1. Forgetting to Wrap with Router
// ❌ Error: useNavigate can only be used within Router
function App() {
return <Navigation />; // Uses useNavigate inside
}
// ✅ CORRECT
function App() {
return (
<BrowserRouter>
<Navigation />
</BrowserRouter>
);
}
2. Not Handling Loading States with Dynamic Routes
// ❌ Crashes if userId changes before fetch completes
function UserProfile() {
const { userId } = useParams();
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
return <div>{user.name}</div>; // user might be null!
}
// ✅ Handle loading
function UserProfile() {
const { userId } = useParams();
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetchUser(userId)
.then(setUser)
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <Spinner />;
if (!user) return <NotFound />;
return <div>{user.name}</div>;
}
3. Using Index as Route Key
// ❌ Don't do this with dynamic route generation
{routes.map((route, index) => (
<Route key={index} path={route.path} element={route.element} />
))}
// ✅ Use stable key
{routes.map(route => (
<Route key={route.path} path={route.path} element={route.element} />
))}
Summary
- BrowserRouter wraps your app; Routes contains route definitions
- Nested routes use
<Outlet />for child rendering - Dynamic segments (
:userId) accessed viauseParams() - Programmatic navigation with
useNavigate() - Protected routes redirect unauthenticated users
- Query params managed with
useSearchParams()
Next up: API Integration with React Query and data fetching patterns.
Part 4 of the React Developer Reference series.