Refs and the DOM

Everything so far has been declarative: you describe what the screen should look like for a given state, and React works out the DOM operations. That covers most of an interface, but not all of it. Moving focus, playing a video, measuring how wide a heading turned out to be, scrolling a list to the bottom — none of these are values you can describe. For those you need the real node, at the moment the user acts.A ref is React's escape hatch: a plain object with a single current property that survives every re-render and never triggers one. It serves two distinct purposes, which this section separates carefully. On its own, a ref is a mutable box for a timer ID, a previous value, a WebSocket. Passed to a JSX element's ref attribute, it becomes a handle on the DOM node React created — React writes the node into current when it commits and clears it when the node goes away.Figure 2.22: A state update re-renders; a ref mutation changes nothing on screenThe DOM API itself — focus, scrollIntoView, getBoundingClientRect, IntersectionObserver — is Volume 1 material. What follows is how React gives you a node to call those methods on, when the node is actually there, and how to keep imperative code from breaking the declarative model around it.

2.6.1 useRef as a Mutable Box

useRef(initialValue) returns an object of the shape { current: initialValue }, and React hands you the same object on every render. The argument is used once, on the first render, and ignored afterwards. That is the whole API. What makes it useful is what it deliberately does not do: React does not track current, so assigning to it never schedules a render. A ref is state the screen does not depend on.

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>

<script type="importmap">{"imports":{"react":"https://esm.sh/react@19.3.0?dev",

"react-dom/client":"https://esm.sh/react-dom@19.3.0/client?dev"}}</script>

<div id="root"></div><script type="text/babel" data-type="module">

import React, { useRef, useState } from 'react';

import { createRoot } from 'react-dom/client';

function Counter() {

const clicks = useRef(0), [renders, setRenders] = useState(0);

return <div style={{font: '16px system-ui'}}>

<button onClick={() => clicks.current++}>Bump the ref</button>{' '}

<button onClick={() => setRenders(r => r + 1)}>Re-render</button>

<p>clicks.current = <b>{clicks.current}</b>, renders = <b>{renders}</b></p></div>;

}

createRoot(document.getElementById('root')).render(<Counter />);

const b = i => document.querySelectorAll('button')[i];

setTimeout(() => { b(0).click(); b(0).click(); b(0).click(); b(1).click(); }, 400);

</script>

Listing 2.40: A ref changes silently; only a state update repaints Figure 2.23: Browser output of Listing 2.40The three "Bump the ref" clicks change nothing on screen. Only the fourth, which sets state, produces a render — and that render reads the ref and finally shows 3. Displaying clicks.current breaks a documented rule (never read or write a ref while rendering), and it is broken here to make the timing visible: React never re-reads a ref on its own. Question useState useRef Re-renders on change? Yes Never Value inside one render Frozen Always the latest Identity across renders New value Same object Safe to touch during render? No No Typical contents Anything on screen Timer IDs, DOM nodes Table 2.11: State and refs solve different problems One wrinkle: the argument is evaluated on every render even though only the first result is kept, so useRef(new VideoPlayer()) builds a player and throws it away each time. Initialize anything expensive lazily, with if (playerRef.current === null) playerRef.current = new VideoPlayer(); at the top of the component. In Strict Mode, React calls your component twice in development, so each ref is created twice and one copy discarded — harmless as long as current is touched only in handlers and effects.

2.6.2 Accessing a DOM Node

Pass a ref object to the ref attribute of a built-in element and React puts that element's DOM node into current when it commits, then sets current back to null when the node is removed. Initialize the ref with null, because that is what it holds before the first commit.

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>

<script type="importmap">{"imports":{"react":"https://esm.sh/react@19.3.0?dev",

"react-dom/client":"https://esm.sh/react-dom@19.3.0/client?dev"}}</script>

<div id="root"></div><script type="text/babel" data-type="module">

import React, { useRef, useState } from 'react';

import { createRoot } from 'react-dom/client';

function TextInputWithFocusButton() {

const inputEl = useRef(null); // null until React commits

const [active, setActive] = useState('nothing');

const focusIt = () => { inputEl.current.focus(); setActive(document.activeElement.id); };

return <div style={{font: '16px system-ui'}}>

<input id="name" ref={inputEl} placeholder="your name" />{' '}

<button onClick={focusIt}>Focus the input</button>

<p>document.activeElement is #<b>{active}</b></p></div>;

}

