Learn

/

Ref Forwarding

Ref Forwarding

5 patterns

Forwarding refs to the right element, typing them correctly, and keeping imperative handles minimal.

Wrapper component swallows ref
easy
Avoid
interface ButtonProps {
  children: React.ReactNode;
  variant?: 'primary' | 'secondary';
  size?: 'sm' | 'md' | 'lg';
  isLoading?: boolean;
  onClick?: () => void;
}

Prefer
interface ButtonProps
  extends React.ComponentProps<'button'> {
  variant?: 'primary' | 'secondary';
  size?: 'sm' | 'md' | 'lg';
  isLoading?: boolean;
}

Extending ComponentProps<'button'> gives you ref, onClick, children, disabled, ARIA props, and every other native button attribute for free. A manually defined interface with no ref support means consumers can't focus the button programmatically or measure its position.

In React 19, ref is a regular prop — extending native props is the simplest way to support it.

React Docs: forwardRef
Ref typed as any
easy
Avoid
interface TextFieldProps {
  value: string;
  onChange: (value: string) => void;
  placeholder?: string;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  ref?: any;
}

Prefer
interface TextFieldProps {
  value: string;
  onChange: (value: string) => void;
  placeholder?: string;
  ref?: React.Ref<HTMLInputElement>;
}

React.Ref<HTMLInputElement> accepts callback refs, ref objects, and null — all valid ref forms. It also tells TypeScript the exact element type, so ref.current.focus() and .select() autocomplete correctly.

any bypasses all of that — consumers can pass a string, a number, or anything else without a type error.

React Docs: Manipulating the DOM with a Ref
Imperative handle vs raw DOM ref
medium
Avoid
interface VideoPlayerProps {
  /** Video source URL. */
  src: string;
  /** Poster image shown before playback. */
  poster?: string;
  /** Whether to show playback controls. */
  hasControls?: boolean;
  /**
   * Ref to the root container element.
   * Use for play/pause/seek operations.
   */
  ref?: React.Ref<HTMLDivElement>;
}

Prefer
interface VideoPlayerHandle {
  /** Start playback. */
  play: () => void;
  /** Pause playback. */
  pause: () => void;
  /** Seek to a specific time in seconds. */
  seek: (time: number) => void;
}

interface VideoPlayerProps {
  /** Video source URL. */
  src: string;
  /** Poster image shown before playback. */
  poster?: string;
  /** Whether to show playback controls. */
  hasControls?: boolean;
  /** Imperative handle for playback control. */
  ref?: React.Ref<VideoPlayerHandle>;
}

An imperative handle exposes only the operations consumers need: play(), pause(), seek(). Exposing the raw <div> leaks every DOM method and property — consumers could accidentally call .remove() or read .innerHTML.

useImperativeHandle creates this focused API. The handle type also serves as documentation for what the component supports.

React Docs: useImperativeHandle
Ref targeting the wrong element
medium
Avoid
interface SearchFieldProps {
  /** Current search query. */
  value: string;
  /** Called when the query changes. */
  onValueChange: (value: string) => void;
  /** Placeholder text. */
  placeholder?: string;
  /**
   * Ref to the outer wrapper element.
   * Used for layout measurements.
   */
  ref?: React.Ref<HTMLDivElement>;
}

Prefer
interface SearchFieldProps {
  /** Current search query. */
  value: string;
  /** Called when the query changes. */
  onValueChange: (value: string) => void;
  /** Placeholder text. */
  placeholder?: string;
  /**
   * Ref to the inner input element.
   * Supports .focus() and .select().
   */
  ref?: React.Ref<HTMLInputElement>;
}

Consumers ref a SearchField to call .focus() or .select() — those are HTMLInputElement methods. A ref to the outer <div> wrapper can't do either. The ref should point to the element consumers actually interact with programmatically.

If consumers also need the wrapper for layout, expose that as a separate containerRef prop.

React Docs: Manipulating the DOM with a Ref
Over-exposed imperative handle
hard
Avoid
interface EditorHandle {
  /** Get the current editor content. */
  getValue: () => string;
  /** Replace the editor content. */
  setValue: (value: string) => void;
  /** Focus the editor. */
  focus: () => void;
  /** Blur the editor. */
  blur: () => void;
  /** Get the current selection range. */
  getSelection: () => SelectionRange;
  /** Set the selection range. */
  setSelection: (range: SelectionRange) => void;
  /** Undo the last change. */
  undo: () => void;
  /** Redo the last undone change. */
  redo: () => void;
}

interface EditorProps {
  /** Controlled content value. */
  value: string;
  /** Called on content change. */
  onChange: (value: string) => void;
  /** Imperative editor API. */
  ref?: React.Ref<EditorHandle>;
}

Prefer
interface EditorHandle {
  /** Move focus to the editor. */
  focus: () => void;
  /** Remove focus from the editor. */
  blur: () => void;
  /** Scroll a line number into view. */
  scrollToLine: (line: number) => void;
}

interface EditorProps {
  /** Controlled content value. */
  value: string;
  /** Called on content change. */
  onChange: (value: string) => void;
  /** Selection range. Controlled by parent. */
  selection?: SelectionRange;
  /** Called when the selection changes. */
  onSelectionChange?: (range: SelectionRange) => void;
  /** Imperative editor API. */
  ref?: React.Ref<EditorHandle>;
}

The imperative handle should only expose what can't be done through props. getValue/setValue duplicate the controlled value/onChange pair. Selection is better as controlled props (selection/onSelectionChange) so the parent can react to changes. undo/redo are internal state management.

What remains — focus(), blur(), scrollToLine() — are genuine imperative operations that have no declarative equivalent.

React Docs: useImperativeHandle