Back to blog

Hooks that unlock new patterns

useReducer, useLayoutEffect, and useImperativeHandle aren't complicated. They solve specific problems that the basic hooks can't.

January 28, 202615 min read
ReactHooksuseReduceruseLayoutEffectuseImperativeHandle
Hooks that unlock new patterns
The State Machine - useReducer, useLayoutEffect, useImperativeHandle
The State Machine - useReducer, useLayoutEffect, useImperativeHandle

You've mastered the basics. useState, useEffect, useContext. You know when to reach for useRef, useMemo, useCallback.

But there's a third tier of hooks that most devs never touch. They look at useReducer and think "overkill." They see useLayoutEffect and think "isn't that just useEffect?" They read useImperativeHandle and close the tab.

Here's the thing: these hooks aren't complicated. They solve specific problems that the basic hooks can't. And once you see those problems, you can't unsee them.

Let's unlock some new patterns.


useReducer: when useState becomes a mess

You start with a simple form:

const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [age, setAge] = useState(0);

Then requirements grow:

const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [age, setAge] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(null);
const [touched, setTouched] = useState({});
const [isValid, setIsValid] = useState(false);

Seven useState calls. Seven setter functions. State updates scattered across handlers. Good luck tracking what triggers what.

Enter useReducer

const initialState = {
  name: '',
  email: '',
  age: 0,
  isSubmitting: false,
  error: null,
  touched: {},
};

function formReducer(state, action) {
  switch (action.type) {
    case 'SET_FIELD':
      return {
        ...state,
        [action.field]: action.value,
        touched: { ...state.touched, [action.field]: true }
      };
    case 'SUBMIT_START':
      return { ...state, isSubmitting: true, error: null };
    case 'SUBMIT_SUCCESS':
      return { ...state, isSubmitting: false };
    case 'SUBMIT_ERROR':
      return { ...state, isSubmitting: false, error: action.error };
    case 'RESET':
      return initialState;
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

function Form() {
  const [state, dispatch] = useReducer(formReducer, initialState);

  function handleChange(e) {
    dispatch({
      type: 'SET_FIELD',
      field: e.target.name,
      value: e.target.value
    });
  }

  async function handleSubmit(e) {
    e.preventDefault();
    dispatch({ type: 'SUBMIT_START' });
    try {
      await submitForm(state);
      dispatch({ type: 'SUBMIT_SUCCESS' });
    } catch (err) {
      dispatch({ type: 'SUBMIT_ERROR', error: err.message });
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" value={state.name} onChange={handleChange} />
      <input name="email" value={state.email} onChange={handleChange} />
      <button disabled={state.isSubmitting}>
        {state.isSubmitting ? 'Submitting...' : 'Submit'}
      </button>
      {state.error && <p>{state.error}</p>}
    </form>
  );
}

Why this is better

  1. All state transitions in one place. Want to know what happens when submit fails? Look at SUBMIT_ERROR. Done.

  2. Impossible states become impossible. With separate useState calls, you could accidentally have isSubmitting: true AND error: "something". With a reducer, each action defines exactly what the next state looks like.

  3. Easy to test. A reducer is a pure function. Input state + action = output state. No React needed.

test('SUBMIT_ERROR sets error and stops submitting', () => {
  const state = { isSubmitting: true, error: null };
  const action = { type: 'SUBMIT_ERROR', error: 'Network failed' };

  const result = formReducer(state, action);

  expect(result.isSubmitting).toBe(false);
  expect(result.error).toBe('Network failed');
});

The mental model

Think of useReducer as a state machine.

Each action is a transition. Each state is a node. You can visualize your entire app's behavior.

When to use useReducer vs useState

Use useState when...Use useReducer when...
State is a single valueState is an object with multiple fields
Updates are independentUpdates depend on each other
Logic is simpleLogic has many branches
You have 1-3 state variablesYou have 4+ related state variables

useLayoutEffect: when useEffect is too late

Quick quiz: what's wrong with this tooltip?

function Tooltip({ text, targetRect }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const ref = useRef(null);

  useEffect(() => {
    const { height } = ref.current.getBoundingClientRect();
    setPosition({
      x: targetRect.left,
      y: targetRect.top - height
    });
  }, [targetRect]);

  return (
    <div ref={ref} style={{ position: 'absolute', left: position.x, top: position.y }}>
      {text}
    </div>
  );
}

Answer: it flickers.

Here's the timeline:

  1. Component renders at position (0, 0)
  2. Browser paints the tooltip at (0, 0)
  3. useEffect runs, measures height, updates position
  4. Component re-renders at correct position
  5. Browser paints again

The user sees the tooltip jump. That's the flicker.

useLayoutEffect to the rescue

function Tooltip({ text, targetRect }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const ref = useRef(null);

  useLayoutEffect(() => {
    const { height } = ref.current.getBoundingClientRect();
    setPosition({
      x: targetRect.left,
      y: targetRect.top - height
    });
  }, [targetRect]);

  return (
    <div ref={ref} style={{ position: 'absolute', left: position.x, top: position.y }}>
      {text}
    </div>
  );
}

Same code, different hook. No flicker.

The difference

useLayoutEffect runs synchronously after DOM mutations but before the browser paints. The user never sees the intermediate state.

When to use useLayoutEffect

  • Measuring DOM elements (width, height, position)
  • Synchronously updating the DOM based on measurements
  • Preventing visual flicker
  • Animations that need to start immediately

When NOT to use useLayoutEffect

  • Data fetching (use useEffect)
  • Subscriptions (use useEffect)
  • Anything that doesn't need DOM measurements

useLayoutEffect blocks the browser paint. Use it only when you need synchronous DOM work.


useImperativeHandle: expose a custom API

Sometimes, a parent component needs to do something to a child. Focus an input. Scroll to a position. Trigger an animation.

The naive approach:

function Parent() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
    inputRef.current.scrollIntoView();
    inputRef.current.style.backgroundColor = 'yellow'; // Uh oh
  }

  return <CustomInput ref={inputRef} />;
}

The parent has full access to the DOM node. It can do anything. That's the problem.

Expose only what you want

function CustomInput({ ref }) {
  const inputRef = useRef(null);

  useImperativeHandle(ref, () => ({
    focus() {
      inputRef.current.focus();
    },
    scrollIntoView() {
      inputRef.current.scrollIntoView({ behavior: 'smooth' });
    }
    // No access to style, value, or anything else
  }), []);

  return <input ref={inputRef} className="custom-input" />;
}

Now the parent can only call focus() and scrollIntoView(). Nothing else.

function Parent() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus(); // Works
    inputRef.current.scrollIntoView(); // Works
    inputRef.current.style.backgroundColor = 'yellow'; // undefined, does nothing
  }

  return <CustomInput ref={inputRef} />;
}