createRoot(document.getElementById('root')).render(<TextInputWithFocusButton />);

setTimeout(() => document.querySelector('button').click(), 400);

</script>

Listing 2.41: Focusing a text input through a ref Figure 2.24: Browser output of Listing 2.41The screenshot is taken after a scripted click, so the input carries the focus ring and the readout names the focused node. Nothing here reads inputEl.current during render — the only safe places are event handlers and effects, because during render the node for this render does not exist yet.Two limits are worth knowing before you hit them. First, ref on a component (<MyInput ref={r} />) is not automatic: the component has to accept it and pass it down, which is Section 2.6.3. Second, React still owns the node. Reading from it — measuring, focusing, scrolling, calling play() — is safe; adding or removing children by hand is not, because React's next update diffs against a tree that no longer matches the DOM. And since updates are batched, the DOM has not changed yet on the line after setItems(...): to measure or scroll immediately afterwards, wrap the update in flushSync from react-dom, sparingly.

2.6.3 Refs as Props

A ref put on your own component used to go nowhere. ref was not a prop — React intercepted it, and a function component had to be wrapped in forwardRef to receive it as a second argument. React 19 removed the special case: ref is an ordinary prop, so a component can destructure it and pass it on.

function MyInput({ label, ref, ...rest }) {

return <label>{label} <input ref={ref} {...rest} /></label>;

}

function SignupForm() {

const emailRef = useRef(null);

return <form onSubmit={e => { e.preventDefault(); emailRef.current.focus(); }}>

<MyInput label="Email" ref={emailRef} type="email" />

<button>Sign up</button></form>;

}

Listing 2.42: A reusable input that forwards its ref, React 19 style SignupForm never learns which DOM node it got; MyInput decides. That is the point — the child publishes one node as its public handle and can change its markup freely, as long as the ref keeps landing on something with the methods the parent calls.Three consequences follow. A component that does not declare ref ignores it the way it ignores any unused prop, so there is no warning and no node: forwarding is opt-in. ref spread through {...rest} works, since it is now part of props. And element.ref is deprecated in favor of element.props.ref.forwardRef still works in React 19 and is what you will meet in older code and in libraries that also support React 18. The official reference calls it unnecessary and says it will be deprecated in a future release.

const MyInput = forwardRef(function MyInput({ label, ...rest }, ref) {

return <label>{label} <input ref={ref} {...rest} /></label>;

});

Listing 2.43: The same component written for React 18 with forwardRef Class components remain the exception: a ref on a class yields the instance, with no forwarding involved. That is a legacy path (Section 2.2.9); the modern way to choose what a parent may call is useImperativeHandle (Section 2.6.8).

2.6.4 Callback Refs

The ref attribute also accepts a function. React calls it with the DOM node when the node is attached, and since React 19 the function may return a cleanup function that React calls when the node is detached. A ref object cannot tell you when it was filled; a callback ref is the notification. That decides every measuring problem: useRef plus a read during render fails outright, because current is still null while the JSX is being produced.

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>

<script type="importmap">{"imports":{"react":"https://esm.sh/react@19.3.0?dev",

"react-dom/client":"https://esm.sh/react-dom@19.3.0/client?dev"}}</script>

<div id="root"></div><script type="text/babel" data-type="module">

import React, { useState, useCallback } from 'react';

import { createRoot } from 'react-dom/client';

function MeasureExample() {

const [rect, setRect] = useState(null);

const measuredRef = useCallback(node => {

if (node) setRect(node.getBoundingClientRect());

return () => { setRect(null); }; // React 19: runs when the node detaches

}, []);

return <div style={{font: '16px system-ui'}}>

<h1 ref={measuredRef}>Hello, world</h1>

{rect && <p>The heading is {Math.round(rect.width)}px by

{' ' + Math.round(rect.height)}px.</p>}</div>;

}

createRoot(document.getElementById('root')).render(<MeasureExample />);

</script>

