Good to knowAdvanced React Patterns · 06

State Initializers

Let the consumer choose the starting state, and make reset go back to it, even if the prop changes later.

On this page · 7 sections
  1. In one minute
  2. The example: gift wrap by default
  3. How it works
  4. Common mistakes
  5. Interview Q&A
  6. Related
  7. Sources

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.

Plain object
useToggle.ts
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}
What you see
☑ Gift wrap new orders by default
Gift wrap: on
Reset
  1. render phaseconst { on, toggle, reset } = useToggle({ initialOn: giftByDefault })First render: the switch starts on, from the store setting.
  2. eventThe shopper turns gift wrap off.
  3. render phaseconst initialState = { on: initialOn } // every renderThe store setting is unticked. This render builds a new initialState = { on: false }, and this render’s reset captures it.
  4. eventconst 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.

0 / 0
The gift-wrap switch starts from a store setting and has a Reset button. Start on, switch off, untick the setting, then click Reset (both versions recorded).

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

Common mistakes

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.

Sources

Primary source: react.dev/reference/react/useRef