π Route Parameters
You can't write a separate route for every user, product, or blog post β there could be millions. Instead you write one pattern, /users/:userId, and let the URL fill in the blank. Route parameters are how a single component serves an endless catalog of pages, each one keyed off a value pulled straight from the address bar.
Week 5 · Day 3 (Wednesday: Advanced Routing) · Lecture 2
π― Learning Objectives
By the end of this lesson, you will be able to:
- Define a dynamic segment with
:paramand read it withuseParams() - Handle multiple parameters in one route (
/posts/:postId/comments/:commentId) - Build optional and wildcard (
*) segments - Fetch data keyed off a param and re-fetch correctly when it changes
- Validate params and redirect gracefully on bad input
- Combine path params with query strings via
useSearchParams
Estimated Time: 70 minutes
Practice: Build a user-profile page driven entirely by /users/:userId.
In This Lesson
What Is a Route Parameter?
A route parameter is a named placeholder in a route's path, written with a leading colon. Where a static route like /about matches exactly one URL, a dynamic route like /users/:userId matches a whole family of them β /users/1, /users/42, /users/ada β and captures whatever appeared in that slot.
Think of it like a mail-merge template: the route is "Dear :name," and the URL supplies the name. React Router matches the pattern, extracts the value, and makes it available to your component as a string.
/users/:userId"] --> M{Match} U["URL
/users/42"] --> M M --> R["useParams()
{ userId: '42' }"]
One detail to burn in now: params are always strings. The URL /users/42 gives you userId === '42', not the number 42. If you need a number, you convert it yourself β a source of subtle bugs we'll defuse in the validation section.
Reading Params with useParams
Define the dynamic segment in the route path, then call the useParams() hook inside the matched component. It returns an object whose keys are your parameter names.
import { Routes, Route, useParams } from 'react-router-dom';
// The route defines the placeholder:
function App() {
return (
<Routes>
<Route path="/users/:userId" element={<UserProfile />} />
<Route path="/posts/:postId/comments/:commentId" element={<Comment />} />
</Routes>
);
}
// The component reads it:
function UserProfile() {
const { userId } = useParams(); // e.g. "42" from /users/42
return <h1>Profile for user {userId}</h1>;
}
Multiple parameters
A single route can carry several params. Destructure them all from one useParams() call β the keys match the names in the path exactly.
function Comment() {
const { postId, commentId } = useParams();
return (
<div>
<h2>Comment #{commentId}</h2>
<p>On post: {postId}</p>
</div>
);
}
// URL /posts/hello-world/comments/7 β { postId: 'hello-world', commentId: '7' }
π The name in the path is the key
Whatever you write after the colon becomes the object key. /:userId gives you params.userId; /:id gives you params.id. Choose descriptive names β userId, productSlug β over generic ones like id or thing, especially when a route has several.
Fetching Data by Param
The whole point of a param is usually to fetch the matching record. The pattern: read the param, load its data in an effect, and β critically β put the param in the effect's dependency array so navigating from /users/1 to /users/2 re-runs the fetch.
import { useParams } from 'react-router-dom';
import { useState, useEffect } from 'react';
function UserProfile() {
const { userId } = useParams();
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false; // guard against a stale response arriving late
setLoading(true);
fetchUser(userId)
.then(data => { if (!cancelled) { setUser(data); setError(null); } })
.catch(err => { if (!cancelled) { setError(err.message); setUser(null); } })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; }; // cleanup when userId changes
}, [userId]); // <-- re-fetch whenever the param changes
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage message={error} />;
if (!user) return <NotFound />;
return (
<div className="user-profile">
<h1>{user.name}</h1>
<p>User ID: {userId}</p>
</div>
);
}
β οΈ The "same component, new param" trap
When you navigate from /users/1 to /users/2, React reuses the UserProfile instance β it does not unmount and remount. Only the effect's dependency array makes it re-fetch. Miss userId in that array and the page will stubbornly show user 1's data forever. The cancelled flag additionally prevents a slow response for user 1 from overwriting user 2's data (a race condition).
π‘ The modern alternative: route loaders
With React Router's data APIs (createBrowserRouter), you can move fetching out of the component entirely into a route loader that runs before the component renders β no loading flicker, no effect:
import { useLoaderData } from 'react-router-dom';
// in the route config:
{ path: 'users/:userId', element: <UserProfile />,
loader: ({ params }) => fetchUser(params.userId) }
function UserProfile() {
const user = useLoaderData(); // already resolved
return <h1>{user.name}</h1>;
}
Loaders receive params directly, so you often don't even call useParams. We'll lean on the effect pattern above for now, but reach for loaders as your apps grow.
Optional & Wildcard Segments
Optional parameters
Append a ? to make a segment optional. The same route then matches with or without it, and the param is simply undefined when absent.
<Route path="/products/:category/:subcategory?" element={<Products />} />
function Products() {
const { category, subcategory } = useParams();
return (
<div>
<h1>{category} Products</h1>
{subcategory && <h2>Subcategory: {subcategory}</h2>}
</div>
);
}
// /products/electronics β { category: 'electronics', subcategory: undefined }
// /products/electronics/phones β { category: 'electronics', subcategory: 'phones' }
Wildcard (splat) routes
A trailing * captures everything after a point β useful for file paths, docs trees, or any nested structure of unknown depth. Read it from the special "*" key.
<Route path="/files/*" element={<FileExplorer />} />
function FileExplorer() {
const params = useParams();
const filePath = params['*'] || ''; // everything after /files/
return (
<div>
<h1>File Explorer</h1>
<p>Current path: /files/{filePath}</p>
</div>
);
}
// /files/docs/2026/report.pdf β params['*'] === 'docs/2026/report.pdf'
:param, an optional :param?, and a catch-all *.Validating Parameters
Params come from the URL, and the URL comes from users β who mistype, share stale links, and probe your routes. Never trust a param blindly. Validate its shape, convert its type, and redirect on garbage input.
Convert types deliberately
Remember: params are strings. Turn them into what you actually need, right where you read them.
function ArchivePage() {
const { year, month } = useParams();
// Params are strings β convert with a radix, then sanity-check:
const y = Number.parseInt(year, 10);
const m = Number.parseInt(month, 10);
const isValid =
Number.isInteger(y) && y >= 1970 && y <= new Date().getFullYear() &&
Number.isInteger(m) && m >= 1 && m <= 12;
if (!isValid) return <Navigate to="/archive" replace />;
return <h1>Archive for {y}/{m}</h1>;
}
Redirect on invalid input
When a format is wrong, send the user somewhere sensible with <Navigate> (declarative) or useNavigate (imperative). Use replace so the bad URL doesn't linger in history.
import { useNavigate } from 'react-router-dom';
function ProductPage() {
const { productId } = useParams();
const navigate = useNavigate();
useEffect(() => {
// Expect IDs like "PROD-000123"
if (!/^PROD-\d{6}$/.test(productId)) {
navigate('/products', { replace: true });
}
}, [productId, navigate]);
return <ProductDetails productId={productId} />;
}
β Schema validation with Zod
For anything beyond a regex, a schema library like Zod parses and coerces in one step, throwing on bad input you can catch and redirect:
import { z } from 'zod';
const paramsSchema = z.object({
userId: z.string().regex(/^\d+$/).transform(Number),
});
const result = paramsSchema.safeParse(useParams());
if (!result.success) return <Navigate to="/404" replace />;
const { userId } = result.data; // a real number, guaranteed valid
Path Params vs Query Strings
Two different tools for two different jobs. A path parameter identifies which resource β it's part of the resource's address. A query string tweaks how you view that resource β search terms, sort order, page number, filters.
| Path parameter | Query string | |
|---|---|---|
| Looks like | /products/42 | /products?sort=price&page=2 |
| Answers | Which one? | Shown how? |
| Read with | useParams() | useSearchParams() |
| Good for | IDs, slugs, required identity | Filters, sorting, pagination, optional state |
They combine beautifully. Here a path param picks the category while query params drive the search and sort β and setSearchParams keeps the URL in sync so results are shareable and bookmarkable.
import { useParams, useSearchParams } from 'react-router-dom';
function CategoryResults() {
const { category } = useParams(); // which category (identity)
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q') ?? ''; // how to view it (view state)
const sort = searchParams.get('sort') ?? 'relevance';
const page = Number.parseInt(searchParams.get('page') ?? '1', 10);
const updateSort = (value) => {
// Merge into existing params instead of clobbering them:
setSearchParams(prev => {
const next = new URLSearchParams(prev);
next.set('sort', value);
return next;
});
};
return (
<div>
<h1>{category} β page {page}</h1>
<select value={sort} onChange={e => updateSort(e.target.value)}>
<option value="relevance">Relevance</option>
<option value="price">Price</option>
</select>
</div>
);
}
// URL: /category/shoes?q=running&sort=price&page=2
Rule of thumb
/users/42 β 42 is WHO (path param)
/users?role=admin β role=admin is a FILTER (query string)
Practice & Quiz
ποΈ Exercise 1: A user-profile page
Goal: Wire up /users/:userId so UserProfile reads the id, fetches the user, and re-fetches when the id changes.
// TODO: read userId, fetch on mount and whenever it changes,
// and show a loading state.
function UserProfile() { /* ... */ }
π‘ Hint
Call useParams() for userId. Fetch inside useEffect with [userId] as the dependency array so a change to the id re-runs it. Track a loading flag.
β Solution
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 <p>Loadingβ¦</p>;
return <h1>{user.name} (#{userId})</h1>;
}
ποΈ Exercise 2: Guard a numeric id
Goal: On /posts/:postId, redirect to /404 if postId isn't all digits.
β Solution
import { useParams, Navigate } from 'react-router-dom';
function Post() {
const { postId } = useParams();
if (!/^\d+$/.test(postId)) return <Navigate to="/404" replace />;
const id = Number.parseInt(postId, 10);
return <h1>Post {id}</h1>;
}
π― Quick Quiz
Question 1: For the route /users/:userId visited at /users/42, what does useParams() return?
Question 2: Navigating from /users/1 to /users/2 shows stale data. The likely cause?
Question 3: Which belongs in a query string rather than a path param?
Best Practices & Pitfalls
β Do
- Name params descriptively:
:userId,:productSlugβ not:ideverywhere - Remember params are strings; convert with
Number.parseInt(x, 10)when you need numbers - Include the param in your effect's dependency array so data re-fetches on change
- Validate and redirect on malformed input with
<Navigate replace> - Use path params for identity, query strings for filters and sorting
β Don't
- Assume a param is a number β
userId + 1on"42"gives"421" - Forget the dependency array and serve one record forever
- Trust param format blindly β a bad id shouldn't crash the page
- Cram filters into the path (
/products/price/0-100/sort/asc) when a query string is cleaner
β οΈ The string-math surprise
const { page } = useParams(); // "2"
const next = page + 1; // "21" β string concatenation!
const next2 = Number(page) + 1; // 3 β
convert first
This is the same coercion gotcha you met with "5" + 3 back in Week 1 β it just sneaks in through the URL now.
Summary
π Key Takeaways
- A
:paramin a route path is a placeholder;useParams()reads its captured value - Params are always strings β convert types deliberately
- One route can hold multiple params, plus optional (
?) and wildcard (*) segments - Put the param in your effect dependency array so data re-fetches when it changes
- Validate and redirect on bad input instead of trusting the URL
- Path params identify the resource; query strings (
useSearchParams) shape the view
π Additional Resources
- React Router β useParams
- React Router β useSearchParams
- React Router β Dynamic Segments
- MDN β URLSearchParams
π What's Next?
You can now read the URL β next you'll learn to change it from code. Programmatic Navigation covers the useNavigate hook: redirecting after a form submit, guarding protected routes, and moving through history.
π Well done!
Dynamic routing is the backbone of every catalog, feed, and profile page you'll build.