Why interviewers love this topic
forwardRef and useImperativeHandle sit at an interesting boundary in React: they are
the framework's deliberate escape hatch from the "data flows down" model into the
imperative world. Senior React interviewers ask about them because the answers reveal
whether a candidate truly understands React's data model, or is just comfortable with
the happy path.
The questions escalate fast. "What is forwardRef?" is the warm-up. The real test is
"When wouldn't you use an imperative handle?" or "Show me how you'd type this in
TypeScript." This guide builds up the full mental model so you can answer from
understanding, not memorisation.
Why function components can't accept refs by default
Every React ref points to something. For a DOM element like <input>, the ref points to
the underlying HTMLInputElement. For a class component, it points to the component
instance. Function components have neither — they are plain functions that return JSX and
leave no persistent object behind.
This is intentional. React's design pushes you toward props for all parent–child communication. Refs to children are the escape hatch, and React makes you opt in explicitly.
React.forwardRef — the opt-in mechanism
forwardRef wraps a render function and injects the parent-supplied ref as a second
argument, alongside props. The result is a component that looks and renders like any
other but correctly threads the ref through to whatever you attach it to internally.
import { forwardRef } from 'react'
const FancyInput = forwardRef(function FancyInput(props, ref) {
// attach the forwarded ref to the actual DOM input
return <input ref={ref} className="fancy" {...props} />
})
function Form() {
const inputRef = useRef(null)
return (
<>
<FancyInput ref={inputRef} placeholder="Name" />
<button onClick={() => inputRef.current.focus()}>Focus</button>
</>
)
}
Without forwardRef, the ref attribute on <FancyInput> would silently be dropped and
inputRef.current would remain null.
Always use a named function expression inside forwardRef. React DevTools uses the
function name as the display name — anonymous wrappers show up as ForwardRef, which
makes debugging miserable.
useImperativeHandle — curating the public API
Forwarding the raw DOM ref works fine for thin wrappers, but it gives the parent
unrestricted access to the node. Any method that exists on HTMLInputElement is now
accessible — including ones you never intended to expose.
useImperativeHandle(ref, createHandle, deps?) solves this by replacing what the
parent sees at ref.current with a custom object you define:
const VideoPlayer = forwardRef(function VideoPlayer({ src }, ref) {
const videoRef = useRef(null) // private — never reaches the parent
useImperativeHandle(ref, () => ({
play: () => videoRef.current.play(),
pause: () => videoRef.current.pause(),
seek: (t) => { videoRef.current.currentTime = t },
}), []) // empty deps — handle is stable
return <video ref={videoRef} src={src} />
})
The parent can now call player.current.play() but cannot touch videoRef.current
directly. The <video> element can be swapped for any other implementation without
breaking the parent.
The full pattern combined
The canonical recipe has four parts: keep an internal DOM ref private, wire it up to the
DOM element, expose only selected methods via useImperativeHandle, and wrap the whole
thing in forwardRef.
const SmartInput = forwardRef(function SmartInput(props, ref) {
const inputRef = useRef(null) // 1. private ref
useImperativeHandle(ref, () => ({ // 2. curated handle
focus: () => inputRef.current.focus(),
blur: () => inputRef.current.blur(),
clear: () => { inputRef.current.value = '' },
}), [])
return <input ref={inputRef} {...props} /> // 3. wire private ref
})
This is the answer interviewers are hoping to hear when they ask "show me forwardRef and
useImperativeHandle working together."
When to use an imperative handle — and when not to
The golden rule: use an imperative handle for actions, not for data.
| Appropriate (action) | Avoid (data) |
|---|---|
input.focus() | list.setItems(data) — use a prop |
player.play() | modal.setTitle(t) — use a prop |
dialog.open() | form.getValues() — use a callback/state |
list.scrollTo(3) | chart.updateColor(c) — use a prop |
If the parent needs to change what renders, use a prop. If it needs to trigger an event, an imperative handle is appropriate.
TypeScript typing
The type parameters for forwardRef are <HandleType, PropsType> — handle first, props
second. Always export the handle interface so consumers can type their useRef calls.
export interface ModalHandle {
open(): void
close(): void
}
interface ModalProps {
title: string
children: React.ReactNode
}
const Modal = forwardRef<ModalHandle, ModalProps>(function Modal(
{ title, children },
ref
) {
const [visible, setVisible] = React.useState(false)
useImperativeHandle(ref, () => ({
open: () => setVisible(true),
close: () => setVisible(false),
}))
if (!visible) return null
return <dialog open><h2>{title}</h2>{children}</dialog>
})
// Consumer
const modalRef = React.useRef<ModalHandle>(null)
modalRef.current?.open() // fully typed
Inside the render function, the parameter type for the forwarded ref is
ForwardedRef<T> (a union of callback ref, object ref, and null), not Ref<T>.
Composing with React.memo
Both memo and forwardRef are higher-order wrappers. Apply forwardRef first
(innermost), then memo:
const MemoInput = memo(forwardRef(function MemoInput(props, ref) {
return <input ref={ref} {...props} />
}))
If you accidentally apply memo first and then forwardRef, it still works in practice
because React resolves the wrapper chain, but the conventional order makes the intent
clear.
The deps array matters
useImperativeHandle's third argument works exactly like useEffect's dependency array.
[]— create the handle once. Safe when the methods only call setters or refs (which are stable across renders).[count]— recreate the handle whencountchanges. Necessary when a method closes over a state value and you need it to be current.- omitted — recreate on every render. Wasteful but correct.
useImperativeHandle(ref, () => ({
getCount: () => count, // closes over count — needs count in deps
}), [count])
React 19 changes
React 19 promotes ref to a first-class prop. Function components can receive it
directly without forwardRef:
// React 19 — no forwardRef needed
function FancyInput({ ref, ...props }: React.ComponentProps<'input'>) {
return <input ref={ref} className="fancy" {...props} />
}
forwardRef still works in React 19 (it's not removed, just legacy). useImperativeHandle
is unchanged and continues to work with the ref prop directly. For codebases targeting
React 18 and earlier, forwardRef remains mandatory.
Testing imperative handles
React Testing Library works well here. Create a ref, render the component with it, then call the handle method and assert the observable result:
import { createRef } from 'react'
import { render, screen } from '@testing-library/react'
import SmartInput from './SmartInput'
test('clear() empties the input', () => {
const ref = createRef()
render(<SmartInput ref={ref} defaultValue="hello" data-testid="inp" />)
ref.current.clear()
expect(screen.getByTestId('inp').value).toBe('')
})
Test what the handle does (observable DOM behaviour), not how it is implemented.
Key Takeaways
- Function components have no instance, so they can't accept a
refwithout explicitly opting in viaforwardRef(React ≤ 18) or arefprop (React 19+). forwardRef(fn)passes the parent's ref as the second argument tofn, letting you attach it to any DOM node or child component inside.useImperativeHandle(ref, createHandle, deps)replacesref.currentwith a custom object — use it to expose a curated, stable API instead of the raw DOM node.- Prefer imperative handles for actions (focus, play, scroll, open); prefer props for data changes.
- TypeScript:
forwardRef<HandleType, PropsType>— handle type first, props second. Export the handle interface so consumers can type their refs. - Compose with
memoasmemo(forwardRef(...)). - React 19 makes
refa regular prop, deprecatingforwardReffor new code. - Test handles by asserting observable DOM or state changes, not internal implementation.