Skip to content

Refs and the DOM

React builds and updates the DOM for you. You describe what the screen should look like, and the actual elements are React's job. A small number of tasks still need the element itself: focusing an input after a button is clicked, scrolling a message into view, measuring how wide a box turned out, playing a video. None of those can be expressed as a piece of JSX, because each one acts on a node instead of describing one. A ref is the escape hatch for them. It gives a component a way to hold onto a real DOM element and call methods on it directly.

Attaching a ref

A ref starts with the useRef hook, and you hand the result to an element through the ref attribute:

jsx
import { useRef } from 'react'

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

  function handleFocus() {
    inputRef.current.focus()
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleFocus}>Focus</button>
    </>
  )
}

useRef(null) returns an object with a single property, current, holding whatever you passed as the initial value. You always pass one, and null is the conventional starting point for a DOM ref, since there is no node yet at that moment.

Writing ref={inputRef} is the instruction React needs. When React puts that <input> on the screen, it sets inputRef.current to the DOM node. From then on inputRef.current is the input element itself, with every method and property the browser gives it, so inputRef.current.focus() inside the click handler moves the cursor into the field. React keeps the assignment up to date for you: if the element is removed from the screen, React sets current back to null.

Scrolling a node into view

Focus is one job. Scrolling is the other one you will hit early, usually in something like a chat log that should jump to the newest message. That job runs after render, so it pairs with useEffect:

jsx
import { useRef, useEffect } from 'react'

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

  useEffect(() => {
    bottomRef.current.scrollIntoView({ behavior: 'smooth' })
  }, [messages])

  return (
    <div>
      {messages.map(message => (
        <p key={message.id}>{message.text}</p>
      ))}
      <div ref={bottomRef} />
    </div>
  )
}

The empty <div> at the bottom exists only as something to scroll to, which is a common and perfectly reasonable trick. The effect runs whenever messages changes, and by then React has already put the new messages on the screen and pointed bottomRef.current at that final div.

Changing a ref never triggers a render

State and refs both survive from one render to the next, and there the resemblance stops. Calling a state setter asks React to render the component again with the new value. Assigning to ref.current changes the value silently, and the screen stays exactly as it was.

That silence is the whole point. A ref holds something the UI does not display: a DOM node, a timer ID, a flag your rendering logic never reads. Re-rendering when one of those changes would cost you a redraw that could not change a single pixel.

It has a second consequence worth holding onto. Because nothing has to be scheduled, the value in ref.current is readable the instant after you assign it. A state variable is fixed for the whole of one render and only moves in the next one, as State covers. A ref behaves like a plain mutable box that you can read and write at any moment.

When a ref is the wrong instinct

The dividing line is whether the value ends up on the screen. Text in a heading, a number in a badge, whether a panel is open: all of that belongs in state, because the display has to change when the value does. Put it in a ref and the update goes unseen: the value moves, and the UI keeps showing whatever it rendered last.

Reaching into the DOM to change what is displayed is the same instinct one step further along. Setting node.textContent by hand, or toggling a class directly, appears to work, and then the next render undoes it, because React re-applies whatever your JSX describes. Refs cover two things: reading and commanding a node, meaning focus it, scroll it, measure it, play it, and holding values that survive across renders without ever being displayed. For anything the user reads off the screen, describe it in JSX and drive it with state.

The second use of a ref has nothing to do with the DOM. Any value that has to survive across renders without being displayed can live in ref.current, which makes a ref the natural home for bookkeeping like a timer ID:

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

  function start() {
    if (intervalRef.current !== null) return
    intervalRef.current = setInterval(() => setSeconds(s => s + 1), 1000)
  }

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

  useEffect(() => () => clearInterval(intervalRef.current), [])

  return (
    <>
      <p>{seconds}</p>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </>
  )
}

The ID that setInterval hands back is the only way to stop the timer later, so stop has to be able to find it. A local variable would be gone by the next render, and state would trigger a pointless re-render every time the timer started. intervalRef.current is the right shape for the job, and it doubles as the "is it already running?" check in start. The effect at the end is the usual cleanup rule from Effects: the timer started here, so something has to stop it if the component leaves the screen while it is running.

The same pattern gives you the previous value of a prop or a state variable, packaged here as a custom hook:

