skip to content

In React Router v7, what type are the values useParams returns, and what must you do before using :productId as a number?

level: juniorimportance: must knowfreq 64%

answer

  1. the URL only holds text
  2. string or undefined
  3. the pattern does not validate
  4. parse, check, then handle invalid

basics

~20 s

useParams values are strings or undefined, decoded from the URL and never coerced. A :productId segment matches any text, so parse it, check the result, and render a not-found or error state when it is missing or invalid.

solid answer

~40 s

`useParams()` returns an object typed as `{ readonly [key: string]: string | undefined }`: every value is the percent-decoded text of its URL segment, never a number or a date. The route pattern `products/:productId` accepts any non-empty segment, so `/products/abc` matches and hands you `"abc"`. Before using it as a number, parse it (`Number(productId)`), check it (`Number.isInteger(id) && id > 0`) and branch on failure, typically to a not-found screen. `undefined` is part of the type because the hook is not tied to the route config: the component might render under a route without that param, and optional segments are genuinely absent. Child routes also see their parents' params, so a nested reviews screen can read `productId`.

code

tsx · 14 lines
tsx
import { useParams } from "react-router";

function useProductId(): number | null {
  const { productId } = useParams();
  if (!productId || !/^\d+$/.test(productId)) return null;
  const id = Number(productId);
  return id > 0 ? id : null;
}

export function ProductPage() {
  const productId = useProductId();
  if (productId === null) return <p role="alert">Product not found.</p>;
  return <ProductDetails id={productId} />;
}

go deeper

for a junior

Recall that useParams values are strings, possibly undefined, and that you must convert and check them before using them as numbers.

for a middle

Explain why the type includes undefined, that dynamic segments accept any text, that values are decoded, and that child routes inherit parent params.

for a senior

Centralise parsing and validation in a small hook, treat URL values as untrusted input, and render a deliberate not-found state for bad ids.

for a principal

Set conventions for URL-derived ids across teams, such as shared parsers and one not-found path, so invalid URLs fail the same way everywhere.

## What `useParams` returns `useParams()` returns the **dynamic segment values** of the current match, as an object keyed by the names used in the route pattern. For a route declared as `products/:productId` and the URL `/products/42`, it returns `{ productId: "42" }`. Three properties of those values drive everything else: - **They are strings.** A URL is text; React Router does not coerce `"42"` into a number or `"2026-09-25"` into a date. - **They are decoded.** Percent-encoding is removed, so `/products/red%20shoes` yields `"red shoes"`. - **They may be `undefined`.** The type is `Params = { readonly [key: string]: string | undefined }`. Child routes inherit their parents' params. A `reviews` route nested under `products/:productId` sees `productId` in its own `useParams()` result. A splat value appears under the key `"*"`. ## Why the pattern does not validate A dynamic segment in React Router matches **any non-empty segment** of the URL. `:productId` is a name, not a type, so all of these match `products/:productId`: | URL | `productId` | |---|---| | `/products/42` | `"42"` | | `/products/abc` | `"abc"` | | `/products/-1` | `"-1"` | | `/products/4.5` | `"4.5"` | Users edit URLs, links go stale, and crawlers try odd paths, so a component that assumes a well-formed number will eventually receive something else. ## Parse, check, branch The robust pattern has three steps: 1. **Parse** into the type you need: `Number(productId)` or a stricter parser. 2. **Check** the result against your domain's rules: `Number.isInteger(id) && id > 0`, a UUID format, a known slug list. 3. **Branch** on failure to a deliberate outcome: a not-found screen, a message, or a redirect to the list. Doing this once, near the top of the route's component (or in a small helper like `useProductId()`), keeps the rest of the tree working with a trusted value. ## Why `undefined` is in the type In declarative and data modes, `useParams` has no link to the route configuration. The hook cannot know that the component is only rendered under `products/:productId`, and in fact it may not be: - the component might be reused under a different route that has no `productId`; - optional segments (`:lang?`) are really absent when the URL omits them; - tests may render the component without the route. The generic form `useParams<"productId">()` narrows which **keys** exist but keeps each value `string | undefined`. The honest handling is a guard, not a non-null assertion: `if (!productId) return <NotFound />`. ## Common mistakes - **`Number(params.productId)` without a check.** `Number("abc")` is `NaN`, and `NaN` flows into requests and comparisons without an error. - **`parseInt` leniency.** `parseInt("42abc", 10)` returns `42`, silently accepting a malformed URL; prefer `Number` plus an integer check, or a regular expression. - **Non-null assertions everywhere** (`params.productId!`), which turn a missing param into a runtime failure far from its cause. - **Comparing strings to numbers.** `productId === 42` is always false; compare like with like. - **Treating the URL as trusted input.** Anything read from `useParams` is user-controlled and must be validated before it reaches an API call. ## The reverse direction: building URLs with params When you create a link to a product, the same rules apply in reverse. `generatePath("/products/:productId", { productId: String(id) })` fills the pattern and runs each dynamic value through `encodeURIComponent`, so a slug containing a space or a slash cannot break the path. It also fails fast: a missing required param throws an invariant error naming it (`Missing ":productId" param`), while optional ones may be omitted. Building the path by string concatenation skips both protections, which is how a value like `a/b` ends up split across two segments and matching a different route entirely. ## Summary - Values are decoded strings, possibly `undefined`, never coerced. - `:param` accepts any segment text; the route does not validate format. - Parse, check and branch once, near the route's entry component. - Child routes see their ancestors' params.

  • In React Router v7, a reviews route is nested under products/:productId. Can its component read productId?
    Yes. Child routes inherit all params from their parent routes, so `useParams()` in the reviews component returns `productId` alongside any params the reviews route declares itself. Avoid giving a child the same param name as an ancestor, because the later value overrides the earlier one.
  • In React Router v7, why not simply write useParams().productId! to satisfy TypeScript?
    The assertion hides a real case. If the component is rendered under a route without that param, or in a test without the route, the value is `undefined` and the failure surfaces later as a confusing error. A guard that renders a not-found state keeps the failure local and visible.

saying these in an interview costs you the question

  • useParams converts numeric segments into numbers automatically
  • A :productId segment only matches digits
  • The generic useParams<"productId">() removes undefined from the type
  • parseInt is enough validation for an id read from the URL
  • A child route cannot see its parent route's params