In React Router v7, what type are the values useParams returns, and what must you do before using :productId as a number?
answer
- the URL only holds text
- string or undefined
- the pattern does not validate
- parse, check, then handle invalid
basics
~20 suseParams 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 linesimport { 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
Recall that useParams values are strings, possibly undefined, and that you must convert and check them before using them as numbers.
Explain why the type includes undefined, that dynamic segments accept any text, that values are decoded, and that child routes inherit parent params.
Centralise parsing and validation in a small hook, treat URL values as untrusted input, and render a deliberate not-found state for bad ids.
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