A real-world example: video player

function VideoPlayer({ ref, src }) {
  const videoRef = useRef(null);

  useImperativeHandle(ref, () => ({
    play() {
      videoRef.current.play();
    },
    pause() {
      videoRef.current.pause();
    },
    seek(time) {
      videoRef.current.currentTime = time;
    },
    getCurrentTime() {
      return videoRef.current.currentTime;
    }
  }), []);

  return (
    <div className="video-container">
      <video ref={videoRef} src={src} />
      {/* Custom controls, overlays, etc. */}
    </div>
  );
}

function App() {
  const playerRef = useRef(null);

  return (
    <>
      <VideoPlayer ref={playerRef} src="/video.mp4" />
      <button onClick={() => playerRef.current.play()}>Play</button>
      <button onClick={() => playerRef.current.pause()}>Pause</button>
      <button onClick={() => playerRef.current.seek(0)}>Restart</button>
    </>
  );
}

The parent controls the video through a clean API. It can't mess with the internal DOM structure.

The mental model

Think of useImperativeHandle as building a remote control for your component.

The parent talks to the remote. The remote talks to the component internals. The parent never touches the internals directly.

When to use useImperativeHandle

  • You're building a reusable component library
  • You want to hide implementation details
  • You need imperative methods but want to control what's exposed
  • You're wrapping a complex DOM structure (video players, rich text editors, etc.)

The cheat sheet

HookSolvesUse when
useReducerComplex state logic4+ related state values, state machine patterns
useLayoutEffectVisual flickerDOM measurements, synchronous updates before paint
useImperativeHandleControlled imperative accessExposing clean APIs, hiding implementation details

Putting it together

Here's a component using all three:

function Modal({ ref, children }) {
  const [state, dispatch] = useReducer(modalReducer, {
    isOpen: false,
    position: { x: 0, y: 0 }
  });
  const contentRef = useRef(null);

  // Expose clean API to parent
  useImperativeHandle(ref, () => ({
    open() { dispatch({ type: 'OPEN' }); },
    close() { dispatch({ type: 'CLOSE' }); }
  }), []);

  // Measure and position before paint
  useLayoutEffect(() => {
    if (state.isOpen && contentRef.current) {
      const { width, height } = contentRef.current.getBoundingClientRect();
      dispatch({
        type: 'SET_POSITION',
        position: {
          x: (window.innerWidth - width) / 2,
          y: (window.innerHeight - height) / 2
        }
      });
    }
  }, [state.isOpen]);

  if (!state.isOpen) return null;

  return (
    <div className="modal-overlay">
      <div
        ref={contentRef}
        className="modal-content"
        style={{ left: state.position.x, top: state.position.y }}
      >
        {children}
      </div>
    </div>
  );
}
  • useReducer manages open/close state and position together
  • useLayoutEffect centers the modal without flicker
  • useImperativeHandle gives the parent a simple open()/close() API

What's next?

You now know hooks that cover 99% of React development.

But React keeps evolving. React 19 brings a new generation of hooks for forms, optimistic updates, and async state. They change how we think about server interactions entirely.

In the next article, we'll explore the React 19 hooks and what they mean for the future of React.

Continue to part 4: The hooks you forgot existed →


Got questions? Found a bug in my code? Hit me up on LinkedIn or check out more on my blog.