Skip to content

forwardRef & useImperativeHandle Interview Questions & Answers

20 questions Updated 2026-06-24 Share:

React forwardRef and useImperativeHandle interview questions — ref forwarding, imperative API design, focus control, animation handles, and TypeScript ref typing.

Read the in-depth guideReact forwardRef & useImperativeHandle — Complete Interview Guide(opens in new tab)
20 of 20

Function components are plain functions — they have no backing instance. When React renders a class component it creates an object you can point a ref at. Function components produce nothing but JSX, so there is no object to attach the ref to by default.

function Input({ value, onChange }) {
  return <input value={value} onChange={onChange} />
}

function Parent() {
  const ref = useRef(null)
  // ❌ ref will be null; React logs a warning
  return <Input ref={ref} value="" onChange={() => {}} />
}

To let a parent reach inside, you must either wrap with React.forwardRef (React ≤ 18) or accept ref as an explicit prop (React 19+).

Rule of thumb: "No instance" means "no implicit ref" — you must opt in explicitly.

React.forwardRef wraps a render function and injects the parent-supplied ref as a second argument alongside props. It returns a component that React treats as a normal component but also honours the ref.

import { forwardRef } from 'react'

// forwardRef(renderFn) — renderFn receives (props, ref)
const FancyInput = forwardRef(function FancyInput(props, ref) {
  return (
    <input
      ref={ref}          // wire the forwarded ref to the DOM node
      className="fancy"
      {...props}
    />
  )
})

// Parent
function Form() {
  const inputRef = useRef(null)
  return (
    <>
      <FancyInput ref={inputRef} placeholder="Type here" />
      <button onClick={() => inputRef.current.focus()}>Focus</button>
    </>
  )
}

Without forwardRef, ref would not appear in the render function's argument list at all — it is filtered out of props by React's reconciler.

Rule of thumb: forwardRef = "please pass the ref through to me as arg #2."

useImperativeHandle(ref, createHandle, deps?) replaces what the parent sees when it reads ref.current. Instead of the raw DOM node you expose a custom object with only the methods you choose to surface. This is the controlled-API boundary between child and parent.

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

  useImperativeHandle(ref, () => ({
    // expose a curated imperative API — not the raw <video> element
    play()  { videoRef.current.play() },
    pause() { videoRef.current.pause() },
    seek(t) { videoRef.current.currentTime = t },
  }))

  return <video ref={videoRef} src={src} />
})

function Page() {
  const player = useRef(null)
  return (
    <>
      <VideoPlayer ref={player} src="/movie.mp4" />
      <button onClick={() => player.current.play()}>Play</button>
    </>
  )
}

The parent can call play, pause, and seek but cannot touch the raw <video> node — accidental DOM manipulation is prevented.

Rule of thumb: useImperativeHandle = "I'll expose only what I designed as my public API."

The standard pattern: keep the real DOM ref private inside the component; expose only the three operations via useImperativeHandle.

import { forwardRef, useImperativeHandle, useRef } from 'react'

const SmartInput = forwardRef(function SmartInput(props, ref) {
  const inputRef = useRef(null)          // private — not leaked to parent

  useImperativeHandle(ref, () => ({
    focus() { inputRef.current.focus() },
    blur()  { inputRef.current.blur()  },
    clear() { inputRef.current.value = '' },
  }), []) // no deps — handle never changes

  return <input ref={inputRef} {...props} />
})

// Usage
function LoginForm() {
  const usernameRef = useRef(null)
  return (
    <>
      <SmartInput ref={usernameRef} placeholder="Username" />
      <button onClick={() => usernameRef.current.clear()}>Reset</button>
    </>
  )
}

Note: the deps array of useImperativeHandle works exactly like useEffect — an empty array means the handle object is created once and never recreated.

Rule of thumb: always keep the raw DOM ref private and return only the surface area the parent actually needs.

Imperative handles suit inherently imperative actions that have no sensible declarative representation: focus management, scroll commands, starting/stopping media, triggering animations, opening/closing portals when the open state is owned by the child.

