Skip to content

Refs and DOM Access

intermediate12 min read

Refs: The Escape Hatch

React is declarative — you describe what the UI should look like, and React handles the DOM. But sometimes you need direct DOM access: focusing an input, measuring an element, integrating with a non-React library. Refs are React's escape hatch for these cases.

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

  useEffect(() => {
    inputRef.current.focus(); // Direct DOM API call
  }, []);

  return <input ref={inputRef} placeholder="Search..." />;
}
Mental Model

Think of a ref as a sticky note attached to the side of your component. It holds one value (current). Changing the sticky note does not trigger a re-render — React does not even notice. State is like the component's official record that React watches. Refs are the side channel that React ignores. Use state for values that affect what the user sees. Use refs for values that do not.

useRef: Two Distinct Use Cases

1. DOM Element References

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

  function handlePlay() {
    videoRef.current.play(); // Imperative DOM API
  }

  function handlePause() {
    videoRef.current.pause();
  }

  return (
    <div>
      <video ref={videoRef} src={src} />
      <button onClick={handlePlay}>Play</button>
      <button onClick={handlePause}>Pause</button>
    </div>
  );
}

When React mounts the <video> element, it sets videoRef.current to the DOM node. On unmount, it sets it back to null.

2. Mutable Instance Variables

function Timer() {
  const [seconds, setSeconds] = useState(0);
  const intervalRef = useRef(null);

  function start() {
    if (intervalRef.current) return; // Prevent double-start
    intervalRef.current = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);
  }

  function stop() {
    clearInterval(intervalRef.current);
    intervalRef.current = null;
  }

  useEffect(() => {
    return () => clearInterval(intervalRef.current); // Cleanup on unmount
  }, []);

  return (
    <div>
      <span>{seconds}s</span>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </div>
  );
}

The interval ID is stored in a ref because:

  • It does not affect rendering (no visual change when the ID changes)
  • It needs to persist across renders (state would work but triggers unnecessary re-renders)
  • It needs to be mutable (stop needs to read the current ID set by start)

Ref vs State: The Decision

QuestionStateRef
Does changing it affect what the user sees?YesNo
Does React need to re-render when it changes?YesNo
Is it used only in event handlers or effects?EitherUsually
Do you need the value during render?YesAvoid
Common Trap

Never read or write ref.current during rendering (in the component body, outside effects and handlers). Refs are mutable, and reading mutable values during render makes the component's output unpredictable. React may call your render function multiple times (concurrent features), and each call could see a different ref value. Keep ref access in effects and event handlers.

forwardRef: Passing Refs to Child Components

Custom components do not accept ref directly. You need forwardRef:

const FancyInput = forwardRef(function FancyInput(props, ref) {
  return (
    <input
      ref={ref}
      className="fancy-input"
      {...props}
    />
  );
});

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

  useEffect(() => {
    inputRef.current.focus(); // Works — ref forwarded to <input>
  }, []);

  return <FancyInput ref={inputRef} placeholder="Name" />;
}
React 19 ref as a prop

React 19 simplifies ref forwarding. You no longer need forwardRefref is passed as a regular prop:

// React 19 — ref is just a prop
function FancyInput({ ref, ...props }) {
  return <input ref={ref} className="fancy-input" {...props} />;
}

// Usage is the same:
<FancyInput ref={inputRef} />

forwardRef still works for backwards compatibility, but new code should use the prop pattern.

Callback Refs: Dynamic DOM Measurement

When you need to run code the moment a DOM node is attached or detached, use a callback ref:

function MeasuredBox() {
  const [height, setHeight] = useState(0);

  const measuredRef = useCallback((node) => {
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height);
    }
  }, []);

  return (
    <div>
      <div ref={measuredRef}>
        <p>Content that determines height</p>
      </div>
      <p>Height: {height}px</p>
    </div>
  );
}

Callback refs fire when the DOM node is attached (argument is the node) and when detached (argument is null). Unlike useRef + useEffect, callback refs work immediately — even for conditionally rendered elements.

Production Scenario: Focus Management in a Modal

function Modal({ isOpen, onClose, children }) {
  const closeButtonRef = useRef(null);
  const previousFocusRef = useRef(null);

  useEffect(() => {
    if (isOpen) {
      // Store the previously focused element
      previousFocusRef.current = document.activeElement;
      // Focus the close button inside the modal
      closeButtonRef.current?.focus();
    }

    return () => {
      // Restore focus when modal closes
      previousFocusRef.current?.focus();
    };
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div className="modal-overlay" role="dialog" aria-modal="true">
      <div className="modal-content">
        <button
          ref={closeButtonRef}
          onClick={onClose}
          aria-label="Close modal"
        >
          X
        </button>
        {children}
      </div>
    </div>
  );
}
Execution Trace
Create ref:
`useRef(null)` → `( current: null )`
Ref object created on first render
Attach:
`div ref=(myRef)` mounted
React sets myRef.current = DOM node after commit
Effect:
useEffect accesses myRef.current
DOM node is available — safe to measure, focus, etc.
Re-render:
Component re-renders
myRef persists — same object, same .current
Detach:
Element unmounts
React sets myRef.current = null
What developers doWhat they should do
Reading ref.current during render to make rendering decisions
Refs are mutable. Reading them during render makes output unpredictable, especially with concurrent features where render may be called multiple times.
Read refs only in effects and event handlers
Using ref when state is needed — component does not update when ref changes
Changing ref.current is invisible to React. The component will not re-render, and the UI will show stale data.
If the value affects what is displayed, use state. Refs do not trigger re-renders.
Passing ref directly to a custom component: <MyInput ref=`{ref}` /> (pre React 19)
Before React 19, ref was not a regular prop on function components. It was consumed by React and not forwarded. forwardRef explicitly passes it through.
Use forwardRef to wrap the component, or pass as a differently named prop
Using useRef to store previous state values without understanding closure timing
During render, the ref still holds the previous value. It should be updated in useEffect so it is in sync after the render completes.
Update the ref in useEffect, not during render
Quiz
What is the difference between updating a ref and updating state?
Quiz
When does React set ref.current to the DOM element?
Quiz
What does a callback ref receive as its argument when the element unmounts?
Key Rules
  1. 1useRef returns { current: value } — a mutable container that persists across renders without triggering re-renders
  2. 2Two use cases: DOM element access and mutable instance variables (timer IDs, previous values)
  3. 3Never read or write ref.current during render — only in effects and event handlers
  4. 4forwardRef passes refs to child components (unnecessary in React 19 where ref is a regular prop)
  5. 5Callback refs fire on mount (with node) and unmount (with null) — use for dynamic measurement

Challenge: Build a Click-Outside Hook

Click Outside Detection