
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
-
All state transitions in one place. Want to know what happens when submit fails? Look at
SUBMIT_ERROR. Done. -
Impossible states become impossible. With separate useState calls, you could accidentally have
isSubmitting: trueANDerror: "something". With a reducer, each action defines exactly what the next state looks like. -
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 value | State is an object with multiple fields |
| Updates are independent | Updates depend on each other |
| Logic is simple | Logic has many branches |
| You have 1-3 state variables | You 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:
- Component renders at position (0, 0)
- Browser paints the tooltip at (0, 0)
- useEffect runs, measures height, updates position
- Component re-renders at correct position
- 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
| Hook | Solves | Use when |
|---|---|---|
useReducer | Complex state logic | 4+ related state values, state machine patterns |
useLayoutEffect | Visual flicker | DOM measurements, synchronous updates before paint |
useImperativeHandle | Controlled imperative access | Exposing 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.