Must knowAdvanced React Patterns · 05

Prop Collections and Getters

Hand out the props an element needs, and merge the consumer’s own handlers instead of overwriting them.

On this page · 7 sections
  1. In one minute
  2. The example: tracking the gift-wrap button
  3. How it works
  4. Common mistakes
  5. Interview Q&A
  6. Related
  7. Sources

In one minute

A toggle button needs aria-checked set from state and an onClick that toggles. Every consumer of a useToggle hook has to remember both. A prop collection bundles them into an object (togglerProps) you spread onto the element. But a spread is just object keys: if you add your own onClick, one of the two handlers silently replaces the other. A prop getter fixes that. It’s a function, getTogglerProps(yourProps), that merges your props with the hook’s and chains handlers so both run.

You’ll be able to: write a prop getter with a callAll helper, and explain why a plain prop collection breaks custom handlers.

Spread, then onClick
GiftWrap.tsx
1function useToggle() {2  const [on, setOn] = useState(false)3  const toggle = () => setOn(!on)4  const togglerProps = { 'aria-checked': on, onClick: toggle }5  return { on, togglerProps }6}78const { on, togglerProps } = useToggle()9<button {...togglerProps} onClick={track}>10  Gift wrap: {on ? 'on' : 'off'}11</button>
What you see
Gift wrap: off
Analytics: –
  1. render phase<button {...togglerProps} onClick={track}>JSX props behave like object keys: the last onClick wins, so track replaces the hook’s toggle.
  2. eventRecorded after two clicks: analytics logged both times, but the button never toggled.

0 / 0
The store wants an analytics event every time the gift-wrap button is clicked. The button is clicked twice (all three versions recorded).

The example: tracking the gift-wrap button

The store wants an analytics event every time the gift-wrap button is clicked.

1. A prop collection

export function useToggleCollection() {
	const [on, setOn] = useState(false)
	const toggle = () => setOn(!on)
	const togglerProps = { 'aria-checked': on, onClick: toggle }
	return { on, toggle, togglerProps }
}

Adding onClick={track} after the spread:

function GiftWrapSpreadFirst() {
	const { on, togglerProps } = useToggleCollection()
	return (
		<button role="switch" {...togglerProps} onClick={track}>
			Gift wrap: {on ? 'on' : 'off'}
		</button>
	)
}

Clicked twice:

Gift wrap: off (aria-checked=false)
Gift wrap: off (aria-checked=false)
analytics: gift wrap clicked
analytics: gift wrap clicked

Analytics ran, but the button never toggled: the later onClick replaced the hook’s.

Putting it before the spread instead:

function GiftWrapSpreadLast() {
	const { on, togglerProps } = useToggleCollection()
	return (
		<button role="switch" onClick={track} {...togglerProps}>
			Gift wrap: {on ? 'on' : 'off'}
		</button>
	)
}
Gift wrap: on (aria-checked=true)
Gift wrap: off (aria-checked=false)
[]

Now it toggles, and analytics never runs. TypeScript does catch this order, because togglerProps definitely has an onClick:

05-prop-getters.bad.tsx(10,25): error TS2783: 'onClick' is specified more than once, so this usage will be overwritten.

It can’t catch the first order, which is valid code that just does the wrong thing.

2. A prop getter

function callAll<A extends unknown[]>(...fns: Array<((...args: A) => unknown) | undefined>) {
	return (...args: A) => fns.forEach((fn) => fn?.(...args))
}
type ButtonClick = (e: MouseEvent<HTMLButtonElement>) => void

function useToggle() {
	const [on, setOn] = useState(false)
	const toggle = () => setOn(!on)
	function getTogglerProps<P extends object>({ onClick, ...props }: P & { onClick?: ButtonClick } = {} as P) {
		return { 'aria-checked': on, onClick: callAll(onClick, toggle), ...props }
	}
	return { on, toggle, getTogglerProps }
}
function GiftWrap() {
	const { on, getTogglerProps } = useToggle()
	return (
		<button role="switch" {...getTogglerProps({ onClick: track, id: 'gift-wrap' })}>
			Gift wrap: {on ? 'on' : 'off'}
		</button>
	)
}
Gift wrap: on (aria-checked=true)
Gift wrap: off (aria-checked=false)
analytics: gift wrap clicked
analytics: gift wrap clicked

Both ran on every click, and the extra id passed straight through (id="gift-wrap" on the button).

How it works

Common mistakes

Interview Q&A

What's a prop collection?

An object of props a hook returns for a typical element, like { 'aria-checked': on, onClick: toggle }, so the consumer can spread it in one line and get the behavior and accessibility right.

What breaks when the consumer adds their own onClick?

Spread order decides which single onClick survives. Recorded: with the consumer’s handler last, the switch never toggled; with it first, analytics never logged. TypeScript only flagged the second case (TS2783).

What's a prop getter, and how does it fix this?

A function the hook returns, like getTogglerProps(props), that takes the consumer’s props and returns merged props, combining handlers with something like callAll(theirs, ours). Recorded: both the toggle and the analytics call ran on each click.

Why destructure onClick separately in the getter?

Because it’s the prop that must be merged. Everything else is spread through as is, so the consumer can still add or override non-function props.

Where have you seen this pattern?

Downshift’s getInputProps/getItemProps, React Table’s getTableProps, Conform’s getFormProps: all prop getters.

Sources

Primary source: kentcdodds.com/blog/how-to-give-rendering-control-to-users-with-prop-getters