React wants data to flow down as props and events to flow up as callbacks. Most of the time that’s enough. But a few things a parent needs to do are commands, not state: “focus the search box now”, “scroll the chat log to the newest message”, “start the video”. You can’t express “focus” as a prop without awkward tricks like a shouldFocus boolean that has to be reset afterwards.
Refs are React’s escape hatch for commands. Passing a ref to a DOM element gives the parent the DOM node. useImperativeHandle lets a component hand its parent something smaller and safer: an object with exactly the methods you choose. Interviewers ask about it to see whether you know the escape hatch exists and, more importantly, whether you know how rarely to use it.
Refs as props in React 19
Before React 19, a function component couldn’t receive a ref prop at all; React stripped it. You had to wrap the component in forwardRef((props, ref) => ...). React 19 changed that: function components receive ref like any other prop, and forwardRef is no longer needed for new code (it still works, and you’ll see it in libraries that support React 18).
// React 19
function SearchField({ ref, ...props }) { /* ... */ }
// React 18 and earlier
const SearchField = forwardRef(function SearchField(props, ref) { /* ... */ });Everything below works the same in either form.
A worked example: a search field with focus and clear
A docs site has a search field in its header. Pressing / anywhere should focus it, and a “Clear” button elsewhere on the page should empty it and put the cursor back. The search field owns its own query state.
The tempting shortcut is to pass a ref straight through to the <input>. That works for focus, but it gives the parent the whole DOM node. The parent could then set input.value = '' directly, which changes what the user sees without updating the component’s query state, so the next keystroke brings the old text back. Exposing a handle avoids that:
import { useImperativeHandle, useRef, useState } from 'react';
function SearchField({ ref, onSearch }) {
const inputRef = useRef(null);
const [query, setQuery] = useState('');
useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus(),
clear: () => {
setQuery('');
inputRef.current.focus();
},
}), []);
return (
<input
ref={inputRef}
aria-label="Search the docs"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onSearch(query)}
/>
);
}
function Header() {
const searchRef = useRef(null);
useEffect(() => {
const onKey = (e) => {
if (e.key === '/' && !e.target.closest('input, textarea, [contenteditable]')) {
e.preventDefault();
searchRef.current?.focus();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, []);
return (
<header>
<SearchField ref={searchRef} onSearch={runSearch} />
<button onClick={() => searchRef.current?.clear()}>Clear</button>
</header>
);
}Running a trimmed version of this with React 19 in jsdom confirmed each piece of behaviour:
handle keys: [ 'focus', 'clear' ] | has style? false | is element? false
typed: closures | focused: false
after clear: "" | focused: true
after unmount, ref.current = nullThe parent’s ref.current is a plain object with two methods. It isn’t an element, so searchRef.current.style or .value simply don’t exist, and the parent can’t bypass the component’s state. Calling clear() emptied the input through state and moved focus. And once SearchField unmounted, React set the ref back to null.
The decisions in that code
The handle wraps an internal ref. inputRef stays private to SearchField. The handle’s methods use it, so the component can change its markup (wrap the input, swap it for a combobox) without breaking any parent.
clear goes through state. That keeps the component the single owner of query. Any method on a handle that changes what’s displayed should do it through the component’s own state.
The dependency array is empty. The factory runs again whenever dependencies change, giving the parent a new object. Both methods only read refs and call a stable setter, so they never need recreating. If a method used a prop or state value directly, that value would belong in the array, or the method would close over a stale copy.
The parent uses searchRef.current?.. The handle doesn’t exist during the parent’s first render, and it’s null whenever the child isn’t mounted, for example when it is conditionally rendered or still waiting on a lazy chunk. Optional chaining turns a crash into a no-op.
What belongs on a handle
A handle should contain verbs that map to imperative browser APIs or animations: focus, blur, select, scrollIntoView, scrollToBottom, play, pause, reset for an uncontrolled widget, measure for a layout calculation. These are actions with no natural “state” representation.
It should not contain state or setters the parent could own. ref.current.setOpen(true) on a dialog, or ref.current.getValue() on an input, are signs the component should be controlled instead: take open or value as a prop and report changes with onChange. Props make the data flow visible in the parent’s JSX; a method call hidden in an effect is much harder to follow and to test.
A useful test: could the parent describe what it wants as “the component should look like X”? Then it’s a prop. Is it “do Y right now, once”? Then it might be a handle.
Mistakes that come up in reviews
Exposing the DOM node inside the handle. useImperativeHandle(ref, () => inputRef.current) gives away exactly what the hook was meant to protect, and on the first render inputRef.current may still be null.
Calling handle methods during render. Refs are filled in during the commit phase, after rendering. Call them from event handlers or effects.
Forgetting the hook on React 18. Without forwardRef, a function component on React 18 never receives ref; React warns that function components cannot be given refs, and ref.current stays null.
Using handles to sync siblings. If component A calls bRef.current.refresh() whenever something changes, the real fix is usually lifting the shared state to a common parent.
Testing components with handles
Test through the handle the way a parent would use it: render a small parent that holds the ref, call ref.current.clear() inside act, and assert on what the user sees (the input’s value, document.activeElement). Avoid asserting on internal refs. If testing a method requires reaching into internals, the method probably does too much.
Interview exercise: the video player API
“Design the public API for a VideoPlayer component used across a learning platform. Course pages need to start playback when the user clicks a chapter in a sidebar, show the current time, and pause when a quiz opens.”
Answer. I’d split it by what’s command and what’s state. Current time is state that flows up, so VideoPlayer reports it through an onTimeUpdate(seconds) callback, and the page stores it if it needs it. Pausing for a quiz can be declarative: a paused prop the page sets to true while the quiz is open, which the player applies to the <video> element in an effect. Jumping to a chapter is a one-off command, so I’d expose a small handle with useImperativeHandle: seekTo(seconds) and play(), implemented on a private ref to the <video> element. I wouldn’t expose the element itself, so pages can’t change src or attach listeners behind the component’s back. On React 19 ref is a regular prop; if the platform still supports React 18, I’d wrap the component in forwardRef. Callers would use playerRef.current?.seekTo(...) because the player may still be loading. In tests I’d render a host component, call seekTo through the ref in act, and assert on the element’s currentTime.