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.
Table of Contents
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>
)
}
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 forwardRef — ref 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" />
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 management | Controlling what is rendered — use state |
| Scroll control | Sharing data between components — use props/context |
| Measuring DOM dimensions | Triggering re-renders — use state |
| Third-party DOM library instances | Reading values during render — will be null |
| Storing timer IDs, previous values | Anything the UI should reflect |