Skip to content

Prop merging

Three parties want to put props on the same element: the component, you, and any element you pass to render. Naive spreading lets whichever runs last silently destroy the others, so each kind of prop is combined rather than replaced.

flowchart TB
  A["1 · the component's own props<br/><small>props, including its data-* state</small>"] --> B["2 · your props<br/><small>consumerProps</small>"]
  B --> C["3 · props on the render element<br/><small>cloneElement</small>"]

  C -.->|"plain values: last wins"| V["className concatenated<br/>style shallow-merged<br/>refs merged<br/>children replaced"]
  C -.->|"handlers: run in reverse"| H["3 → 2 → 1<br/>stop with preventComponentHandler()"]

  classDef out fill:transparent,stroke-dasharray:4 3
  class V,H out
Values resolve top to bottom, last one winning. Handlers run in the opposite direction.

A component’s data-* state attributes are part of its own props, at the bottom of the order, so you can override one when you have a reason to.

The order is not a convention components follow — useRender takes the component’s props and yours in separate arguments and merges them itself, so there is no arrangement a component author could get wrong.

PropHow it combines
classNameconcatenated, never replaced
styleshallow-merged, per property
refmerged — every ref receives the node, and every cleanup runs
event handlerschained, in reverse precedence order
children and everything elselast one wins

undefined is skipped, so an absent prop never clobbers a value someone else set.

children is not special-cased, which means a render element that declares its own content keeps it — and one that declares none inherits yours:

{
/* renders "Docs" — the span has no children, so yours survive */
}
<Button render={<span />}>Docs</Button>;
{
/* renders "Read the docs" — the span's own children are the last word */
}
<Button render={<span>Read the docs</span>}>Docs</Button>;

Both are useful, and neither is a special case: an absent children key is undefined and gets skipped, a present one wins like any other value.

Values resolve so that yours beat the component’s. Handlers do the opposite: yours runs first, and can stop the component’s from running at all.

<Switch.Root onClick={(event) => event.preventComponentHandler()} />

The escape hatch belongs to you. A component already decides which props reach the element, so it never needs the chain to suppress anything — it simply declines to attach a handler. Without this you could not adapt a component’s behaviour without forking it.

preventDefault() is deliberately left alone. It already means “cancel the browser’s default action”, which is a different statement — a link inside a component may want one without the other.

Only real events carry the signal. An on* prop called with something else — onCheckedChange receives a boolean — always runs every handler, since there is nothing to attach it to.

A ref on the component and a ref on the render element both receive the node:

<Switch.Root ref={mine} render={<Tooltip.Trigger ref={theirs} />} />

Both are called on attach, and both are cleaned up on detach — whether they use React 19’s cleanup return or the older null call.