useToggle Hook
A boolean and a stable toggle. useReducer or a lazy setter keeps the function identity, so children memoized on toggle do not re-render for free.
- react
- hooks
- state
- interview
You wrap a row in React.memo and pass it onToggle so typing in a parent search box does not redraw every switch. Then you write const toggle = () => setOn(!on). Every parent render allocates a new function. Memo compares props with Object.is, sees a new onToggle, and the child renders anyway.
useToggle is a boolean plus a stable flip. The interview is not !value. It is identity: the function you return must not change because the parent re-rendered, and it must not close over a stale boolean.
useReducer gives you that for free: dispatch is stable for the life of the component. useCallback plus a functional setState also works, with [] deps, because the updater does not read on from the render. A new arrow every render is the miss. Optional setTrue / setFalse are extra stable setters, not a second source of truth.
- Return a boolean and a stable
toggle. Identity is the product.!onis the easy line.useReducer((on) => !on, initial)is the short form. The returneddispatchistoggle.useCallback(() => setOn((v) => !v), [])is theuseStateform. The setter is stable. The functional update avoids a staleon.() => setOn(!on)rebuilds every render.useCallback(() => setOn(!on), [on])rebuilds every flip.useCallback(() => setOn(!on), [])flips once and sticks.setTrue/setFalsearedispatch('on')/dispatch('off')wrapped so callers do not pass an action type.
A boolean and a stable toggle, not a new function every render
The one-liner interviewers actually want:
import { useReducer } from 'react';
function useToggle(initial = false) {
return useReducer((on) => !on, Boolean(initial));
}
function PasswordField() {
const [visible, toggleVisible] = useToggle(false);
return (
<>
<input type={visible ? 'text' : 'password'} />
<button type="button" onClick={toggleVisible}>
{visible ? 'Hide' : 'Show'}
</button>
</>
);
}
useReducer returns [state, dispatch]. The reducer ignores its action and returns !state. Calling toggleVisible() (or toggleVisible('anything')) always flips. dispatch does not change between renders. That is documented React behavior, same as the useState setter.
Walk a memoized child:
- Parent renders.
toggleisdispatch.Rowreceives it and paints. - Parent re-renders because a sibling
useStatechanged.toggleis stilldispatch.React.memo(Row)bails out. - Click.
dispatchruns the reducer.onflips. Parent re-renders.Rowstill bails out if it does not also receiveon.
If Row needs the boolean, it will re-render when on changes. Stability still matters for a setter-only child (a toolbar button, a context value that is only the dispatcher). See React.memo and useMemo and useCallback.
Why useState plus a fresh arrow fails
function useToggleBroken(initial = false) {
const [on, setOn] = useState(initial);
const toggle = () => setOn(!on); // new function every render
return [on, toggle];
}
| Form | toggle identity |
Flip correctness |
|---|---|---|
() => setOn(!on) |
New every render | Correct for that render, useless for memo |
useCallback(() => setOn(!on), [on]) |
New every flip | Correct, still churns identity when on changes |
useCallback(() => setOn(!on), []) |
Stable | Wrong. Closes over the first on. Second click sets the same value. React bails out. The switch sticks. |
useCallback(() => setOn((v) => !v), []) |
Stable | Correct. Updater reads the queued state, not the render. |
useReducer((on) => !on, false) |
Stable (dispatch) |
Correct. No useCallback to forget. |
The stale-[] row is the one that looks “optimized” and fails in review. Functional updates exist for this: next state depends on previous state, so you must not close over on. Same rule as incrementing a counter in useState Patterns.
import { useCallback, useState } from 'react';
function useToggle(initial = false) {
const [on, setOn] = useState(Boolean(initial));
const toggle = useCallback(() => {
setOn((v) => !v);
}, []);
return [on, toggle];
}
You do not put setOn in the dependency array. React guarantees the setter is stable. Including it is noise. Omitting on is required; including on is the identity leak.
setTrue / setFalse without breaking the one-liner
The ignore-action reducer cannot set a value. toggle(true) still flips, because the reducer never reads the argument. If the prompt asks for on/off helpers, name a real action:
import { useCallback, useReducer } from 'react';
function toggleReducer(state, action) {
switch (action) {
case 'toggle':
return !state;
case 'on':
return true;
case 'off':
return false;
default:
return state;
}
}
function useToggle(initial = false) {
const [on, dispatch] = useReducer(toggleReducer, Boolean(initial));
const toggle = useCallback(() => dispatch('toggle'), []);
const setTrue = useCallback(() => dispatch('on'), []);
const setFalse = useCallback(() => dispatch('off'), []);
return [on, toggle, setTrue, setFalse];
}
dispatch is already stable. The useCallback wrappers exist so callers get zero-arg functions. If you write const toggle = () => dispatch('toggle') without useCallback, you are back to a new arrow every render and the reducer win is gone.
You can skip the wrappers and return dispatch plus documented action strings. That is a worse call site (dispatch('toggle') in JSX) and easy to typo. Prefer named functions.
Object return vs tuple:
// New object every render. Memo children that take the bag still re-render.
return { on, toggle, setTrue, setFalse };
If you want an object API, wrap it in useMemo with [on] (the functions are stable). Most interview whiteboards should return a tuple and stop.
Concurrent React and the render-time flip
Do not write this:
function useToggleBroken(initial = false) {
const [on, setOn] = useState(initial);
const onRef = useRef(on);
onRef.current = on;
const toggle = useCallback(() => {
setOn(!onRef.current);
}, []);
return [on, toggle];
}
It often works in a demo. A discarded concurrent render can publish a boolean that never committed, and the next click flips the wrong snapshot. The functional updater and useReducer read React’s queued state, not a ref you mutated during render. Keep the mutation inside React’s update, not in a box you maintain by hand.
useReducer is also the better story when the boolean later grows into a small machine (open, closing, locked). Start here, grow in useReducer for Complex State.
Footguns
Returning a new options object. [on, toggle] is two stable-enough slots (on changes when it must, toggle does not). { on, toggle } without useMemo makes React.memo on the child pointless if the child receives the whole object.
Passing toggle and on into the same memo child, then wondering why it still renders. It should. The boolean changed. Stability pays off when the child only needs the setter, or when toggle sits in a dependency array of an effect you do not want to re-subscribe.
Conditional useToggle. if (enabled) { const [on, toggle] = useToggle(); } breaks Rules of Hooks. Always call it. Ignore the result, or pass enabled into the click handler.
Coercing poorly. useToggle('no') with Boolean(initial) becomes true. If you want a strict boolean, accept boolean only and let a bad call throw in development.
Using toggle as a stand-in for derived UI. If the screen can compute visible from a parent prop, do not store a copy and flip it. Toggle is for independent on/off state.
Interview out-loud answer
I would not write
const toggle = () => setOn(!on). That function is a new reference every render, so memoized children and effect deps churn. I useuseReducer((on) => !on, false)sodispatchis the toggle and stays stable, oruseCallbackwith a functionalsetOn((v) => !v)and empty deps. The empty-deps form that still readsonfrom the closure is the stale-click bug. If I needsetTrueandsetFalse, I dispatch named actions through that same stabledispatch, wrapped inuseCallback. I do not wrapdispatchin a fresh arrow and call it done.
Further reading
- react.dev:
useReducer - react.dev:
useState(stable setter, functional updates) - react.dev: Skipping re-renders with
memo - react.dev: Caching functions with
useCallback
Related on this site
- useState Patterns: functional updates when next depends on prev
- useMemo and useCallback: when identity is the actual requirement
- useReducer for Complex State: grow the boolean into a machine
- React.memo: why an unstable
toggledefeats memo - Custom Hooks Design:
useprefix, stable returns - Rules of Hooks: no conditional
useToggle - Batching State Updates: two
toggle()calls in one event
Related guides
- useCopyToClipboard HookCopy text with navigator.clipboard, fallback execCommand, transient copied state, and permissions that fail in insecure contexts.
- useDebouncedValue HookDebounce a changing value in React without firing effects on every keystroke. Cleanup, leading vs trailing, and the stale timer that updates unmounted state.
- useEventListener HookAttach a window, document, or element listener with a stable handler ref so you do not rebind on every render. Capture option and target changes.
- useIdle HookDetect idle users: reset a timer on input events, fire idle after timeout, and decide whether mount starts the timer immediately.
- useInfiniteScroll HookLoad the next page when a sentinel enters the viewport. IntersectionObserver, hasMore, in-flight guards, and the footer that never intersects.