Dynamic Routes & Navigation
URL-Driven React Apps
LinkedIn Hook
Most React developers can set up basic routes. But the moment an interviewer asks "how do you extract a product ID from the URL?" or "how do you navigate programmatically after a form submission?" — silence.
Dynamic routing is the backbone of every real React application. Product pages, user profiles, search results with filters, dashboards with tab state in the URL — all of these depend on route parameters, query strings, and programmatic navigation.
Yet most tutorials stop at
<Route path="/about">. They never show you useParams for dynamic segments, useSearchParams for query strings, useNavigate for redirects after async operations, or how to build a proper 404 catch-all route.In interviews, they will hand you a scenario: "Build a product detail page that reads the ID from the URL, a search page that syncs filters with query parameters, and redirect unauthenticated users to login." They want to see if you understand how React Router turns the URL into application state.
In this lesson, I break down every piece: route parameters with useParams, query strings with useSearchParams, programmatic navigation with useNavigate, redirect patterns, and the catch-all 404 route that every production app needs.
If you have ever hardcoded state that should live in the URL — this lesson will change how you think about routing.
Read the full lesson -> [link]
#React #JavaScript #InterviewPrep #Frontend #ReactRouter #WebDevelopment #CodingInterview #100DaysOfCode
What You'll Learn
- How to define dynamic route segments and extract them with useParams
- How to read and update query strings with useSearchParams
- How to navigate programmatically with useNavigate (redirects, back navigation, state passing)
- How to build redirect patterns for common scenarios like post-login redirects
- How to set up a 404 catch-all route that handles unknown URLs gracefully
The Concept — The URL Is Your State
Analogy: The Library Catalog System
Imagine a public library. Every book has a unique call number on its spine — something like FIC/TOL/001. When you walk up to the front desk and give the librarian that call number, she knows exactly which shelf, section, and slot to find your book. You do not need to explain the book, describe the cover, or tell her the story. The call number is enough.
That call number is a route parameter. In React Router, /products/42 works the same way — 42 is the ID, and your component knows exactly what to fetch and display.
Now imagine you also tell the librarian: "I want fiction books, sorted by author, published after 2020." That is not a specific book — that is a search with filters. The librarian does not change the shelf you are looking at. She applies filters to narrow the results. That is a query string: /books?genre=fiction&sort=author&after=2020. The base route stays the same, but the filters modify what you see.
Finally, imagine you try to find a book with call number XYZ/999/000 — it does not exist. The librarian does not just stare at you. She says "that book is not in our catalog" and points you to the help desk. That is a 404 catch-all route — a fallback for when no route matches.
And programmatic navigation? That is the librarian physically walking you from the fiction section to the reference section after you return a book — moving you to a new location based on an action you just completed, not because you asked to go there.
Route Parameters with useParams
Route parameters are dynamic segments in your URL path. You define them with a colon prefix in the route path, and extract them in the component with useParams.
Code Example 1: Product Detail Page with useParams
import { BrowserRouter, Routes, Route, Link, useParams } from "react-router-dom";
// Simulated product data — in real apps, you would fetch this from an API
const products = [
{ id: "1", name: "Mechanical Keyboard", price: 129 },
{ id: "2", name: "Wireless Mouse", price: 59 },
{ id: "3", name: "USB-C Monitor", price: 349 },
];
function ProductList() {
return (
<div>
<h1>Products</h1>
<ul>
{products.map((product) => (
<li key={product.id}>
{/* Link creates a clickable navigation element — no page reload */}
<Link to={`/products/${product.id}`}>
{product.name} — ${product.price}
</Link>
</li>
))}
</ul>
</div>
);
}
function ProductDetail() {
// useParams returns an object of key-value pairs from the URL
// The keys match the dynamic segments defined in the Route path
const { id } = useParams();
// Find the product matching the URL parameter
const product = products.find((p) => p.id === id);
// Always handle the case where the parameter does not match any data
if (!product) {
return <h2>Product not found (ID: {id})</h2>;
}
return (
<div>
<h2>{product.name}</h2>
<p>Price: ${product.price}</p>
<p>Product ID from URL: {id}</p>
<Link to="/products">Back to Products</Link>
</div>
);
}
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/products" element={<ProductList />} />
{/* :id is a dynamic segment — it matches any value */}
<Route path="/products/:id" element={<ProductDetail />} />
</Routes>
</BrowserRouter>
);
}
// Navigating to /products/2:
// Output: "Wireless Mouse", "Price: $59", "Product ID from URL: 2"
// Navigating to /products/999:
// Output: "Product not found (ID: 999)"
Key point: useParams always returns strings. If your ID is a number in your database, you must convert it: const numericId = Number(id). Forgetting this is a common source of bugs when comparing with ===.
Query Strings with useSearchParams
Query strings hold optional, non-hierarchical data — filters, sort order, pagination, search terms. Unlike route parameters, they do not define what resource you are looking at. They modify how you view it.
Code Example 2: Search Page with Filters Using useSearchParams
import { BrowserRouter, Routes, Route, useSearchParams } from "react-router-dom";
const allProducts = [
{ id: 1, name: "Mechanical Keyboard", category: "peripherals", price: 129 },
{ id: 2, name: "Wireless Mouse", category: "peripherals", price: 59 },
{ id: 3, name: "USB-C Monitor", category: "displays", price: 349 },
{ id: 4, name: "Standing Desk", category: "furniture", price: 499 },
{ id: 5, name: "Webcam HD", category: "peripherals", price: 79 },
];
function SearchPage() {
// useSearchParams works like useState but syncs with the URL query string
// searchParams is a URLSearchParams object (browser API)
// setSearchParams updates the URL without a page reload
const [searchParams, setSearchParams] = useSearchParams();
// Read current filter values from the URL
const category = searchParams.get("category") || "all";
const sortBy = searchParams.get("sort") || "name";
// Filter products based on URL query parameters
let filtered = allProducts;
if (category !== "all") {
filtered = filtered.filter((p) => p.category === category);
}
// Sort products based on URL query parameter
const sorted = [...filtered].sort((a, b) => {
if (sortBy === "price") return a.price - b.price;
return a.name.localeCompare(b.name);
});
function handleCategoryChange(e) {
// setSearchParams replaces ALL query params — preserve existing ones
setSearchParams((prev) => {
prev.set("category", e.target.value);
return prev;
});
}
function handleSortChange(e) {
setSearchParams((prev) => {
prev.set("sort", e.target.value);
return prev;
});
}
function clearFilters() {
// Passing an empty object removes all query parameters
setSearchParams({});
}
return (
<div>
<h1>Product Search</h1>
{/* Filters — changes update the URL, making the page shareable */}
<div>
<label>
Category:{" "}
<select value={category} onChange={handleCategoryChange}>
<option value="all">All</option>
<option value="peripherals">Peripherals</option>
<option value="displays">Displays</option>
<option value="furniture">Furniture</option>
</select>
</label>
<label>
Sort by:{" "}
<select value={sortBy} onChange={handleSortChange}>
<option value="name">Name</option>
<option value="price">Price</option>
</select>
</label>
<button onClick={clearFilters}>Clear Filters</button>
</div>
{/* Product list reflects current URL state */}
<ul>
{sorted.map((p) => (
<li key={p.id}>
{p.name} — ${p.price} ({p.category})
</li>
))}
</ul>
</div>
);
}
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/search" element={<SearchPage />} />
</Routes>
</BrowserRouter>
);
}
// URL: /search?category=peripherals&sort=price
// Output:
// Wireless Mouse — $59 (peripherals)
// Webcam HD — $79 (peripherals)
// Mechanical Keyboard — $129 (peripherals)
// URL: /search (no params)
// Output: All 5 products sorted by name
Key point: Storing filters in the URL instead of component state means the page is shareable and bookmarkable. A user can copy the URL, send it to a colleague, and they see the exact same filtered view. This is a major UX win that interviewers notice.
Programmatic Navigation with useNavigate
Sometimes navigation happens as a result of an action — a form submission, a successful API call, a logout. You cannot use a <Link> for these cases because navigation is triggered by code, not by a click.
Code Example 3: Redirect After Form Submission
import {
BrowserRouter, Routes, Route, useNavigate, useLocation
} from "react-router-dom";
import { useState } from "react";
function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const [error, setError] = useState("");
// Check if the user was redirected here from a protected page
// location.state holds data passed during navigation
const from = location.state?.from || "/dashboard";
async function handleSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
const email = formData.get("email");
const password = formData.get("password");
try {
// Simulate an API call
await fakeLogin(email, password);
// Navigate to the page the user originally wanted
// { replace: true } replaces the login page in history
// so pressing "back" does not go back to the login form
navigate(from, { replace: true });
} catch (err) {
setError("Invalid credentials. Try again.");
}
}
return (
<div>
<h1>Login</h1>
{error && <p style={{ color: "red" }}>{error}</p>}
<form onSubmit={handleSubmit}>
<input name="email" type="email" placeholder="Email" required />
<input name="password" type="password" placeholder="Password" required />
<button type="submit">Log In</button>
</form>
</div>
);
}
function Dashboard() {
const navigate = useNavigate();
function handleLogout() {
// Clear auth state (simplified)
localStorage.removeItem("token");
// Navigate to login — replace history so "back" does not return to dashboard
navigate("/login", { replace: true });
}
return (
<div>
<h1>Dashboard</h1>
<p>Welcome back!</p>
<button onClick={handleLogout}>Log Out</button>
{/* Navigate back one step in browser history */}
<button onClick={() => navigate(-1)}>Go Back</button>
{/* Navigate forward (if there is forward history) */}
<button onClick={() => navigate(1)}>Go Forward</button>
</div>
);
}
function fakeLogin(email, password) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (email === "test@test.com" && password === "password") {
resolve({ token: "abc123" });
} else {
reject(new Error("Invalid credentials"));
}
}, 500);
});
}
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</BrowserRouter>
);
}
// User visits /dashboard while not logged in
// -> Redirected to /login with state: { from: "/dashboard" }
// User logs in successfully
// -> navigate("/dashboard", { replace: true })
// -> User lands on /dashboard, pressing "back" does NOT go to /login
Key point: The replace: true option is critical for login flows. Without it, the user can press the back button and land on the login page again even though they are already authenticated. This is a detail interviewers specifically look for.
The 404 Catch-All Route
Every production app needs a fallback for URLs that do not match any defined route. React Router uses a wildcard path * that matches anything.
Code Example 4: Catch-All 404 with Navigation Options
import { BrowserRouter, Routes, Route, Link, useNavigate } from "react-router-dom";
function NotFound() {
const navigate = useNavigate();
return (
<div style={{ textAlign: "center", padding: "50px" }}>
<h1>404 — Page Not Found</h1>
<p>The page you are looking for does not exist.</p>
{/* Give the user clear options to recover */}
<div>
<Link to="/">Go to Home</Link>
{" | "}
<button onClick={() => navigate(-1)}>Go Back</button>
</div>
</div>
);
}
function Home() {
return (
<div>
<h1>Home</h1>
<nav>
<Link to="/products">Products</Link>{" | "}
<Link to="/about">About</Link>{" | "}
<Link to="/this-page-does-not-exist">Broken Link (404)</Link>
</nav>
</div>
);
}
function About() {
return <h1>About Us</h1>;
}
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
{/* The catch-all route MUST be last
path="*" matches any URL that no other route matched */}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}
// Navigating to /about:
// Output: "About Us"
// Navigating to /xyz or /products/foo/bar/baz:
// Output: "404 — Page Not Found" with links to go Home or go Back
Key point: The * route must be the last route in your <Routes>. React Router matches routes in order of specificity, but placing the catch-all last makes intent clear and avoids confusion during code review.
Common Mistakes
Mistake 1: Forgetting that useParams values are always strings
// BAD: Strict comparison fails because useParams returns strings
function ProductDetail() {
const { id } = useParams();
// id is "42" (string), not 42 (number)
// If your data uses numeric IDs, this comparison silently fails
const product = products.find((p) => p.id === id);
// Returns undefined if product.id is the number 42
// GOOD: Convert the parameter to the expected type
const product = products.find((p) => p.id === Number(id));
// Or ensure your data uses string IDs consistently
}
Mistake 2: Overwriting all query params when updating one
// BAD: Setting one param destroys all existing params
function SearchPage() {
const [searchParams, setSearchParams] = useSearchParams();
function updateSort(value) {
// This REPLACES all params — category filter disappears
setSearchParams({ sort: value });
// URL goes from /search?category=electronics&sort=name
// to /search?sort=price
// category is gone
}
// GOOD: Use the callback form to preserve existing params
function updateSort(value) {
setSearchParams((prev) => {
prev.set("sort", value);
return prev;
});
// URL goes from /search?category=electronics&sort=name
// to /search?category=electronics&sort=price
// category is preserved
}
}
Mistake 3: Not using replace in redirect flows
// BAD: After login, user can press "back" and land on login page again
function handleLoginSuccess() {
navigate("/dashboard");
// History: [..., /login, /dashboard]
// Pressing back -> /login (confusing for authenticated user)
}
// GOOD: Replace the login entry in history
function handleLoginSuccess() {
navigate("/dashboard", { replace: true });
// History: [..., /dashboard]
// Pressing back -> whatever was before /login (expected behavior)
}
Interview Questions
Q: What is the difference between route parameters and query strings? When do you use each?
Route parameters (
:idin the path) identify a specific resource — they are required and hierarchical. Use them for resource identity:/users/42,/posts/hello-world. Query strings (?key=value) are optional modifiers that change how you view a resource — filters, sorting, pagination. Use them for:/products?category=shoes&sort=price&page=2. Route params answer "what am I looking at?" Query strings answer "how am I looking at it?"
Q: How does useNavigate differ from the Link component?
<Link>is a declarative JSX element the user clicks — it renders an anchor tag.useNavigatereturns a function for imperative navigation triggered by code — after a form submission, an API response, a timeout, or any logic-driven event. Use Link for navigation the user initiates by clicking. Use useNavigate for navigation your code initiates after an operation completes.
Q: What does the replace option do in useNavigate, and when should you use it?
By default,
navigate("/path")pushes a new entry onto the history stack. With{ replace: true }, it replaces the current entry instead. Use it in login redirects (so "back" does not return to the login form), after form submissions (to prevent duplicate submissions via back button), and in redirect components (so the redirected-from URL is not in history). The mental model: push means "the user can go back here," replace means "this page should not exist in history."
Q: How do you handle a URL parameter that does not match any data?
After extracting the parameter with useParams, check if the corresponding data exists. If it does not, render a "not found" message or redirect to a 404 page. Never assume the URL parameter is valid — users can type arbitrary values into the address bar, and bots crawl random paths. Defensive checks prevent blank screens and cryptic errors.
Q: How would you build a search page where filters persist in the URL?
Use useSearchParams to sync filter state with the URL query string. Read initial values from searchParams on mount, update the URL when filters change using setSearchParams (with the callback form to preserve other params), and derive the filtered data from the URL state — not from separate useState. This makes the page shareable, bookmarkable, and browser-back-button friendly. The URL becomes the single source of truth for the current filter state.
Quick Reference — Cheat Sheet
DYNAMIC ROUTES & NAVIGATION
=============================
Route Parameters (useParams):
Define: <Route path="/products/:id" element={<Detail />} />
Extract: const { id } = useParams()
Note: Always returns STRINGS — convert if needed
Use for: Resource identity (/users/42, /posts/hello-world)
Query Strings (useSearchParams):
Read: const [searchParams, setSearchParams] = useSearchParams()
Get: searchParams.get("sort") // "price" or null
GetAll: searchParams.getAll("tag") // ["react", "hooks"]
Set one: setSearchParams(prev => { prev.set("sort", "price"); return prev })
Clear: setSearchParams({})
Use for: Filters, sort, pagination, search terms
Programmatic Navigation (useNavigate):
Setup: const navigate = useNavigate()
Go to: navigate("/dashboard")
Replace: navigate("/dashboard", { replace: true })
Back: navigate(-1)
Forward: navigate(1)
State: navigate("/login", { state: { from: "/dashboard" } })
Read: const location = useLocation(); location.state?.from
Catch-All 404:
Define: <Route path="*" element={<NotFound />} />
Position: MUST be the last route in <Routes>
Matches: Any URL that no other route matched
+------------------+------------------+---------------------------+
| Hook | Purpose | Returns |
+------------------+------------------+---------------------------+
| useParams | Route params | { id: "42" } |
| useSearchParams | Query strings | [URLSearchParams, setter] |
| useNavigate | Programmatic nav | navigate function |
| useLocation | Current URL info | { pathname, search, ... } |
+------------------+------------------+---------------------------+
Route Params vs Query Strings:
Params: Required, identifies resource, part of path
Query: Optional, modifies view, after ? in URL
Both: Live in URL — shareable, bookmarkable, survives refresh
Common patterns:
Post-login redirect: navigate(from, { replace: true })
Logout redirect: navigate("/login", { replace: true })
Back button: navigate(-1)
Preserve params: setSearchParams(prev => { prev.set(k, v); return prev })
Previous: Lesson 7.1 — Client-Side Routing -> Next: Lesson 7.3 — Protected Routes & Route Guards ->
This is Lesson 7.2 of the React Interview Prep Course — 10 chapters, 42 lessons.