Lesson 46 of 80
React wants you to describe the end state and let it figure out the DOM changes — the imperative escape hatches exist, but reaching for them should be rare, not habitual.
12 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
Declarative code says what you want: "this button is disabled when loading is true." Imperative code says how to get there, step by step: "find the button, call .disabled = true on it, then remember to flip it back later."
React's entire model is declarative. You return a description of the UI for the current state — <button disabled={loading}>Save</button> — and React diffs that against what's already on screen and makes the minimal DOM changes itself. You never call element.setAttribute yourself. That's the whole trade: you give up manual control over how the DOM updates, and in exchange you stop having to track when to update it, which is where most manual DOM bugs live — a flag getting out of sync with what's actually rendered.
Most UI is genuinely declarative. But some things — a video's play()/pause(), focusing an input, scrolling to a node, reading a DOM measurement — aren't values you can express as JSX. They're commands you issue to a specific DOM node. That's what ref and useImperativeHandle are for:
const VideoPlayer = forwardRef(function VideoPlayer(props, ref) {
const videoEl = useRef(null);
useImperativeHandle(ref, () => ({
play: () => videoEl.current.play(),
pause: () => videoEl.current.pause(),
}));
return <video ref={videoEl} src={props.src} />;
});
function App() {
const playerRef = useRef(null);
return (
<>
<VideoPlayer ref={playerRef} src="/clip.mp4" />
<button onClick={() => playerRef.current.play()}>Play</button>
</>
);
});
Notice what useImperativeHandle does: it exposes a small, deliberate imperative surface (play, pause) instead of handing out the raw DOM node. The parent calls a command, not a description — there's no isPlaying prop that React re-renders around, because "play this video right now" genuinely is an action, not a state.
useRef alone gets you a node to read from or call a native method on directly. useImperativeHandle is for when a parent needs to command a child component without the child handing over its internals wholesale.playerRef.current.play() doesn't trigger a re-render and isn't visible to React's reconciliation — which is exactly right for "do a thing" (play), but wrong if you actually needed the UI to reflect a changed value (like a play/pause icon), which still needs state.This is the same "props down, one exposed handle up" shape used by every ref-forwarding pattern in this track — the parent still doesn't reach into the child's internals, it calls a name the child chose to expose. The habit of reaching for direct DOM manipulation instead of describing state is also the same instinct this platform's rerender-triggers lesson pushes back on: React re-renders in response to state and prop changes, not because you told a node to update.