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.
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}- event
if (!onIsControlled) dispatch(action)Anonprop was passed, so this toggle is controlled: it skips its owndispatch. - event
onChange?.(reducer({ ...state, on }, action)) // a suggestionIt runs the reducer directly, only to compute what it would do, and passes that toonChangeas a suggestion. - schedule
setGiftWrap(suggested.on)The parent accepts the suggestion and updates its state. - commit phaseRecorded:
Checkoutand all three toggles rendered, and both gift-wrap switches turned on, though only one was clicked.
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}- event
onChange?.(reducer({ ...state, on }, action)) // a suggestionThe toggle suggests turning gift wrap off. - event
if (orderPlaced) return // ignore itThe parent doesn’t callsetGiftWrap. - commit phaseRecorded: “ignored the change”, and nothing rendered. A controlled component can only change through its parent.
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}- event
<Toggle onChange={s => log(s.on)} />Noonprop: this toggle is uncontrolled. - render phase
if (!onIsControlled) dispatch(action)So it dispatches to its own state;onChangeis only a notification. Recorded: only this toggle rendered.
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
- Two sources, one chosen.
useReduceralways runs, but when controlled,oncomes from the prop and the internal state is ignored. That avoids two copies of the truth drifting apart. - Suggestions come from the same reducer.
reducer({ ...state, on }, action)computes “what I’d do” without committing it. A custom reducer (The State Reducer Pattern) shapes the suggestion too. - The round trip: click →
onChange(suggestion)→ parentsetState→ parent re-renders → newonprop → toggle shows it. If the parent doesn’t callsetState, nothing happens. - Same contract as form inputs. An
<input value={v}>ignores typing until youronChangesetsv(Inputs). Like inputs, a component shouldn’t switch between controlled and uncontrolled during its life; libraries such as Downshift warn when it does.
Common mistakes
- Passing
onwithoutonChange. The component becomes read-only, like<input value>withoutonChange. - Passing
on={undefined}sometimes and a boolean other times. That silently switches modes. - Expecting
onChangeto mean the value changed. In controlled mode it’s a suggestion; the value changes only if the parent accepts it. - Using control props when a state reducer would do. If the consumer only needs to tweak how the component reacts to its own clicks, a reducer is less work for them: they don’t have to own and pass back the state.
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).
Related
- Inputs: controlled and uncontrolled inputs.
- The State Reducer Pattern and State Initializers: the earlier steps of this hook.
- Lifting State: owning shared state in the parent.
Sources
- Kent C. Dodds: When to use control props or state reducers
- react.dev: Controlling an input with a state variable
- Radix UI: Select (
value/onValueChange) - 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/control-props-vs-state-reducers