// ✅ appropriate — "start playing" is an event, not a state the parent owns
player.current.play()

// ✅ appropriate — programmatic focus after async validation
inputRef.current.focus()

// ❌ avoid — visibility is declarative; use a prop instead
modalRef.current.setTitle('New title')   // should be <Modal title={...} />

// ❌ avoid — data flow should go through props, not imperative setters
listRef.current.setItems(data)           // should be <List items={data} />

React's model is "data flows down via props." Imperative handles are the escape hatch for side-effectful actions that can't be expressed as a render-cycle change.

Rule of thumb: if the parent needs to trigger an action, use a handle; if it needs to change what renders, use a prop.

Forwarding the raw DOM ref gives the parent full access — convenient, but it couples the parent to implementation details and allows accidental mutations.

// raw DOM ref — parent can do anything, including things you don't want
const RawInput = forwardRef((props, ref) => <input ref={ref} {...props} />)

// custom handle — parent can only call what you expose
const SafeInput = forwardRef((props, ref) => {
  const inner = useRef(null)
  useImperativeHandle(ref, () => ({
    focus: () => inner.current.focus(),
    // getSelectionRange, scrollIntoView, etc. — only what you choose
  }))
  return <input ref={inner} {...props} />
})

Reasons to prefer a custom handle:

  • The child may replace its DOM implementation (e.g. swap <input> for a contenteditable div) without breaking the parent.
  • Prevents parents from reading value directly, bypassing React's state.
  • Documents the intended imperative API explicitly.

Rule of thumb: forward the raw ref for thin wrappers; use a custom handle for components with a stable public contract.

forwardRef takes two type parameters: the element / handle type and the props type. The order is forwardRef<HandleType, PropsType>.

import { forwardRef, useRef, type ForwardedRef } from 'react'

interface InputProps {
  label: string
  defaultValue?: string
}

// forwardRef<handle-type, props-type>
const LabelledInput = forwardRef<HTMLInputElement, InputProps>(
  function LabelledInput({ label, defaultValue }, ref) {
    return (
      <label>
        {label}
        <input ref={ref} defaultValue={defaultValue} />
      </label>
    )
  }
)

// Consumer
function Form() {
  const ref = useRef<HTMLInputElement>(null)
  ref.current?.focus()   // TS knows it's HTMLInputElement | null
  return <LabelledInput ref={ref} label="Name" />
}

If you use useImperativeHandle, replace HTMLInputElement with your handle interface (e.g. { focus(): void; clear(): void }).

Rule of thumb: forwardRef<HandleType, PropsType> — handle first, props second.

Define an interface for the handle, use it as the first generic to forwardRef, and useImperativeHandle will enforce that createHandle returns a conforming object.

import { forwardRef, useImperativeHandle, useRef } from 'react'

// 1. Define the public imperative API
export interface ModalHandle {
  open(): void
  close(): void
  isOpen(): boolean
}

interface ModalProps {
  title: string
  children: React.ReactNode
}

// 2. Use ModalHandle as the first generic
const Modal = forwardRef<ModalHandle, ModalProps>(function Modal(
  { title, children },
  ref
) {
  const [visible, setVisible] = React.useState(false)

  useImperativeHandle(ref, () => ({
    open:   () => setVisible(true),
    close:  () => setVisible(false),
    isOpen: () => visible,
  }))

  if (!visible) return null
  return <dialog open><h2>{title}</h2>{children}</dialog>
})

// 3. Parent types the ref with the handle interface
function App() {
  const modalRef = React.useRef<ModalHandle>(null)
  return (
    <>
      <Modal ref={modalRef} title="Alert">Are you sure?</Modal>
      <button onClick={() => modalRef.current?.open()}>Show</button>
    </>
  )
}

Rule of thumb: export the handle interface alongside the component so consumers can type their refs without importing internals.

In React 19, ref is a regular prop. Function components can receive ref directly in their props object — no forwardRef wrapper required. forwardRef still works for backwards compatibility but is considered legacy and will eventually be deprecated.

