A Box That Survives Renders
You have met two kinds of value in a component. A plain variable is reset every render. State survives renders but re-renders the component whenever it changes. useRef gives you the third combination: a value that survives renders and does not re-render anything when it changes.
Call useRef(initialValue) and you get back an object with a single property, current. React hands you the same object on every render, so anything you put in current is still there next time. Assigning to it is a plain assignment — no setter, no scheduling, nothing tells React about it.
That last point is the whole design. A ref is deliberately outside React's data flow. It is for values your component needs to remember but that the screen does not depend on: the id returned by setInterval so you can clear it later, whether you have already sent an analytics event, the previous value of a prop, or a handle to a library instance you created.
The counterpart is the rule you must respect. Because changing a ref does not trigger a render, anything you display from a ref can be out of date. If a value appears in your JSX, it belongs in state.
import { useRef, useState } from 'react';
function Stopwatch() {
const [seconds, setSeconds] = useState(0); // shown on screen -> state
const intervalId = useRef(null); // just bookkeeping -> ref
function start() {
if (intervalId.current !== null) return; // already running
intervalId.current = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
}
function stop() {
clearInterval(intervalId.current);
intervalId.current = null;
}
return (
<div>
<p>{seconds}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
// A plain `let id` would be lost on every re-render — and this component
// re-renders every second, so Stop would never find the interval to clear. useRefis not only for the DOM, despite the name. Roughly half its real-world use is storing ordinary values that must persist without causing renders.
Reaching a Real DOM Element
The other half of useRef is getting hold of an actual DOM node. Pass a ref to an element's ref attribute and React sets ref.current to that node once the element is on the page. From then on you can call ordinary DOM methods on it.
This is React's official escape hatch, and it exists because a handful of browser capabilities have no declarative equivalent. You cannot express focus this field or scroll this container to the bottom or how many pixels tall is this element as a piece of returned UI. For those, you need the node.
Timing is the thing to get right. On the very first render the element does not exist yet, so ref.current is null. React fills it in after rendering, which means the earliest safe place to use it is an effect or an event handler — both run after the DOM exists. Trying to call inputRef.current.focus() in the component body throws Cannot read properties of null, and that error is nearly always this mistake.
Keep the list of legitimate uses short. Focus, scroll, measurement, media playback, and handing a node to a non-React library that wants to draw into it. Anything else — showing and hiding, changing text, toggling classes — should be done by changing state and letting React update the DOM. Writing to the DOM directly while React also owns that element sets up a fight that React eventually wins, by overwriting your change on the next render.
import { useRef, useEffect } from 'react';
function MessageBox({ messages, onSend }) {
const inputRef = useRef(null);
const listRef = useRef(null);
// Focus the input once, after the DOM exists
useEffect(() => {
inputRef.current.focus();
}, []);
// Scroll to the newest message whenever the list grows
useEffect(() => {
const el = listRef.current;
el.scrollTop = el.scrollHeight;
}, [messages.length]);
function handleSend() {
onSend(inputRef.current.value);
inputRef.current.value = '';
inputRef.current.focus(); // put the cursor back for the next message
}
return (
<div>
<div ref={listRef} className="messages">
{messages.map(m => <p key={m.id}>{m.text}</p>)}
</div>
<input ref={inputRef} />
<button onClick={handleSend}>Send</button>
</div>
);
}
// WRONG: the element does not exist yet during the first render
// function Bad() {
// const ref = useRef(null);
// ref.current.focus(); // TypeError: ref.current is null
// return <input ref={ref} />;
// } inputRef.current.focus()— move the cursor into a fieldel.scrollIntoView()or settingscrollTop— scrollingel.getBoundingClientRect()— measuring size and positionvideoRef.current.play()— controlling mediadialogRef.current.showModal()— native dialogs- Handing a container node to a chart or map library
- Attaching a ref to one of your own components does not automatically give you the DOM node inside it — a component has to be written to accept and pass a ref along, and how that is written has changed between React versions. If you simply need an element, put the ref on a real HTML tag.
Remembering the Previous Value
A pattern worth knowing on its own: refs let a component remember what something was on the last render. Store the current value in a ref inside an effect, and because the effect runs after rendering, the ref still holds the previous value during the render itself.
This is how you build features that depend on a change rather than a value — highlighting a price that just went up, animating a number that moved, or logging only when a filter actually changed. Doing the same thing with state would cause an extra render every time and can easily loop.
The same idea covers one-shot flags. A ref holding hasLoggedView lets you guarantee something happens only once, without adding state that nothing displays.
import { useRef, useEffect, useState } from 'react';
function PriceTicker({ price }) {
const previous = useRef(price);
// During render, previous.current is still the value from last time
const direction =
price > previous.current ? 'up' : price < previous.current ? 'down' : 'same';
useEffect(() => {
previous.current = price; // update AFTER render, ready for next time
}, [price]);
return (
<p className={direction}>
₹{price} {direction === 'up' ? '▲' : direction === 'down' ? '▼' : ''}
</p>
);
}
// A one-shot flag, with no state and no extra render
function Article({ id }) {
const logged = useRef(false);
useEffect(() => {
if (logged.current) return;
logged.current = true;
sendAnalytics('article_view', { id });
}, [id]);
return <article>…</article>;
} - Reading a ref during render, as the price example does, is only safe because the value is intentionally the previous one. As a general habit, avoid reading or writing
ref.currentduring render — you get no re-render when it changes, so the screen and the ref can silently disagree.
Ref or State? A Short Decision
The question to ask is only this: does the screen need to change when this value changes? If yes, it is state. If no, a ref will do, and will save a render.
The failure mode is using a ref for something visible. It looks tempting — you avoid a re-render — but nothing tells React to update, so the page keeps showing the old value until some unrelated change forces a render, at which point the new value appears as if by accident. That intermittent, delayed-by-one behaviour is the signature of a value that should have been state.
The mirror-image mistake is putting bookkeeping into state. Storing a timer id with useState re-renders the component every time you start or stop the timer, for a value nobody looks at. It works, it is just wasteful and noisier to read.
// BROKEN: the count is displayed, so it must be state
function Counter() {
const count = useRef(0);
return (
<button onClick={() => { count.current += 1; }}>
Clicked {count.current} times {/* stays at 0 on screen */}
</button>
);
}
// CORRECT
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>Clicked {count} times</button>;
} - Shown on screen — state
- Used in a condition that decides what is rendered — state
- Timer or interval ids — ref
- The previous value of a prop or state — ref
- A one-time flag such as already initialised — ref
- A DOM node — ref
- An instance of an outside library — ref
- Refs are an escape hatch, and escape hatches should be rare. If a component has four refs and manipulates the DOM in several handlers, that is usually a sign the UI is being driven imperatively and would be simpler expressed as state.
Callback Refs, and What Goes Wrong
useRef works when you know in advance that there is exactly one element to hold on to. Sometimes you do not — a list of rows where any one of them might need to be scrolled into view, for instance. For that, the ref attribute also accepts a function. React calls it with the element when the element is added to the page, and calls it again with null when the element is removed, which is your chance to forget about it.
Storing those nodes in a Map held inside a normal ref gives you a lookup from item id to DOM node, and lets you scroll to any row on demand. It is a small amount of code for a genuinely useful capability, and it is the standard way to handle refs for a variable number of elements.
The rest of this section is the list of ref mistakes worth recognising quickly, because the error messages are not always informative. The most common by a distance is the first one: using ref.current too early, before React has attached anything to it.
import { useRef } from 'react';
function OrderList({ orders }) {
const rowRefs = useRef(new Map());
function scrollTo(id) {
rowRefs.current.get(id)?.scrollIntoView({ behavior: 'smooth' });
}
return (
<>
<button onClick={() => scrollTo(orders.at(-1).id)}>Jump to latest</button>
<ul>
{orders.map(order => (
<li
key={order.id}
ref={node => {
if (node) rowRefs.current.set(order.id, node); // attached
else rowRefs.current.delete(order.id); // removed
}}
>
{order.id} — ₹{order.total}
</li>
))}
</ul>
</>
);
} Cannot read properties of null— you usedref.currentduring render, before the element existed- The ref is
nullin an effect — the element is behind a condition and is not currently rendered - The DOM change you made disappears — React re-rendered and overwrote it; use state instead
- A ref on your own component gives
null— the component has to be written to forward it - The screen does not update — the value is displayed, so it should have been state
- Forgetting to clear a timer whose id you stored — always clear it in an effect's cleanup
- Optional chaining is worth a habit here:
ref.current?.focus()does nothing instead of throwing when the element is not on the page. It is not a fix for using a ref at the wrong time, but it keeps a conditional element from crashing the component.
