August 27, 2026

Implicit Contracts: When a Component Assumes Something About Its Children

Wrapper components often make silent assumptions about what they're wrapping: a single child, a mounted-and-waiting tree. When those assumptions break, the failure is usually confusing rather than obvious.

frontendreactcompatibilityweb-development

A component that wraps or coordinates other components frequently makes assumptions about them that aren't written down anywhere a type system or a compiler would catch. A wrapper might assume its child is a single element it can safely clone props onto. A router might assume the component it's rendering can simply be removed from the tree the instant navigation happens. Neither assumption is unreasonable on its own, but neither is enforced, and when the assumption turns out to be wrong, the failure tends to be confusing rather than obvious, because nothing actually says "this component expects X" out loud.

This is a recurring category worth naming precisely: an implicit contract between a wrapper and whatever it's coordinating, a contract that exists only as an assumption baked into the wrapper's implementation, not as something enforced or even necessarily documented.

Why this matters, and where it shows up

The reason this is worth understanding as its own category, rather than debugging each instance from scratch, is that the failure mode is nearly always the same shape regardless of which specific library is involved: something that looks like it should work silently doesn't, or works most of the time and breaks under a specific condition that wasn't obviously related to the change that introduced it. Knowing the general pattern, that this is a wrapper assuming something about what it wraps, turns a confusing one-off into a recognizable, diagnosable category.

It's relevant anywhere component composition involves one component managing, cloning props onto, or controlling the lifecycle of another, which describes a large share of component libraries and routing systems in practice, not a narrow edge case.

Two examples of the same failure

Composition patterns that clone or forward props

A common pattern in component libraries lets a wrapper component render as if it were its child element instead of adding an extra DOM node, often called the "asChild" pattern, popularized by Radix UI and since adopted broadly across the React ecosystem. It's genuinely useful: it lets a styled Button component, for instance, render as an <a> tag when composed with a link, without introducing a redundant wrapping <button>.

The pattern has real, documented constraints, and violating them fails silently or with a confusing error rather than a clear one:

  • The child must be a single React element. Passing a fragment with multiple children, or conditionally rendering null, breaks the contract, since the wrapper has nothing single and well-defined to clone its props onto.
  • Event handler merge order matters. When a wrapper injects its own onClick alongside a child's existing onClick, the order they run in affects behavior. Radix's own documentation flags that if either handler depends on event.defaultPrevented, the outcome changes depending on which handler runs first.
// The wrapper expects exactly one child element to clone props onto.
<Button asChild>
  <a href="/pricing">View pricing</a>
</Button>
 
// Breaks the contract: no single element to clone onto.
<Button asChild>
  {condition && <a href="/pricing">View pricing</a>}
</Button>

It's worth treating any component that clones or forwards props onto its children with the same suspicion: know what shape it expects, and don't assume that shape is obvious from the component's name alone.

Routers and animation libraries assuming different lifecycles

A second, structurally identical example shows up at the intersection of routing and animation. Wrapping page content in an animation library's exit-transition component (commonly AnimatePresence-style APIs), expecting a smooth animated transition between routes, frequently produces an instant cut instead. This isn't a bug specific to one library: independent issue trackers across Framer Motion, TanStack Router, and React Router all describe the same root cause.

The conflict is, again, two components making incompatible assumptions about the same thing. A router, on navigation, unmounts the previous route's component immediately and mounts the new one: it assumes removal can happen instantly. An exit animation needs the outgoing component to remain mounted and rendering for the duration of the animation: it assumes it will be told before removal happens, not after. The router has usually already removed the old component from the tree before the animation library gets a chance to intervene, which is exactly why libraries like this need a dedicated "presence"-tracking component at all. Its entire purpose is to intercept and defer that removal long enough for an exit animation to finish, patching over the router's assumption with its own.

The common mitigation across ecosystems is keying the routed element by the current path, so the animation library can distinguish "the same route re-rendering" from "a genuinely different route that should animate out and in":

<AnimatePresence mode="wait">
  <motion.div key={location.pathname} exit={{ opacity: 0 }}>
    <Outlet />
  </motion.div>
</AnimatePresence>

Even this doesn't always work cleanly with every router's internals, because some routers replace the entire subtree in a single commit rather than giving an animation library a window to observe the transition. It's worth checking whether the specific router and animation library combination in use actually supports this pattern before assuming it will.

There's a platform-level answer emerging for this exact conflict, worth knowing as the direction this is heading: React's <ViewTransition> component, built on the browser-native View Transitions API, and Next.js's own documented view-transitions integration. Both move transition orchestration out of a third-party animation library fighting the router's lifecycle, and into the browser and framework directly, a case where a recurring userland workaround eventually became a native platform feature, removing the mismatched assumption rather than working around it.

Applying it

  • Before composing two components where one clones props, forwards refs, or controls another's lifecycle, check what shape or timing it actually expects: a single child, a specific mount order, being told before removal rather than after.
  • When something that "should obviously work" fails in a confusing way after composing two libraries together, consider whether the two are making incompatible assumptions about each other rather than either one being straightforwardly broken.
  • Look for a library's own documented constraints on composition before assuming a pattern that works in one context (a single child, a specific prop shape) will work identically everywhere else it's used.
  • Where a recurring workaround exists for a structural mismatch like this, check whether the platform or framework has since absorbed it as a native feature. The userland fix is sometimes no longer the best available one.