Must knowAdvanced React Patterns · 08

Control Props

A hook that works on its own, or lets the parent own the value: suggestions through onChange, like input value.

On this page · 7 sections
  1. In one minute
  2. The example: one choice, two switches
  3. How it works
  4. Common mistakes
  5. Interview Q&A
  6. Related
  7. Sources

In one minute

Control props apply the controlled <input value onChange> idea to your own components. Left alone, useToggle manages its own on (uncontrolled). Pass it an on prop and it becomes controlled: it stops updating its own state and only suggests the next state through onChange. The parent decides whether to accept it (by setting its own state) or ignore it. This lets the parent own the value: keep two components in sync, change it from outside, or refuse a change.

You’ll be able to: make a hook work both controlled and uncontrolled, explain the round trip a controlled change takes, and choose between control props and a state reducer.

Parent accepts
Checkout.tsx
1function useToggle({ on: controlledOn, onChange, reducer = toggleReducer }) {2  const [state, dispatch] = useReducer(reducer, { on: false })3  const onIsControlled = controlledOn != null4  const on = onIsControlled ? controlledOn : state.on5  function dispatchWithOnChange(action) {6    if (!onIsControlled) dispatch(action)7    onChange?.(reducer({ ...state, on }, action)) // a suggestion8  }9  return { on, toggle: () => dispatchWithOnChange({ type: 'toggle' }) }10}1112function Checkout() {13  const [giftWrap, setGiftWrap] = useState(false)14  function handleChange(suggested) {15    if (orderPlaced) return // ignore it16    setGiftWrap(suggested.on)17  }18  return (<>19    <Toggle on={giftWrap} onChange={handleChange} />20    <Toggle on={giftWrap} onChange={handleChange} />21    <Toggle onChange={s => log(s.on)} />22  </>)23}
What you see
Cart: gift wrap off
Checkout: gift wrap off
Newsletter: off
Rendered this step
Checkout–
Toggle1 (cart)–
Toggle2 (checkout)–
Toggle3 (newsletter)–
  1. eventif (!onIsControlled) dispatch(action)An on prop was passed, so this toggle is controlled: it skips its own dispatch.
  2. eventonChange?.(reducer({ ...state, on }, action)) // a suggestionIt runs the reducer directly, only to compute what it would do, and passes that to onChange as a suggestion.
  3. schedulesetGiftWrap(suggested.on)The parent accepts the suggestion and updates its state.
  4. commit phaseRecorded: Checkout and all three toggles rendered, and both gift-wrap switches turned on, though only one was clicked.

0 / 0
The gift-wrap switch appears in the cart and at checkout and must always agree; a newsletter switch is left uncontrolled (all three runs recorded).

The example: one choice, two switches

The gift-wrap switch appears in the cart summary and on the checkout step. They must always agree, and after the order is placed neither may change. A newsletter switch on the same page is left uncontrolled.

1. The hook

type Options = {
	initialOn?: boolean
	reducer?: typeof toggleReducer
	on?: boolean // pass it to control the value
	onChange?: (suggested: ToggleState, action: ToggleAction) => void
}

function useToggle({ initialOn = false, reducer = toggleReducer, on: controlledOn, onChange }: Options = {}) {
	const { current: initialState } = useRef({ on: initialOn })
	const [state, dispatch] = useReducer(reducer, initialState)
	const onIsControlled = controlledOn != null
	const on = onIsControlled ? controlledOn : state.on

	function dispatchWithOnChange(action: ToggleAction) {
		if (!onIsControlled) dispatch(action)
		onChange?.(reducer({ ...state, on }, action), action) // the suggested next state
	}

	const toggle = () => dispatchWithOnChange({ type: 'toggle' })
	return { on, toggle }
}

The key lines: onIsControlled is decided by whether an on prop was passed (!= null, so undefined and null both mean “not controlled”). When controlled, dispatch is skipped and the reducer is called as a plain function, only to compute the suggestion.

function Toggle({ label, on, onChange }: { label: string } & Pick<Options, 'on' | 'onChange'>) {
	const toggle = useToggle({ on, onChange })
	log(`render Toggle "${label}" (on: ${toggle.on})`)
	return <Switch id={label.toLowerCase()} on={toggle.on} onClick={toggle.toggle} aria-label={label} />
}

2. The page owns the value

function Checkout() {
	const [giftWrap, setGiftWrap] = useState(false)
	const [orderPlaced, setOrderPlaced] = useState(false)
	log('render Checkout')

	function handleGiftWrapChange(suggested: ToggleState) {
		if (orderPlaced) return log('Checkout: ignored the change (order placed)')
		log(`Checkout: setGiftWrap(${suggested.on})`)
		setGiftWrap(suggested.on)
	}

	return (
		<>
			<Toggle label="Cart" on={giftWrap} onChange={handleGiftWrapChange} />
			<Toggle label="Checkout" on={giftWrap} onChange={handleGiftWrapChange} />
			<Toggle label="Newsletter" onChange={(s) => log(`newsletter onChange: ${s.on}`)} />
			<button id="place" onClick={() => setOrderPlaced(true)}>Place order</button>
		</>
	)
}

Recorded. Clicking the Cart switch:

Checkout: setGiftWrap(true)
render Checkout
render Toggle "Cart" (on: true)
render Toggle "Checkout" (on: true)
render Toggle "Newsletter" (on: false)
cart: true, checkout: true

Both switches turned on, though only one was clicked: the click became a suggestion, Checkout stored it, and both toggles got the new on.

Clicking the uncontrolled Newsletter switch:

newsletter onChange: true
render Toggle "Newsletter" (on: true)

It updated its own state; only it re-rendered, and onChange was just a notification.

After “Place order”, clicking the Checkout switch:

Checkout: ignored the change (order placed)
cart: true, checkout: true

The parent ignored the suggestion, so nothing rendered at all: the toggle had skipped its own dispatch, and the parent didn’t set state. A controlled component can only change through its parent.

How it works

Common mistakes

Interview Q&A

What are control props?

Props that let the parent own a component’s state, the way value and onChange do for <input>. Without them the component manages its own state; with them, it only suggests changes through onChange.

How does the hook know it's controlled?

An on prop was passed: const onIsControlled = controlledOn != null. Then on is taken from the prop and internal dispatch is skipped.

Walk through a click on a controlled toggle.

The toggle computes the suggested next state with its reducer and calls onChange. The parent sets its own state, re-renders, and passes the new on down. Recorded: clicking one switch logged Checkout: setGiftWrap(true), then Checkout and both toggles rendered with on: true.

How does a parent veto a change?

It doesn’t call setState. Recorded: after the order was placed, the click logged only “ignored the change”, and no component rendered.

Control props or state reducer?

State reducer: the consumer changes how the component responds to its own actions, but the component still owns the state. Control props: the consumer owns the state, so it can change it from outside, sync several components, or drive it from a URL or a server. Control props are more powerful and more work for the consumer. They combine fine: this hook accepts both.

Where do you see this in real libraries?

Every form input; Radix (value/onValueChange, open/onOpenChange); Downshift (selectedItem, isOpen with onStateChange).

Sources

Primary source: kentcdodds.com/blog/control-props-vs-state-reducers