When Implicit Context is Safe

A component that reaches into its environment for what it needs is one that fails silently when the environment changes. There is a place where implicit context is safe, and a moment when that safety disappears.

Divyanshu Pundir
May 18, 2026
6 min read

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.tsx

When 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.tsx

order-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.