Sometimes, people want to be able to manage the internal state of our component from the outside. The state reducer allows them to manage what state changes are made when a state change happens, but sometimes people may want to make state changes themselves. We can allow them to do this with a feature called “Control Props.”
This concept is basically the same as controlled form elements 📜 in React that you’ve probably used many times.
function MyCapitalizedInput() {const [capitalizedValue, setCapitalizedValue] = useState('')return (<inputvalue={capitalizedValue}onChange={e => setCapitalizedValue(e.target.value.toUpperCase())}/>)}
In this case, the “component” that’s implemented the “control props” pattern is
the <input />
. Normally it controls state itself (like if you render
<input />
by itself with no value
prop). But once you add the value
prop,
suddenly the <input />
takes the back seat and instead makes “suggestions” to
you via the onChange
prop on the state updates that it would normally make
itself.
This flexibility allows us to change how the state is managed (by capitalizing the value), and it also allows us to programmatically change the state whenever we want to, which enables this kind of synchronized input situation:
function MyTwoInputs() {const [capitalizedValue, setCapitalizedValue] = useState('')const [lowerCasedValue, setLowerCasedValue] = useState('')function handleInputChange(e) {setCapitalizedValue(e.target.value.toUpperCase())setLowerCasedValue(e.target.value.toLowerCase())}return (<><input value={capitalizedValue} onChange={handleInputChange} /><input value={lowerCasedValue} onChange={handleInputChange} /></>)}
Real World Projects that use this pattern:
👨💼 In this exercise, we’ve created a <Toggle />
component which can accept a
prop called on
and another called onChange
. These work similar to the
value
and onChange
props of <input />
. Your job is to make those props
actually control the state of on
and call the onChange
with the suggested
changes.
import { useState } from 'react'import { Toggle, type ToggleAction, type ToggleState } from './toggle.tsx'export function App() {const [bothOn, setBothOn] = useState(false)const [timesClicked, setTimesClicked] = useState(0)function handleToggleChange(state: ToggleState, action: ToggleAction) {if (action.type === 'toggle' && timesClicked > 4) {return}setBothOn(state.on)setTimesClicked(c => c + 1)}function handleResetClick() {setBothOn(false)setTimesClicked(0)}return (<div><div><Toggle on={bothOn} onChange={handleToggleChange} /><Toggle on={bothOn} onChange={handleToggleChange} /></div>{timesClicked > 4 ? (<div data-testid="notice">Whoa, you clicked too much!<br /></div>) : (<div data-testid="click-count">Click count: {timesClicked}</div>)}<button onClick={handleResetClick}>Reset</button><hr /><div><div>Uncontrolled Toggle:</div><ToggleonChange={(...args) =>console.info('Uncontrolled Toggle onChange', ...args)}/></div></div>)}
import { useReducer, useRef } from 'react'import { Switch } from '#shared/switch.tsx'function callAll<Args extends Array<unknown>>(...fns: Array<((...args: Args) => unknown) | undefined>) {return (...args: Args) => fns.forEach(fn => fn?.(...args))}export type ToggleState = { on: boolean }export type ToggleAction =| { type: 'toggle' }| { type: 'reset'; initialState: ToggleState }export function toggleReducer(state: ToggleState, action: ToggleAction) {switch (action.type) {case 'toggle': {return { on: !state.on }}case 'reset': {return action.initialState}}}export function useToggle({initialOn = false,reducer = toggleReducer,onChange,on: controlledOn,}: {initialOn?: booleanreducer?: typeof toggleReduceronChange?: (state: ToggleState, action: ToggleAction) => voidon?: boolean} = {}) {const { current: initialState } = useRef<ToggleState>({ on: initialOn })const [state, dispatch] = useReducer(reducer, initialState)const onIsControlled = controlledOn != nullconst on = onIsControlled ? controlledOn : state.onfunction dispatchWithOnChange(action: ToggleAction) {if (!onIsControlled) {dispatch(action)}onChange?.(reducer({ ...state, on }, action), action)}const toggle = () => dispatchWithOnChange({ type: 'toggle' })const reset = () => dispatchWithOnChange({ type: 'reset', initialState })function getTogglerProps<Props>({onClick,...props}: { onClick?: React.ComponentProps<'button'>['onClick'] } & Props) {return {'aria-checked': on,onClick: callAll(onClick, toggle),...props,}}function getResetterProps<Props>({onClick,...props}: { onClick?: React.ComponentProps<'button'>['onClick'] } & Props) {return {onClick: callAll(onClick, reset),...props,}}return {on,reset,toggle,getTogglerProps,getResetterProps,}}export function Toggle({on: controlledOn,onChange,}: {on?: booleanonChange?: (state: ToggleState, action: ToggleAction) => void}) {const { on, getTogglerProps } = useToggle({ on: controlledOn, onChange })const props = getTogglerProps({ on })return <Switch {...props} />}
Quick Links
Legal Stuff
Social Media