// React 19 — ref is just another prop
function FancyInput({ ref, ...props }: React.ComponentProps<'input'> & {
  ref?: React.Ref<HTMLInputElement>
}) {
  return <input ref={ref} className="fancy" {...props} />
}

// React ≤ 18 — must use forwardRef
const FancyInputLegacy = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
  function FancyInputLegacy(props, ref) {
    return <input ref={ref} className="fancy" {...props} />
  }
)

useImperativeHandle continues to work in React 19 — you still call it inside the component with the ref prop directly.

Rule of thumb: for new React 19 codebases, skip forwardRef and accept ref as a prop; keep forwardRef when targeting older React versions.

Both forwardRef and memo are higher-order wrappers that return a new component. You must apply forwardRef first (innermost), then wrap with memo so that memoisation sees the already-forwarded component.

import { forwardRef, memo, useRef } from 'react'

// Step 1 — enable ref forwarding
const BaseInput = forwardRef(function BaseInput(props, ref) {
  return <input ref={ref} {...props} />
})

// Step 2 — memoize the forwarded component
const MemoInput = memo(BaseInput)

// Usage — both ref forwarding and memoisation work correctly
function Parent() {
  const ref = useRef(null)
  return <MemoInput ref={ref} value="" onChange={() => {}} />
}

If you apply memo first and then forwardRef, the outer wrapper still works in practice (React resolves the chain), but the conventional and explicit order is memo(forwardRef(...)).

Rule of thumb: memo(forwardRef(component)) — ref first, memo second.

This pattern is appropriate when the open/closed state lives inside the modal itself (e.g. it manages its own transition) and the parent only needs to command it.

const ConfirmModal = forwardRef(function ConfirmModal({ onConfirm }, ref) {
  const [open, setOpen] = React.useState(false)
  const dialogRef = useRef(null)

  useImperativeHandle(ref, () => ({
    open:  () => { setOpen(true);  dialogRef.current?.showModal() },
    close: () => { setOpen(false); dialogRef.current?.close()     },
  }))

  if (!open) return null
  return (
    <dialog ref={dialogRef}>
      <p>Are you sure?</p>
      <button onClick={() => { onConfirm(); setOpen(false) }}>Yes</button>
      <button onClick={() => setOpen(false)}>No</button>
    </dialog>
  )
})

function DeleteButton({ id }) {
  const modal = useRef(null)
  return (
    <>
      <button onClick={() => modal.current.open()}>Delete</button>
      <ConfirmModal ref={modal} onConfirm={() => deleteItem(id)} />
    </>
  )
}

Rule of thumb: use this only when open/close state is truly private to the modal; if the parent must know whether the modal is open, lift state or use a prop instead.

Focus management is the canonical use case — moving focus programmatically after validation errors or submit actions.

const Field = forwardRef(function Field({ label, error, ...inputProps }, ref) {
  return (
    <div className={error ? 'field field--error' : 'field'}>
      <label>{label}</label>
      <input ref={ref} {...inputProps} />
      {error && <span className="error">{error}</span>}
    </div>
  )
})

function SignupForm() {
  const emailRef    = useRef(null)
  const passwordRef = useRef(null)

  function handleSubmit(e) {
    e.preventDefault()
    const errors = validate(e.target)
    if (errors.email)    { emailRef.current.focus();    return }
    if (errors.password) { passwordRef.current.focus(); return }
    submitForm(e.target)
  }

  return (
    <form onSubmit={handleSubmit}>
      <Field ref={emailRef}    label="Email"    type="email"    />
      <Field ref={passwordRef} label="Password" type="password" />
      <button type="submit">Sign up</button>
    </form>
  )
}

Rule of thumb: reach for forwardRef when a parent orchestrates focus across several child inputs after async feedback.

Animation libraries (GSAP, Framer Motion) maintain internal timelines. Exposing play, pause, and reset via a handle keeps the animation logic encapsulated while giving parent components a clean trigger surface.

import gsap from 'gsap'

