MENU
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>
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.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>
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>;
}
const MyInput = forwardRef(function MyInput({ label, ...rest }, ref) {
return <label>{label} <input ref={ref} {...rest} /></label>;
});
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>
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.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>
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.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>;
}
function usePrevious(value) {
const ref = useRef(undefined);
useEffect(() => { ref.current = value; }); // runs after every commit
return ref.current; // the value from the render before
}
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>
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></>;
}