Skip to content
React rc advanced 4 min read

Refs & the DOM

React’s declarative model handles almost everything: you describe the UI as a function of state and React reconciles the DOM for you. But a few tasks are inherently imperative—focusing an input, scrolling an element into view, measuring its size, or controlling a <video>. For those, you need a direct handle on the underlying DOM node, and refs are how React hands one to you without breaking out of the component model.

Getting a DOM node with a ref

Create a ref with useRef(null) and pass it to a JSX element’s ref attribute. After React commits the element to the DOM, it sets ref.current to the real DOM node. Before mount and after unmount, current is null, so guard accordingly.

import { useRef } from "react";

function SearchField() {
  const inputRef = useRef(null);

  function focusInput() {
    inputRef.current?.focus();
  }

  return (
    <div>
      <input ref={inputRef} type="text" placeholder="Search…" />
      <button onClick={focusInput}>Focus the field</button>
    </div>
  );
}

The DOM node exposes the full browser API—focus(), select(), scrollIntoView(), getBoundingClientRect(), and so on. React doesn’t wrap or restrict it; you’re talking to the platform directly.

Common imperative jobs

These four cases cover the large majority of legitimate ref usage.

Managing focus. Move keyboard focus after an action—opening a dialog, revealing an inline editor, or recovering focus after a deletion.

Scrolling. Bring an element into view or restore scroll position.

function scrollToTop(ref) {
  ref.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}

Measuring layout. Read geometry to position tooltips, build virtualized lists, or trigger animations. Measure inside useLayoutEffect so you read the DOM after it’s committed but before the browser paints, avoiding a visible flicker.

import { useLayoutEffect, useRef, useState } from "react";

function MeasuredBox({ children }) {
  const boxRef = useRef(null);
  const [height, setHeight] = useState(0);

  useLayoutEffect(() => {
    if (boxRef.current) {
      setHeight(boxRef.current.getBoundingClientRect().height);
    }
  }, [children]);

  return (
    <div ref={boxRef}>
      {children}
      <small>Rendered height: {Math.round(height)}px</small>
    </div>
  );
}

Media playback. Control elements that hold their own internal state—video, audio, and <canvas>.

import { useRef } from "react";

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

  const play = () => videoRef.current?.play();
  const pause = () => videoRef.current?.pause();

  return (
    <figure>
      <video ref={videoRef} src={src} width={480} />
      <div>
        <button onClick={play}>Play</button>
        <button onClick={pause}>Pause</button>
      </div>
    </figure>
  );
}

The ref lifecycle

React sets ref.current during the commit phase, after mutating the DOM and before running effects. On unmount it sets the ref back to null. This timing is why effects—not render—are the right place to touch a ref: by the time an effect runs, the node is guaranteed to exist.

Phaseref.current value
During rendernull (mount) or previous node (update)
After commit, before paintThe DOM node (read in useLayoutEffect)
After paintThe DOM node (read in useEffect)
After unmountnull

Never read or write ref.current during rendering. The node may not exist yet, and side effects during render make output unpredictable. Confine ref access to event handlers, useEffect, and useLayoutEffect.

Ref callbacks

Instead of a ref object, you can pass a function to the ref attribute. React calls it with the DOM node on mount and with null on unmount. Ref callbacks shine when you need to attach refs to a dynamic list of elements, or run setup the moment a node appears.

import { useRef } from "react";

function ItemList({ items }) {
  const nodesRef = useRef(new Map());

  function scrollToItem(id) {
    nodesRef.current.get(id)?.scrollIntoView({ behavior: "smooth" });
  }

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

In React 19 a ref callback may return a cleanup function, called when the node detaches—mirroring useEffect. In earlier versions React calls the callback with null on detach instead, so do cleanup there. Avoid inline arrow callbacks that recreate every render unless you intend the detach/attach cycle; React re-runs a callback whose identity changes.

Keep imperative code minimal

Refs are an escape hatch, not a default. Reaching for the DOM to set text, toggle classes, or store data that should drive the UI fights React instead of using it. Prefer state and props; drop to a ref only for things React genuinely cannot express declaratively, and keep the imperative surface as small as possible.

// Avoid: mutating the DOM React owns
spanRef.current.textContent = label; // React may overwrite this

// Prefer: let React render it
return <span>{label}</span>;

A safe rule: read from the DOM freely (measure, check focus), but think twice before writing to nodes React renders. Mutating React-managed DOM risks being clobbered on the next render.

Best Practices

  • Use refs only for imperative tasks React can’t express: focus, scroll, measurement, media, and third-party DOM libraries.
  • Guard every access with ref.current?. or a null check—nodes are absent before mount and after unmount.
  • Touch refs in event handlers and effects, never during render.
  • Measure layout in useLayoutEffect, not useEffect, to avoid a flash of wrong geometry.
  • Use ref callbacks for collections of nodes; return (or handle null for) cleanup when a node detaches.
  • Don’t write to DOM that React renders—let state and props drive content; React may overwrite manual mutations.
  • To expose a child’s imperative API to a parent, combine refs with useImperativeHandle rather than reaching across components.
Last updated June 14, 2026
Was this helpful?