Listing 2.44: A callback ref that measures the node it receives Figure 2.25: Browser output of Listing 2.44Wrapping the callback in useCallback with an empty dependency list matters. React compares function identity between renders, so a fresh inline arrow would make React detach the node from the old callback and attach it to the new one on every render — here, a setRect per render and an infinite loop.Two more things follow from the signature. Returning something that is not a cleanup function is now a TypeScript error, so write ref={node => { el = node; }} with braces rather than ref={node => (el = node)}. And a callback ref is the only way to collect a list of nodes: keep a Map in a ref and have the callback run map.set(id, node) on attach and map.delete(id) on cleanup. Warning: This callback fires only on mount and unmount, so the measurement does not follow a resize. For a box whose size changes, create a ResizeObserver inside the callback and disconnect it in the cleanup.

2.6.5 Fragment Refs

A ref needs a node, and a component that returns several siblings has no single node to give. The old fix was a wrapper <div> added purely so something could be measured or observed. React 19.3, released on 9 September 2026, removed the need for it: Fragment now accepts a ref and fills it with a fragment instance, an object that forwards a small set of operations to the children it wraps.

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>

<script type="importmap">{"imports":{"react":"https://esm.sh/react@19.3.0?dev",

"react-dom/client":"https://esm.sh/react-dom@19.3.0/client?dev"}}</script>

<div id="root"></div><script type="text/babel" data-type="module">

import React, { Fragment, useRef, useState } from 'react';

import { createRoot } from 'react-dom/client';

function Fields({ children }) {

const group = useRef(null), [info, setInfo] = useState('');

function inspect() {

const rects = group.current.getClientRects();

group.current.focus(); // first focusable child, depth-first

setInfo(`${rects.length} children, first ${Math.round(rects[0].width)}px wide; `

+ `focus is on #${document.activeElement.id}`);

}

return <div style={{font: '15px system-ui'}}>

<Fragment ref={group}>{children}</Fragment>

<p><button onClick={inspect}>Inspect the group</button></p>

<p><b>{info}</b></p></div>;

}

createRoot(document.getElementById('root')).render(<Fields>

<input id="first" placeholder="first" /><input id="last" placeholder="last" /></Fields>);

setTimeout(() => document.querySelector('button').click(), 400);

</script>

Listing 2.45: A Fragment ref measures and focuses a group of fields Figure 2.26: Browser output of Listing 2.45The readout comes entirely from the fragment instance: getClientRects() returns one DOMRect per first-level DOM child, and focus() walks the children depth-first to the first focusable element. The same object offers focusLast(), blur(), addEventListener and removeEventListener (attached to every first-level child), observeUsing and unobserveUsing for an IntersectionObserver or ResizeObserver, dispatchEvent, scrollIntoView, getRootNode and compareDocumentPosition.Generic wrappers become possible without touching the markup they wrap: a <Highlight> that watches whatever you put inside it for visibility no longer has to impose a <div> that might break a grid or a flex layout. Note: The shorthand <>...</> cannot take a ref — there is nowhere to put attributes — so import Fragment and write it out. Each first-level DOM child also carries a reactFragments property holding the fragment instances that own it, which lets a library share one observer across them.

2.6.6 Refs as Component Variables

Not everything a component remembers belongs on screen. An interval ID, a WebSocket, a scroll position to restore, a flag saying whether this is the first render — the component needs these across renders, but no pixel depends on them. In a class they were instance fields; in a function component they go in a ref. A plain variable will not do: the function body runs again on every render, so let id = null starts over each time, while useRef hands back the same box.

function Timer() {

const [n, setN] = useState(0);

const tid = useRef(null);

useEffect(() => {

tid.current = setInterval(() => setN(v => v + 1), 1000);

return () => clearInterval(tid.current); // also stops it on unmount

}, []);

return <button onClick={() => clearInterval(tid.current)}>Stop at {n}</button>;

}

Listing 2.46: An interval ID lives in a ref, not in state Putting tid in state would be wrong twice over: setting it would schedule a pointless render, and the value would be read one render behind whenever two handlers ran in the same batch.The same trick gives you the value a prop had last time, which React does not otherwise offer. Write the current value into the ref inside an effect with no dependency array; during the next render the ref still holds the previous one.

function usePrevious(value) {

const ref = useRef(undefined);

useEffect(() => { ref.current = value; }); // runs after every commit

return ref.current; // the value from the render before

}

