ReactManipulating the DOM with Refs

Manipulating the DOM with Refs

React manages the DOM for you — you describe what the UI should look like and React makes it so. But occasionally you need to step outside React's model and talk to the DOM directly: focusing an input, measuring an element's dimensions, scrolling a container, or integrating with a third-party library that expects a real DOM node.

The ref attribute on a DOM element is the official escape hatch for these situations. Pair it with useRef and you have a direct reference to the underlying DOM node.

Attaching a Ref to a DOM Element

JSX
import { useRef } from 'react'

function Example() {
  const inputRef = useRef(null)

  // After the component mounts, inputRef.current === the <input> DOM node
  return <input ref={inputRef} type="text" />
}

The lifecycle is precise:

  • Before mount — ref.current is null

  • After mount — ref.current is the DOM node

  • After unmount — ref.current is reset to null

Auto-Focus on Mount

A common requirement: focus a search box or modal input the moment it appears. Use useEffect with an empty dependency array to run code after the first render:

JSX
import { useRef, useEffect } from 'react'

function SearchModal({ isOpen }) {
  const inputRef = useRef(null)

  useEffect(() => {
    if (isOpen && inputRef.current) {
      inputRef.current.focus()
    }
  }, [isOpen])   // re-run whenever the modal opens

  if (!isOpen) return null

  return (
    <div role="dialog" aria-modal="true">
      <input
        ref={inputRef}
        type="search"
        placeholder="Search..."
        autoComplete="off"
      />
    </div>
  )
}
Note
The autoFocus HTML attribute works for the initial render, but useEffect + ref.current.focus() is required when the element mounts conditionally (like a modal that starts closed).
Scroll to Bottom — Chat Window

Chat applications need to scroll the message list to the bottom whenever a new message arrives. Refs make this simple:

JSX
import { useRef, useEffect } from 'react'

function ChatWindow({ messages }) {
  const bottomRef = useRef(null)

  useEffect(() => {
    // Scroll the sentinel element into view after every message update
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [messages])

  return (
    <div
      style={{
        height: 400,
        overflowY: 'auto',
        display: 'flex',
        flexDirection: 'column',
        gap: 8,
        padding: 16,
      }}
    >
      {messages.map(msg => (
        <div key={msg.id} className={msg.isOwn ? 'bubble-own' : 'bubble-other'}>
          {msg.text}
        </div>
      ))}
      {/* Invisible sentinel at the very bottom */}
      <div ref={bottomRef} />
    </div>
  )
}
Measuring Element Dimensions

Sometimes you need to know how large an element is — to position a tooltip, build a custom resize observer, or compute a dynamic layout:

JSX
import { useRef, useState, useLayoutEffect } from 'react'

function MeasuredBox({ children }) {
  const boxRef = useRef(null)
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 })

  useLayoutEffect(() => {
    if (!boxRef.current) return

    const { width, height } = boxRef.current.getBoundingClientRect()
    setDimensions({ width, height })

    // Optionally observe future size changes:
    const observer = new ResizeObserver(([entry]) => {
      setDimensions({
        width: entry.contentRect.width,
        height: entry.contentRect.height,
      })
    })
    observer.observe(boxRef.current)
    return () => observer.disconnect()
  }, [])

  return (
    <div ref={boxRef} style={{ resize: 'both', overflow: 'auto', border: '1px solid' }}>
      {children}
      <small style={{ display: 'block', color: 'gray' }}>
        {dimensions.width.toFixed(0)} × {dimensions.height.toFixed(0)} px
      </small>
    </div>
  )
}
Triggering a CSS Animation Imperatively

When you need a one-shot animation triggered by an event (e.g., shake on invalid input), you can add and remove a CSS class directly via the DOM:

JSX
import { useRef } from 'react'

// CSS (in your stylesheet):
// @keyframes shake {
//   0%, 100% { transform: translateX(0) }
//   20%, 60% { transform: translateX(-8px) }
//   40%, 80% { transform: translateX(8px) }
// }
// .shake { animation: shake 0.4s ease; }

function ValidatedInput({ onSubmit }) {
  const inputRef = useRef(null)

  function handleSubmit() {
    const value = inputRef.current.value.trim()
    if (!value) {
      // Shake the input to signal invalid
      const el = inputRef.current
      el.classList.remove('shake')
      // Force reflow so the animation restarts if already running
      void el.offsetWidth
      el.classList.add('shake')
      el.addEventListener('animationend', () => el.classList.remove('shake'), { once: true })
      return
    }
    onSubmit(value)
  }

  return (
    <div>
      <input ref={inputRef} type="text" placeholder="Enter a value" />
      <button onClick={handleSubmit}>Submit</button>
    </div>
  )
}
The useEffect + Ref Pattern

Almost all imperative DOM work follows the same structure: create the ref, attach it to the element, then use useEffect to run the imperative code after React has committed the DOM:

JSX
function MapWidget({ lat, lng }) {
  const containerRef = useRef(null)
  const mapRef = useRef(null)   // store the library instance

  // Initialize the map once on mount
  useEffect(() => {
    if (!containerRef.current) return
    mapRef.current = new ThirdPartyMap(containerRef.current, { lat, lng, zoom: 14 })
    return () => mapRef.current?.destroy()   // cleanup on unmount
  }, [])

  // Update the map when coordinates change
  useEffect(() => {
    mapRef.current?.setCenter({ lat, lng })
  }, [lat, lng])

  return <div ref={containerRef} style={{ width: '100%', height: 400 }} />
}
Warning
Do not use refs to read or set values that React is already managing. For example, never do inputRef.current.value = '' to clear a controlled input — call setState instead. Bypassing React's reconciliation for data it owns leads to out-of-sync state bugs that are very hard to debug.
Multiple Refs in the Same Component

JSX
function VideoPlayer({ src }) {
  const videoRef = useRef(null)
  const progressRef = useRef(null)

  function handlePlay() { videoRef.current?.play() }
  function handlePause() { videoRef.current?.pause() }

  function handleTimeUpdate() {
    const pct = (videoRef.current.currentTime / videoRef.current.duration) * 100
    progressRef.current.style.width = pct + '%'
  }

  return (
    <div>
      <video ref={videoRef} src={src} onTimeUpdate={handleTimeUpdate} />
      <div style={{ height: 4, background: '#eee' }}>
        <div ref={progressRef} style={{ height: '100%', background: 'blue', width: '0%' }} />
      </div>
      <button onClick={handlePlay}>Play</button>
      <button onClick={handlePause}>Pause</button>
    </div>
  )
}
Ref Callbacks for Dynamic Lists

When you need refs for a dynamic number of elements (you cannot call hooks in a loop), use a ref callback — a function that React calls with the DOM node when the element mounts:

JSX
import { useRef } from 'react'

function HighlightableList({ items }) {
  const itemRefs = useRef(new Map())

  function scrollToItem(id) {
    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.name}
          </li>
        ))}
      </ul>
      <button onClick={() => scrollToItem(items[items.length - 1].id)}>
        Scroll to last
      </button>
    </>
  )
}
Tip
Ref callbacks receive null when the component unmounts. Always clean up your Map (or array) in the else branch to avoid memory leaks — especially important in long-lived lists that mount and unmount items frequently.