jsx
function usePrevious(value) {
  const ref = useRef(undefined)

  useEffect(() => {
    ref.current = value
  })

  return ref.current
}

The effect runs after each render, so the ref is updated only once the render that used the old value has finished, and the next render reads what came before it. On the very first render the hook returns undefined. Note the explicit useRef(undefined): useRef takes its initial value as an argument, and React 19's types expect that argument to be present, so pass undefined deliberately when there is nothing meaningful to start from.

One more everyday case: an uncontrolled form field. When a value only matters at submit time and nothing on screen depends on it while the user types, a ref reads it straight off the input without a re-render per keystroke, which the Forms chapter shows in full.

Being precise about when ref.current holds a node explains most of the surprises. React works in two phases. During the render phase it calls your component function to produce a description of the UI, and no DOM exists for that description yet. During the commit phase it applies the changes to the real DOM, and that is where refs are attached: React sets current to the node it just created or kept, then runs useLayoutEffect, then lets the browser paint, then runs useEffect. When a ref is detached, either because the element left the screen or because the ref prop now points somewhere else, React sets current back to null before attaching the new one.

So the ordering is: effects and event handlers see a populated ref whenever the element is on the screen, and the render body does not. On the first render, inputRef.current is still the null you passed to useRef while the function body runs. On later renders it holds the node from the previous commit, which describes a screen that is about to be replaced.

That last point is why reading a ref during render is unsafe. React expects rendering to be pure, and it reserves the right to render a component without committing the result: Strict Mode renders twice in development, a concurrent render can be thrown away when a higher-priority update arrives, and a suspended tree can be retried. A measurement taken during render can therefore describe a layout that no longer matches, and a write to ref.current during render can happen twice or be discarded entirely. Keep reads and writes in event handlers, effects, or callback refs, where React has committed and the timing is defined.

A callback ref is the other way to attach one. Pass a function to ref and React calls it with the node on attach:

jsx
<div
  ref={node => {
    const observer = new ResizeObserver(() => {
      // react to the element changing size
    })
    observer.observe(node)
    return () => observer.disconnect()
  }}
/>

In React 19 a callback ref may return a cleanup function, and React calls it when that node is detached. That supersedes the older convention for any callback that returns a cleanup, and it lets setup and teardown sit together the way they do in an effect. A callback that returns nothing is still invoked a second time with null on detach, which is the form you will meet in existing code. One sharp edge: an inline arrow function is a fresh function on every render, so React detaches and re-attaches the node each time. When that matters, wrap the callback in useCallback so its identity stays stable.

Because ref arrives as an ordinary prop in React 19, a component can also accept one and hand it down to whichever element should receive it:

jsx
function TextInput({ ref, ...props }) {
  return <input ref={ref} {...props} />
}

Expose that sparingly. Handing a parent a live DOM node inside your component makes the node part of your public API, and any refactor of the markup underneath can break the caller.

Version note

Before React 19, function components could not receive a ref prop at all. Passing one through to an element inside required wrapping the component in forwardRef. React 19 delivers ref to function components as an ordinary prop, so forwardRef is unnecessary in new code and is deprecated. Further back, class components created refs with React.createRef() and read them from this. The useRef hook covers both roles in function components.

JunoA ref reaches the real DOM node Call useRef(null), put the result on an element with ref={inputRef}, and React fills in inputRef.current with the actual DOM node once it is on the screen. Then you can do things to it, like inputRef.current.focus(). Reach for a ref when you need to focus, scroll, or measure something. Anything the person using your app reads off the screen belongs in state.
JunoA ref reaches the real DOM nodeuseRef gives you a box with a current property that survives every render, and writing to it never schedules one. That covers two jobs: reaching a DOM node for focus, scrolling, or measurement, and stashing values the UI never displays, like a timer ID or the previous value of a prop. If the value shows up on screen, use state so the screen actually updates.
JunoA ref reaches the real DOM node Refs attach during commit, before useLayoutEffect and useEffect, and detach back to null, so effects and handlers see a live node while the render body sees the previous commit's. That is why reading or writing ref.current during render is unsafe: renders must stay pure, and React can discard or repeat one. Callback refs in React 19 can return a cleanup function, which pairs setup with teardown, though an inline callback re-attaches every render unless you stabilize it with useCallback.

Next up: Hooks, the wider family that useState, useEffect, and useRef all belong to.