Listing 2.47: Remembering the previous value of a prop A ref used this way is invisible to React, and that cuts both ways: nothing re-renders when you change it, and nothing tells you when it changed. A useful test is to ask whether the rendered output would differ if the value changed. If it would, the value is state and you picked the wrong tool.

2.6.7 Dealing with Stale State

Every function inside a component — event handlers, effects, callbacks handed to setTimeout — closes over the props and state of the render that created it. That is not a bug; it is what makes a render a consistent snapshot. It surprises people when a callback fires much later and reports a value the user has already changed.

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>

<script type="importmap">{"imports":{"react":"https://esm.sh/react@19.3.0?dev",

"react-dom/client":"https://esm.sh/react-dom@19.3.0/client?dev"}}</script>

<div id="root"></div><script type="text/babel" data-type="module">

import React, { useRef, useState, useEffect } from 'react';

import { createRoot } from 'react-dom/client';

function Example() {

const [count, setCount] = useState(0), [msg, setMsg] = useState('');

const latest = useRef(count);

useEffect(() => { latest.current = count; }); // synced after every commit

const report = () =>

setTimeout(() => setMsg(`state said ${count}, ref says ${latest.current}`), 500);

return <div style={{font: '16px system-ui'}}><p>Clicked {count} times</p>

<button onClick={() => setCount(c => c + 1)}>Click me</button>{' '}

<button onClick={report}>Report in 0.5 s</button>

<p><b>{msg}</b></p></div>;

}

createRoot(document.getElementById('root')).render(<Example />);

const b = i => document.querySelectorAll('button')[i];

setTimeout(() => { b(1).click(); b(0).click(); b(0).click(); b(0).click(); }, 400);

</script>

Listing 2.48: A delayed callback sees old state, but the latest ref Figure 2.27: Browser output of Listing 2.48The script presses "Report" while the counter is still 0, then clicks three times. Half a second later the callback runs with count frozen at 0 from the render that created it, while latest.current followed along and returns 3.Which one you want depends on the question. For "what was on screen when the user pressed the button" — an analytics event, an undo snapshot — the closure is correct and a ref would be a bug. For "what is true right now" — a poll that must not restart, a handler registered once for the life of a subscription — keep the value in a ref updated by an effect, as above.Two better answers exist for common cases. Use the updater form setCount(c => c + 1) when you only need the latest value to compute the next one; React passes it in, so no closure is involved. For an Effect that must read fresh values without re-running, use useEffectEvent (Section 2.4.7), which spares you a shadow copy of your own state.

2.6.8 useImperativeHandle

Forwarding a ref hands the parent the raw DOM node with every method on it, including remove() and innerHTML. useImperativeHandle(ref, createHandle, dependencies?) replaces that node with an object you define, so the parent gets exactly the operations you meant to publish and nothing else.

function MyInput({ ref, ...rest }) {

const inputRef = useRef(null);

useImperativeHandle(ref, () => ({

focus: () => inputRef.current.focus(),

clear: () => { inputRef.current.value = ''; }

}), []);

return <input ref={inputRef} {...rest} />;

}

function Form() {

const box = useRef(null); // box.current is the handle, not the <input>

return <><MyInput ref={box} placeholder="search" />

<button onClick={() => { box.current.clear(); box.current.focus(); }}>Reset</button></>;

}

Listing 2.49: Publishing a two-method handle instead of the input node The third argument works like the dependency array of useEffect: with [] the handle is built once, and with dependencies listed React rebuilds it when they change. Leaving the array out rebuilds it every render, making the handle's identity unstable for any effect that depends on it.The methods need not mirror the DOM. A Post component can expose one scrollAndFocusAddComment() that drives two of its own children — a coarse-grained action rather than a leaked node. The handle is an API, so name it after intent. React 19 made ref an ordinary prop, so the hook no longer needs forwardRef around the component; all it ever wanted is the ref itself, whichever route delivered it.Before reaching for it, check that the job is genuinely imperative. Scrolling, focusing, selecting text, playing media and starting an animation cannot be expressed as values. Everything else can: a dialog a parent opens with modal.current.open() should have been <Modal isOpen={open} />, because the declarative version survives a re-render, a route change and server rendering.