In one minute
A reusable hook shouldn’t hard-code its starting state. The state initializer pattern adds an initial… option (useToggle({ initialOn: true })) and usually a reset() that goes back to it. Like useState’s argument and an input’s defaultValue, the initial value should count only on the first render. The subtle part: if the consumer’s initialOn changes later, reset() must still go back to the value the hook started with. Keeping the initial state in a useRef makes sure of it.
You’ll be able to: add an initial value and a reset to a custom hook, and keep reset stable when the prop changes.
1function useToggle({ initialOn = false } = {}) {2 const initialState = { on: initialOn } // every render3 const [state, dispatch] = useReducer(toggleReducer, initialState)4 const reset = () => dispatch({ type: 'reset', initialState })5 return { on: state.on, reset /* , toggle */ }6}78function GiftOptions() {9 const [giftByDefault, setGiftByDefault] = useState(true)10 const { on, toggle, reset } = useToggle({ initialOn: giftByDefault })11 // checkbox → setGiftByDefault · switch → toggle · Reset → reset12}- render phase
const { on, toggle, reset } = useToggle({ initialOn: giftByDefault })First render: the switch starts on, from the store setting. - eventThe shopper turns gift wrap off.
- render phase
const initialState = { on: initialOn } // every renderThe store setting is unticked. This render builds a newinitialState = { on: false }, and this render’sresetcaptures it. - event
const reset = () => dispatch({ type: 'reset', initialState })Click Reset. Recorded: the switch stayed off. It “reset” to the current setting, not to how it started, so nothing visibly happened.
1function useToggle({ initialOn = false } = {}) {2 const { current: initialState } = useRef({ on: initialOn })3 const [state, dispatch] = useReducer(toggleReducer, initialState)4 const reset = () => dispatch({ type: 'reset', initialState })5 return { on: state.on, reset /* , toggle */ }6}78function GiftOptions() {9 const [giftByDefault, setGiftByDefault] = useState(true)10 const { on, toggle, reset } = useToggle({ initialOn: giftByDefault })11 // checkbox → setGiftByDefault · switch → toggle · Reset → reset12}- render phase
const { on, toggle, reset } = useToggle({ initialOn: giftByDefault })First render: the switch starts on, from the store setting. - eventThe shopper turns gift wrap off.
- render phase
const { current: initialState } = useRef({ on: initialOn })The store setting is unticked, soinitialOnis nowfalse. The ref still holds{ on: true }from the first render. - event
const reset = () => dispatch({ type: 'reset', initialState })Click Reset. Recorded: the switch went back to on, how it actually started.
The example: gift wrap by default
The store has a setting: “Gift wrap new orders by default”. The checkout’s gift-wrap switch starts from that setting, and has a Reset button.
type ToggleState = { on: boolean }
type ToggleAction = { type: 'toggle' } | { type: 'reset'; initialState: ToggleState }
function toggleReducer(state: ToggleState, action: ToggleAction): ToggleState {
switch (action.type) {
case 'toggle':
return { on: !state.on }
case 'reset':
return action.initialState
}
}
function GiftOptions({ useToggleHook }: { useToggleHook: typeof useToggle }) {
const [giftByDefault, setGiftByDefault] = useState(true) // a store setting
const { on, toggle, reset } = useToggleHook({ initialOn: giftByDefault })
return (
<>
<label>
<input id="setting" type="checkbox" checked={giftByDefault} onChange={(e) => setGiftByDefault(e.target.checked)} />
Gift wrap new orders by default
</label>
<Switch id="gift" on={on} onClick={toggle} aria-label="Gift wrap" />
<button id="reset" onClick={reset}>Reset</button>
</>
)
}
The recorded steps: start (setting ticked), click the switch off, untick the setting, then click Reset.
1. Initial state rebuilt every render
function useTogglePlain({ initialOn = false } = {}) {
const initialState = { on: initialOn } // rebuilt on every render
const [state, dispatch] = useReducer(toggleReducer, initialState)
const toggle = () => dispatch({ type: 'toggle' })
const reset = () => dispatch({ type: 'reset', initialState })
return { on: state.on, toggle, reset }
}
mount (setting on): true
click switch: false
untick "by default": false
click Reset: false
Reset did nothing visible. useReducer used initialState only on the first render, but reset is a new function each render, and it captured that render’s initialState, built from the current initialOn (false).
2. Initial state kept in a ref
function useToggle({ initialOn = false } = {}) {
const { current: initialState } = useRef({ on: initialOn }) // kept from the first render
const [state, dispatch] = useReducer(toggleReducer, initialState)
const toggle = () => dispatch({ type: 'toggle' })
const reset = () => dispatch({ type: 'reset', initialState })
return { on: state.on, toggle, reset }
}
mount (setting on): true
click switch: false
untick "by default": false
click Reset: true
Reset went back to on, where the switch started. The ref is created on the first render and React returns the same object after that, so initialState is always the first one.
How it works
useRef(value)keeps the first value. The argument is evaluated on every render but only used on the first; after thatref.currentis whatever was there. (useState(() => …)keeps a first value too, but it’s state, meant for values the UI displays.)resetis an action, not a computation.{ type: 'reset', initialState }says “replace the state with this”, which the reducer can’t derive from the current state.- Initial means initial. The same contract as
useState(initial)and<input defaultValue>: later changes to the prop are ignored. If the consumer needs to change the value from outside at any time, that’s Control Props. - To start over completely, the consumer can also change the component’s
key, which remounts it with fresh state (Rendering Arrays).
Common mistakes
- Expecting a new
initialOnto change the current state. It’s only read on the first render. - Building the reset target from the current props (recorded above).
- Expensive initial values in
useRef(expensive()). The argument runs every render; compute it lazily if it’s costly.
Interview Q&A
What is the state initializer pattern?
Letting the consumer of a hook or component choose its starting state with an initial… option, usually with a reset that returns to it. It’s the same contract as useState(initialValue) or defaultValue.
Why should a later change to initialOn be ignored?
Because it’s an initial value, read once like useState’s argument. Following later changes would make it a controlled value, which is a different pattern (Control Props).
What's the bug with const initialState = { on: initialOn } and reset?
reset is recreated each render and captures that render’s initialState. If initialOn changed, reset goes to the new value, not the starting one. Recorded: after unticking the setting, Reset left the switch off instead of returning to on.
How does useRef fix it?
The ref object is created once, on the first render, with { on: initialOn }; later renders get the same object. reset dispatches that object, so it always goes back to the true starting state. Recorded: Reset returned to on.
How else can a consumer reset a component?
Change its key. React unmounts it and mounts a new one, with all state starting over.
Related
- The State Reducer Pattern: the next step, the consumer controls transitions.
- Control Props: the consumer controls the value at any time.
- Managing UI State:
useState’s initial value and lazy initialization.
Sources
- react.dev:
useRef,useReducer, Resetting state with a key - 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: react.dev/reference/react/useRef