ESC

Type to search the knowledge base.

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.

beginner7 min read
  • 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. !on is the easy line.
  • useReducer((on) => !on, initial) is the short form. The returned dispatch is toggle.
  • useCallback(() => setOn((v) => !v), []) is the useState form. The setter is stable. The functional update avoids a stale on.
  • () => setOn(!on) rebuilds every render. useCallback(() => setOn(!on), [on]) rebuilds every flip. useCallback(() => setOn(!on), []) flips once and sticks.
  • setTrue / setFalse are dispatch('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:

  1. Parent renders. toggle is dispatch. Row receives it and paints.
  2. Parent re-renders because a sibling useState changed. toggle is still dispatch. React.memo(Row) bails out.
  3. Click. dispatch runs the reducer. on flips. Parent re-renders. Row still bails out if it does not also receive on.

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 use useReducer((on) => !on, false) so dispatch is the toggle and stays stable, or useCallback with a functional setOn((v) => !v) and empty deps. The empty-deps form that still reads on from the closure is the stale-click bug. If I need setTrue and setFalse, I dispatch named actions through that same stable dispatch, wrapped in useCallback. I do not wrap dispatch in a fresh arrow and call it done.

Further reading

Related guides