React Refs and forwardRef: DOM Access and Imperative APIs (2026)

Refs are React's escape hatch to the DOM and to values that persist across renders without causing re-renders. Most of the time React's declarative model handles everything — but for focus management, scroll position, measuring elements, video players and third-party integrations, refs are the right tool. React 19 simplified the forwardRef pattern significantly.

useRef Basics

import { useRef } from 'react'

// useRef returns a mutable object: { current: initialValue }
// Changing .current does NOT trigger a re-render

// 1. Hold a DOM node
const inputRef = useRef<HTMLInputElement>(null)

// 2. Store a mutable value that persists across renders
const renderCount = useRef(0)
const previousValue = useRef<string | undefined>(undefined)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)

function Counter() {
  const [count, setCount] = useState(0)
  const renderCount = useRef(0)

  // Increment on every render — doesn't cause another render
  renderCount.current++

  return (
    <div>
      <p>Count: {count}</p>
      <p>Renders: {renderCount.current}</p>
      <button onClick={() => setCount(c => c + 1)}>+</button>
    </div>
  )
}
useRef vs useState: Both persist values across renders. useState triggers a re-render when updated. useRef does not. Use useRef for values the UI doesn't need to reflect: timers, previous values, DOM nodes, external library instances.

DOM Access Patterns

// Focus management
function SearchBar() {
  const inputRef = useRef<HTMLInputElement>(null)

  // Focus on mount
  useEffect(() => {
    inputRef.current?.focus()
  }, [])

  // Focus on keyboard shortcut
  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
        e.preventDefault()
        inputRef.current?.focus()
        inputRef.current?.select()
      }
    }
    document.addEventListener('keydown', handleKeyDown)
    return () => document.removeEventListener('keydown', handleKeyDown)
  }, [])

  return <input ref={inputRef} placeholder="Search... (Ctrl+K)" />
}

// Scroll control
function ChatWindow({ messages }) {
  const bottomRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [messages])   // Scroll to bottom whenever messages change

  return (
    <div className="overflow-y-auto h-96">
      {messages.map(m => <Message key={m.id} message={m} />)}
      <div ref={bottomRef} />
    </div>
  )
}

// Measure element dimensions
function MeasuredDiv() {
  const divRef = useRef<HTMLDivElement>(null)
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 })

  useEffect(() => {
    if (!divRef.current) return
    const observer = new ResizeObserver(([entry]) => {
      const { width, height } = entry.contentRect
      setDimensions({ width: Math.round(width), height: Math.round(height) })
    })
    observer.observe(divRef.current)
    return () => observer.disconnect()
  }, [])

  return (
    <div ref={divRef} className="resize overflow-auto border p-4">
      {dimensions.width} × {dimensions.height}px
    </div>
  )
}

// Video player control
function VideoPlayer({ src }) {
  const videoRef = useRef<HTMLVideoElement>(null)

  return (
    <div>
      <video ref={videoRef} src={src} />
      <button onClick={() => videoRef.current?.play()}>Play</button>
      <button onClick={() => videoRef.current?.pause()}>Pause</button>
    </div>
  )
}

Callback Refs

Callback refs run when the node is attached or detached — useful when you need to react to the node becoming available:

// Callback ref — called with the node on mount, null on unmount
function AutoFocusInput() {
  const setRef = useCallback((node: HTMLInputElement | null) => {
    if (node) {
      node.focus()
      // node is available here — safe to measure, observe, etc.
    }
  }, [])

  return <input ref={setRef} placeholder="Auto-focused" />
}

// Combine callback ref with state to trigger re-render when ref attaches
function MeasuredElement() {
  const [element, setElement] = useState<HTMLDivElement | null>(null)
  const [width, setWidth] = useState(0)

  useEffect(() => {
    if (!element) return
    setWidth(element.getBoundingClientRect().width)
  }, [element])

  return (
    <div ref={setElement}>Width: {width}px</div>
  )
}

forwardRef

Function components can't receive a ref prop by default. forwardRef lets you pass a ref through to a DOM element inside the component:

import { forwardRef } from 'react'

interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string
  error?: string
}

// forwardRef wraps your component and gives access to the ref argument
const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, ...props }, ref) => {
    return (
      <div>
        <label>{label}</label>
        <input
          ref={ref}        // Forward to the actual DOM input
          aria-invalid={!!error}
          {...props}
        />
        {error && <p className="error">{error}</p>}
      </div>
    )
  }
)

Input.displayName = 'Input'   // Required for React DevTools

// Usage — ref works just like on a native input
function Form() {
  const inputRef = useRef<HTMLInputElement>(null)

  return (
    <form onSubmit={() => inputRef.current?.focus()}>
      <Input ref={inputRef} label="Email" type="email" />
    </form>
  )
}

React 19: ref as Prop

React 19 removes the need for forwardRefref is now a regular prop on function components:

// React 19+ — no forwardRef needed
function Input({ label, ref, ...props }: InputProps & { ref?: React.Ref<HTMLInputElement> }) {
  return (
    <div>
      <label>{label}</label>
      <input ref={ref} {...props} />
    </div>
  )
}

// Usage is identical
const inputRef = useRef<HTMLInputElement>(null)
<Input ref={inputRef} label="Email" />
Migration: In React 19, forwardRef still works but is deprecated. New components should use the ref-as-prop pattern. React will show a deprecation warning for forwardRef in dev mode.

useImperativeHandle

useImperativeHandle customizes what the parent gets when they hold a ref to your component — instead of exposing the raw DOM node, you expose a controlled API:

interface VideoPlayerHandle {
  play: () => void
  pause: () => void
  seek: (time: number) => void
  getCurrentTime: () => number
}

const VideoPlayer = forwardRef<VideoPlayerHandle, { src: string }>(
  ({ src }, ref) => {
    const videoRef = useRef<HTMLVideoElement>(null)

    // Expose only these methods — parent can't access the full DOM node
    useImperativeHandle(ref, () => ({
      play: () => videoRef.current?.play(),
      pause: () => videoRef.current?.pause(),
      seek: (time) => { if (videoRef.current) videoRef.current.currentTime = time },
      getCurrentTime: () => videoRef.current?.currentTime ?? 0,
    }), [])

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

// Parent uses the controlled API
function MediaControls() {
  const playerRef = useRef<VideoPlayerHandle>(null)

  return (
    <div>
      <VideoPlayer ref={playerRef} src="/video.mp4" />
      <button onClick={() => playerRef.current?.play()}>Play</button>
      <button onClick={() => playerRef.current?.seek(0)}>Restart</button>
    </div>
  )
}

Refs in Lists

// Can't use useRef directly in .map() — hooks can't be called conditionally
// Solution: store a Map of refs

function HighlightableList({ items }) {
  const itemRefs = useRef<Map<string, HTMLLIElement>>(new Map())

  function scrollToItem(id: string) {
    itemRefs.current.get(id)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
  }

  return (
    <ul>
      {items.map(item => (
        <li
          key={item.id}
          ref={node => {
            if (node) itemRefs.current.set(item.id, node)
            else itemRefs.current.delete(item.id)
          }}
        >
          {item.label}
        </li>
      ))}
    </ul>
  )
}

When to Use Refs

Use refs for Don't use refs for
Focus managementControlling what is rendered — use state
Scroll controlSharing data between components — use props/context
Measuring DOM dimensionsTriggering re-renders — use state
Third-party DOM library instancesReading values during render — will be null
Storing timer IDs, previous valuesAnything the UI should reflect