There's a category of bug that doesn't throw a loud error. A component renders blank. A request goes out with an empty string in the path. A query hook returns nothing. Nothing in the code points at the cause, because the cause isn't in the code. It's in the environment the code was running in.
The component assumed something would be there. It wasn't.
The environment is fragile
When a function reaches outside its arguments to find what it needs, you've coupled its behavior to its surroundings in a way that doesn't show up in the type system.
function OrderSummary() {
const { orderId } = useParams();
// ...
}This looks fine. It probably works the first time. But the contract this function holds with the rest of the world is
now invisible. Nothing in its signature says "I only work when rendered under a route that has an :orderId segment."
The first time someone reuses this component in a modal, a dashboard widget or a different route entirely, it
silently fails.
What we usually see next is worse than the original bug. Instead of fixing the contract, the component gets patched to
guess where the orderId might be coming from this time:
function OrderSummary() {
const { orderId: paramOrderId } = useParams();
const [searchParams] = useSearchParams();
const orderId =
paramOrderId ??
searchParams.get("orderId") ??
localStorage.getItem("lastOrderId") ??
"";
// ...
}Every new caller adds another branch to the entry point. The component now knows about routing, query strings and browser storage, none of which have anything to do with summarising an order. Each branch is a place the value can silently be wrong. The bug isn't gone, it's just been moved further away from where it'll show up.
Compare this to the version where the requirement is in the signature:
function OrderSummary({ orderId }: { orderId: string }) {
// ...
}The compiler now enforces what you used to hope for. You can't render this component without giving it what it needs.
The contract is loud, and the question of where orderId comes from is pushed out to the caller, which is the one
piece of code that actually knows the answer.
The general principle: a function that depends on its environment is one whose dependencies you can't see. A function that takes its dependencies as arguments is one whose dependencies are written down. Pick the second one most of the time, and your future self gets to read the contract instead of guess at it.
Frameworks earn back the implicit
This isn't an absolute rule. There's a place where implicit context is fine, and that place is where the framework has built a strong enough convention that the implicit becomes visible from the file structure alone.
In Next.js, a folder named [orderId] is a contract. In Remix and TanStack Router, a file or folder named $orderId
is the same contract. In SvelteKit, it's [orderId]. In Nuxt, it's [orderId].vue. In each of these, the dynamic
segment isn't hidden in some routes config three layers up. It's right there in the path you typed to find the file.
app/
orders/
[orderId]/
page.tsx
layout.tsx
items/
page.tsxWhen I open app/orders/[orderId]/page.tsx and see params.orderId, I don't have to trace anything. The folder told
me. The convention is doing the work that an explicit prop would normally do. It's making the dependency visible
without requiring it to be written down twice.
This is the only place I let useParams or params.orderId live. The pattern that follows is: extract once at the
route boundary, then pass it down as a regular prop.
export default async function OrderLayout({
params,
}: {
params: Promise<{ orderId: string }>;
}) {
const { orderId } = await params;
return <OrderShell orderId={orderId} />;
}After this point, orderId is just data. Components downstream don't know they're inside a router. They couldn't tell
you what URL they're rendered at. They don't need to.
Shared components don't get to know about the URL
The line gets drawn at the route tree. Anything that lives under a [param] folder gets to read params. Anything
hoisted out of it does not.
The "under a [param] folder" part matters more than people give it credit for. A local components folder scoped to
one route segment is still inside the contract:
app/
orders/
[orderId]/
page.tsx
_components/
order-summary.tsx
order-timeline.tsxorder-summary.tsx here can call useParams() and reach for orderId without anyone wincing. The path you typed to
open the file went through [orderId]/, the same way page.tsx did. The folder is still doing the work. A
co-located library like this is a perfectly fine place to keep the implicit.
The refactor moment is when you decide to promote one of these components somewhere more common, say to
components/order-summary.tsx at the top of the project. The file is now reachable from imports that don't pass
through any [orderId] folder. The contract the old location was relying on is gone. That's the point at which the
component has to start taking orderId as a prop. Not because props are categorically better, but because the
guarantee the file structure used to provide doesn't exist at the new location.
A component in components/order-summary.tsx could be imported anywhere. There's no folder name above it making any
promises about the URL. So it doesn't get to read from the URL. It takes an orderId prop, and the caller decides
where the value comes from.
The first few times you write a shared component this way, it feels like more work. You're typing out one extra prop. But the moment someone needs to render it in a place you didn't anticipate, a drawer, a preview pane, a different section of the app, the design rewards itself. Nothing breaks. The component doesn't care.
The pattern under all of this
The route boundary gets to use the framework's params API because the folder name is the contract. The shared component takes props because there's no folder name making promises for it.
Implicit context is fine when the convention makes it visible. It's not fine when it's hiding. The test I run in my head is: if I'm reading this code from the outside, can I tell what it depends on without running it? If yes, the implicitness is paying for itself. If no, I should be passing a prop.
That's what file-based routing gets right. It takes one of the most common implicit dependencies in a frontend app, the URL, and bakes it into a place you can't miss: the file path. The framework didn't have to do that. It could have asked you to declare your routes in a config and parse params out of an opaque hook. But by tying the dynamic segment to the folder name, it turned an invisible contract into one you trip over every time you navigate the codebase.