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.
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>- render phase
<button {...togglerProps} onClick={track}>JSX props behave like object keys: the lastonClickwins, sotrackreplaces the hook’stoggle. - eventRecorded after two clicks: analytics logged both times, but the button never toggled.
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 onClick={track} {...togglerProps}>10 Gift wrap: {on ? 'on' : 'off'}11</button>- render phase
<button onClick={track} {...togglerProps}>The other order: now the hook’sonClickwins. TypeScript actually flags this one (TS2783: “specified more than once”). - eventRecorded: the button toggles, but analytics never logged.
1const callAll = (...fns) => (...args) => fns.forEach(fn => fn?.(...args))23function useToggle() {4 const [on, setOn] = useState(false)5 const toggle = () => setOn(!on)6 function getTogglerProps({ onClick, ...props } = {}) {7 return { 'aria-checked': on, onClick: callAll(onClick, toggle), ...props }8 }9 return { on, getTogglerProps }10}1112<button {...getTogglerProps({ onClick: track, id: 'gift-wrap' })}>- render phase
<button {...getTogglerProps({ onClick: track, id: 'gift-wrap' })}>The consumer hands its props to the hook instead of spreading them next to the hook’s. - render phase
return { 'aria-checked': on, onClick: callAll(onClick, toggle), ...props }The hook combines both handlers into one function that calls each. Other props, likeid, pass through. - event
const callAll = (...fns) => (...args) => fns.forEach(fn => fn?.(...args))Recorded: on, then off, with analytics logged each time.
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
- Spreading is last-key-wins. JSX props behave like an object literal, so two
onClicks can’t both survive a spread. Only code that runs can combine two functions. - The getter takes your props and returns the merged result. It pulls out the handlers it needs to merge (
onClick), chains them withcallAll, and spreads everything else, so other props likeidoraria-labelpass through or override. callAll(onClick, toggle)returns one function that calls each one that’s defined, in order.callAll(undefined, toggle)just toggles.- Name them
get…Props. Downshift (getInputProps,getItemProps,getMenuProps), React Table and Conform use this convention. Seeing it tells you: call this, don’t spread your own handlers next to it.
Common mistakes
- Shipping a collection when consumers will add handlers. Default to getters for anything with event handlers.
- Letting the consumer override the handler entirely. Chain the hook’s handler; don’t let a spread of
...propsafter it replace it. - Needing a veto and only having
callAll. If the consumer’s handler should be able to stop the hook’s (say, afterpreventDefault()), checkevent.defaultPreventedbefore calling the hook’s handler.
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.
Related
- Compound Components: the component-level way to share toggle state.
- The State Reducer Pattern: the same
useToggle, letting consumers control transitions. - Inputs:
aria-*attributes and form controls.
Sources
- Kent C. Dodds: How to give rendering control to users with prop getters, Mixing component patterns
- Downshift, a library built on prop getters
- Topic order inspired by Kent C. Dodds’ EpicReact Advanced React Patterns workshop; the example app and code here are this handbook’s own.
Primary source: kentcdodds.com/blog/how-to-give-rendering-control-to-users-with-prop-getters