What you'll learn
Quick Answer
useRef returns a plain object with a single current property that stays the same object across every render. Writing to ref.current does not trigger a re-render, so refs are for values React does not need to draw: DOM nodes, timer and interval IDs, previous values, and flags. If changing the value should change what the user sees, it belongs in state, not in a ref.
The gotcha: updating a ref does not re-render
Here is the first program almost everyone writes with useRef, usually while trying to avoid a re-render:
function Counter() {
const countRef = useRef(0);
return (
<button onClick={() => { countRef.current += 1; }}>
Clicked {countRef.current} times
</button>
);
}Click it ten times. The label still says zero. Then type a character somewhere else on the page that causes a re-render, and the button suddenly jumps to ten. The value was updating the whole time; React simply never redrew the button, because nothing told it to.
That is the single sentence to remember: mutating ref.current is invisible to React. There is no subscription, no queue, no scheduling. It is an ordinary JavaScript object property.
What useRef actually gives you is a stable box. On the first render React creates { current: initialValue }, and on every render after that it hands back the exact same object. A plain local variable would be recreated and reset on each render; a ref survives. That is all it is.
So the decision rule is short. Ask whether changing this value should change what is on screen. If yes, it is state. If no, and you just need something to persist between renders, it is a ref. A search box's text is state because the input displays it. The ID returned by setInterval is a ref, because the user never sees it and clearing it should not repaint anything.
The other half of the rule: do not read or write ref.current during rendering. Rendering must be pure, and React may render a component without committing that result. Touch refs in effects and in event handlers only.
Refs for DOM nodes
The most common use of useRef is getting hold of a real DOM element, because some things simply have no declarative equivalent: focusing an input, playing a video, measuring a box, or scrolling an element into view.
function SearchBox() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus();
}, []);
return <input ref={inputRef} placeholder="Search courses" />;
}The timing here is what catches people. During the first render, inputRef.current is still null, because the element does not exist yet. React sets it while committing the DOM, which happens before effects run. So inside useEffect the node is there; inside the render body it is not.
function SearchBox() {
const inputRef = useRef(null);
inputRef.current.focus(); // TypeError: Cannot read properties of null (reading 'focus')
return <input ref={inputRef} />;
}The same null appears when the element is conditionally rendered. If the input only shows when isOpen is true, then any code that runs while it is hidden must guard: inputRef.current?.focus(). React also sets current back to null when the element unmounts, so a timer that fires after a modal closes will otherwise crash.
For measuring, a callback ref is often better than an object ref, because it runs at the moment the node is attached:
function Banner() {
const [height, setHeight] = useState(0);
const measure = useCallback((node) => {
if (node) setHeight(node.getBoundingClientRect().height);
}, []);
return <div ref={measure}>Placement drive: 12 companies this month</div>;
}Note the guard: React calls a callback ref with the node on mount and with null on unmount.
Refs as an instance variable
The second use of useRef has nothing to do with the DOM. It is a place to keep a value that must survive re-renders but must never cause one. In a class component you would have written this.timerId; a ref is the function-component version of that.
Timers are the classic case. If you store the interval ID in a normal variable, the next render creates a fresh variable and the old ID is gone, so you can no longer clear that interval and every further call to start leaks another one:
function Timer() {
const [seconds, setSeconds] = useState(0);
const idRef = useRef(null);
function start() {
if (idRef.current) return; // already running
idRef.current = setInterval(() => {
setSeconds((s) => s + 1); // functional update, no stale value
}, 1000);
}
function stop() {
clearInterval(idRef.current);
idRef.current = null;
}
useEffect(() => stop, []); // clear on unmount
return <p onClick={start}>{seconds}s</p>;
}The setSeconds((s) => s + 1) form matters. Writing setSeconds(seconds + 1) inside the interval captures seconds from the render where the interval was created, so the counter sticks at one. That is the stale closure problem, and refs are also the standard escape hatch for it when you need the latest value of a prop or callback inside a long-lived subscription:
const latest = useRef(onTick);
useEffect(() => { latest.current = onTick; }); // after every render
useEffect(() => {
const id = setInterval(() => latest.current(), 1000);
return () => clearInterval(id);
}, []); // set up onceThe same trick powers the small usePrevious helper everyone writes eventually. The ref is updated in an effect, so during render it still holds the value from last time.
forwardRef and passing refs to your own components
Put a ref on your own component and, in most projects, it will not work:
function TextField(props) {
return <input className="field" {...props} />;
}
<TextField ref={inputRef} /> // React 18 and earlier: inputRef.current stays nullFor most of React's history ref was not an ordinary prop. React stripped it out before your function received props, so nothing forwarded it to the real <input>. The long-standing fix is forwardRef, which gives your component a second parameter:
const TextField = forwardRef(function TextField(props, ref) {
return <input ref={ref} className="field" {...props} />;
});Now inputRef.current is the DOM input and the parent can focus it. Newer versions of React allow function components to receive ref as a normal prop, which makes forwardRef unnecessary there. On those versions the first example can even start working by itself, because ref now arrives in props and {...props} spreads it onto the input. The forwardRef wrapper still works either way and is what you will see in most existing codebases and tutorials, so check which version your project is on before assuming either behaviour.
Sometimes handing the parent your raw DOM node is too much access. It can then change classes, styles or values behind your back. useImperativeHandle lets you publish a small, deliberate API instead:
const OtpInput = forwardRef(function OtpInput(props, ref) {
const inner = useRef(null);
useImperativeHandle(ref, () => ({
focus: () => inner.current.focus(),
clear: () => { inner.current.value = ""; }
}), []);
return <input ref={inner} maxLength={6} />;
});Use this sparingly. Every imperative method you expose is a piece of behaviour that props and state cannot describe, which makes the component harder to reason about and to test.
Four ways refs are misused
Using a ref to dodge re-renders for something displayed. This is the big one. Someone notices a component renders too often, moves a value into a ref, and the counter stops updating until an unrelated click forces a repaint. The value is displayed, so it is state. If re-rendering is genuinely the problem, profile first and fix the actual cause.
Reading or writing refs during render. A component body must be pure. Code such as ref.current = props.value at the top level of a component works by accident today and misbehaves when React renders a component twice, which it deliberately does in development StrictMode. Move it into an effect or a handler.
Reaching into the DOM for things React already owns. Setting ref.current.style.display = "none" or ref.current.textContent = "Saved" puts you in a fight with React: the next render overwrites your change, seemingly at random. Anything React renders should be controlled by props and state. Refs are for things React does not manage, such as focus, scroll position, media playback and measurement.
Calling useRef in a loop. Hooks must run in the same order on every render, so you cannot create one ref per list item with a loop. Use a single ref holding a Map and a callback ref:
const nodes = useRef(new Map());
{students.map((s) => (
<li key={s.id} ref={(el) => {
if (el) nodes.current.set(s.id, el);
else nodes.current.delete(s.id);
}}>{s.name}</li>
))}Then nodes.current.get(id)?.scrollIntoView() gives you any row on demand, with the entry removed automatically when the row unmounts.