const AnimatedCard = forwardRef(function AnimatedCard({ children }, ref) {
  const cardRef = useRef(null)
  const tlRef   = useRef(null)         // private timeline

  useEffect(() => {
    tlRef.current = gsap.timeline({ paused: true })
      .fromTo(cardRef.current,
        { opacity: 0, y: 40 },
        { opacity: 1, y: 0, duration: 0.4 }
      )
  }, [])

  useImperativeHandle(ref, () => ({
    play:  () => tlRef.current.play(),
    pause: () => tlRef.current.pause(),
    reset: () => tlRef.current.restart().pause(),
  }))

  return <div ref={cardRef}>{children}</div>
})

function Demo() {
  const card = useRef(null)
  return (
    <>
      <AnimatedCard ref={card}>Hello</AnimatedCard>
      <button onClick={() => card.current.play()}>Animate in</button>
    </>
  )
}

Rule of thumb: expose semantic action names (play, pause) not raw library handles — the parent shouldn't need to know which animation library you use.

React.ForwardedRef<T> is the exact type of the second argument received by a forwardRef render function. It is a union: RefCallback<T> | MutableRefObject<T> | null. React.Ref<T> is a wider union that also includes string refs (legacy) and is used for passing refs to elements.

import { forwardRef, type ForwardedRef } from 'react'

// ForwardedRef<T> is correct for the parameter you *receive*
function BaseInput(
  props: React.ComponentProps<'input'>,
  ref: ForwardedRef<HTMLInputElement>  // not Ref<> — this is what forwardRef gives you
) {
  return <input ref={ref} {...props} />
}
export default forwardRef(BaseInput)

// If you want to accept a ref in a helper that isn't a forwardRef component:
function mergeRefs<T>(...refs: ForwardedRef<T>[]): React.RefCallback<T> {
  return (node) => {
    refs.forEach(ref => {
      if (typeof ref === 'function') ref(node)
      else if (ref) ref.current = node
    })
  }
}

Rule of thumb: use ForwardedRef<T> for the ref parameter inside forwardRef; use Ref<T> or RefObject<T> in props interfaces for refs the component accepts as props.

Use React.createRef() (or useRef inside a wrapper component) to capture the handle, then assert on its methods.

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createRef } from 'react'
import SmartInput from './SmartInput'

test('clear() empties the input', async () => {
  const ref = createRef()
  render(<SmartInput ref={ref} defaultValue="hello" data-testid="inp" />)

  // verify initial state
  expect(screen.getByTestId('inp').value).toBe('hello')

  // call the imperative method
  ref.current.clear()

  // assert DOM changed
  expect(screen.getByTestId('inp').value).toBe('')
})

test('focus() moves focus to the input', () => {
  const ref = createRef()
  render(<SmartInput ref={ref} data-testid="inp" />)
  ref.current.focus()
  expect(screen.getByTestId('inp')).toHaveFocus()
})

You don't need to test the internal DOM structure — only that the exposed handle methods produce the expected observable behaviour.

Rule of thumb: test the contract of the handle (what it does), not how it is implemented.

useImperativeHandle(ref, createHandle, deps) re-runs createHandle whenever a dependency changes, replacing ref.current with a new object. With an empty array [], the handle is created once. With no deps array at all, it reruns on every render.

const Counter = forwardRef(function Counter(props, ref) {
  const [count, setCount] = useState(0)

  useImperativeHandle(ref, () => ({
    increment: () => setCount(c => c + 1),
    // getCount captures the current count via closure —
    // must be in deps so the handle refreshes when count changes
    getCount:  () => count,
  }), [count]) // ← re-create handle when count changes

  return <p>{count}</p>
})

If getCount were in a stale closure (deps omitted or []) it would always return the initial value. Omitting deps entirely causes a new handle object every render, which is wasteful but not incorrect.

Rule of thumb: include any state or prop that the handle methods read via closure; use [] only when the methods rely solely on refs or setters (which are stable).

