Skip to main content

πŸ”‘ 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 :param and read it with useParams()
  • 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.

graph LR P["Route pattern
/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'
A URL broken into a static segment, a required param, an optional param, and a wildcard /shop static :category required param :sub? optional param * wildcard (rest)
Four kinds of path segment: fixed text, a required :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 parameterQuery string
Looks like/products/42/products?sort=price&page=2
AnswersWhich one?Shown how?
Read withuseParams()useSearchParams()
Good forIDs, slugs, required identityFilters, 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 :id everywhere
  • 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 + 1 on "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 :param in 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

πŸš€ 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.