A DOM node can only have one ref prop. When a component needs its own local ref and must forward the parent's ref to the same node, you merge them with a callback ref.

function mergeRefs(...refs) {
  return (node) => {
    refs.forEach(ref => {
      if (typeof ref === 'function') ref(node)      // callback ref
      else if (ref != null) ref.current = node      // object ref
    })
  }
}

const ScrollableList = forwardRef(function ScrollableList(props, forwardedRef) {
  const localRef = useRef(null)  // needed for internal scroll logic

  function scrollToTop() {
    localRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
  }

  return (
    <ul
      ref={mergeRefs(localRef, forwardedRef)} // both refs point at <ul>
      {...props}
    />
  )
})

Libraries like useMergedRef (react-merge-refs) do exactly this. React 19's cleanup-returning ref callbacks make the pattern cleaner still.

Rule of thumb: when you need two refs on one node, always merge — never pick one and lose the other.

Five common mistakes:

  1. Exposing the raw DOM node when a custom handle is safer — gives the parent unrestricted access.
  2. Using an imperative handle to pass dataref.current.getUser() should be const { user } = useUser() or a prop.
  3. Forgetting to memoize the handle when it closes over frequently-changing state without listing it in deps.
  4. Wrapping every component in forwardRef "just in case" — most components are controlled via props and don't need a ref at all.
  5. Not cleaning up — if the handle exposes subscriptions or timers, they should be cleared in a useEffect cleanup, not in the handle method itself.
// ❌ anti-pattern — data fetch through an imperative handle
const DataList = forwardRef((_, ref) => {
  useImperativeHandle(ref, () => ({
    getData: async () => fetchData(),  // parent should just receive data as a prop
  }))
  return <ul />
})

// ✅ prefer — data flows through props / context
function DataList({ data }) {
  return <ul>{data.map(item => <li key={item.id}>{item.name}</li>)}</ul>
}

Rule of thumb: before adding an imperative handle, ask "can I achieve this with a prop, callback, or state?" — if yes, do that instead.

React DevTools shows the display name in the component tree. Anonymous forwardRef calls appear as ForwardRef — unhelpful for debugging.

// Option 1 — named function expression (preferred)
const TextInput = forwardRef(function TextInput(props, ref) {
  return <input ref={ref} {...props} />
})
// DevTools shows: TextInput

// Option 2 — set displayName explicitly
const TextInput2 = forwardRef((props, ref) => (
  <input ref={ref} {...props} />
))
TextInput2.displayName = 'TextInput'

// Option 3 — in a factory helper, set displayName dynamically
function createForwardedInput(displayName) {
  const C = forwardRef((props, ref) => <input ref={ref} {...props} />)
  C.displayName = displayName
  return C
}

Named function expressions (Option 1) are the cleanest because the name is inferred automatically without an extra line.

Rule of thumb: always use a named function inside forwardRef — you get a readable DevTools tree for free.

A list component that manages its own DOM can expose a scrollTo(index) method so the parent doesn't have to reach into its internals.

const VirtualList = forwardRef(function VirtualList({ items }, ref) {
  const rowRefs = useRef([])   // array of row DOM nodes

  useImperativeHandle(ref, () => ({
    // scrollTo(index) — brings row at `index` into the viewport
    scrollTo(index) {
      rowRefs.current[index]?.scrollIntoView({
        behavior: 'smooth',
        block: 'nearest',
      })
    },
  }), []) // stable — no deps needed; rowRefs.current is mutable

  return (
    <ul>
      {items.map((item, i) => (
        <li key={item.id} ref={el => { rowRefs.current[i] = el }}>
          {item.label}
        </li>
      ))}
    </ul>
  )
})

function SearchResults({ results, activeIndex }) {
  const listRef = useRef(null)
  useEffect(() => {
    listRef.current?.scrollTo(activeIndex)
  }, [activeIndex])

  return <VirtualList ref={listRef} items={results} />
}

Rule of thumb: when the child owns a collection of DOM nodes, expose a semantic scrollTo(index) rather than exposing the raw refs array.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel