useRef: The Mutable Box
What you'll learn
- What
useRefactually returns, a plain, stable box, and why its identity never changes - Why mutating
.currentnever causes a re-render, and when that is exactly what you want - The three everyday use cases: DOM access, invisible mutable values, and the "latest value" pattern
- What refs are NOT for, plus a ref-vs-state decision table
forwardRef: passing a ref through your component to a DOM node inside it
Sometimes you need to remember something that has nothing to do with what is on screen: a timer ID, a DOM node, the room the user is in right now. State is the wrong tool for these, changing state re-renders, and you don't want a re-render. React's answer is almost disappointingly simple: a plain object with one property.
The box
useRef(0) returns { current: 0 }. That is the whole API. Two facts make it powerful:
- It is the same object on every render. Render once or a thousand times, you get the identical box back.
- Mutating it tells React nothing. Writing
ref.current = 5causes no re-render, ever.
You can watch both facts misbehave in one tiny component:
import { useRef, useState } from 'react';
export default function RefVsState() {
const ref = useRef(0);
const [count, setCount] = useState(0);
function bumpRef() {
ref.current += 1;
console.log('ref.current is now', ref.current);
}
return (
<div>
<p>state count: {count}</p>
<p>ref count (watch it go stale!): {ref.current}</p>
<button onClick={bumpRef}>Increment the ref</button>
<button onClick={() => setCount(count + 1)}>Increment state</button>
</div>
);
}
What happens:
- Click "Increment the ref" three times. The console prints 1, 2, 3. The screen still shows
ref count: 0, no render happened, so the paragraph is stale. - Click "Increment state". React re-renders, the paragraph reads the box again, and it jumps straight to 3, catching up all at once.
- The lesson: the screen updates when React renders, and mutating a ref does not render.
(Reading ref.current in JSX like this is normally a mistake, precisely because it goes stale. We do it here only to watch it happen.)
Pseudocode model, not real source:
// Per component instance, React keeps one storage cell per hook call.function useRef(initialValue) {const cell = getCurrentHookCell();if (cell.box === undefined) {cell.box = { current: initialValue }; // created ONCE}return cell.box; // the SAME object, every single render}
Jargon: "escape hatch". A sanctioned way to step outside React's normal rules when you genuinely need to. Refs are the escape hatch out of the reactive system (state → render → UI): a place React deliberately does not watch.
Use case 1: grabbing DOM nodes
The most familiar use: put the box in a ref attribute, and React fills it with the real DOM node after commit:
import { useRef } from 'react';
export default function SearchForm() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<div>
<input ref={inputRef} placeholder="Type to search…" />
<button onClick={handleClick}>Focus the input</button>
</div>
);
}
What happens:
- First render:
inputRef.currentisnull, the DOM node does not exist yet. - During commit, React creates the real
<input>and assigns it toinputRef.currentbecause of theref={inputRef}attribute. - By the time your click handler runs, the box holds the actual DOM node, and
.focus()works. - When the input is removed from the page, React sets the box back to
null.
The same box works for measuring:
import { useRef, useState } from 'react';
export default function Measurer() {
const boxRef = useRef(null);
const [size, setSize] = useState(null);
function measure() {
const rect = boxRef.current.getBoundingClientRect();
setSize({ width: Math.round(rect.width), height: Math.round(rect.height) });
}
return (
<div>
<div ref={boxRef} style={{ width: '50%', height: 120, background: '#def' }}>
Resize the window, then press the button
</div>
<button onClick={measure}>Measure</button>
{size && <p>{size.width} × {size.height} px</p>}
</div>
);
}
Jargon: "callback ref". A function passed to the
refattribute instead of auseRefbox. React calls it with the DOM node when the node is attached, and withnullwhen it is removed.
Why use a function instead of a box? Because the two calls, node on attach, null on detach, are exactly mount and unmount moments for that DOM node. A box can only be read later; a callback ref tells you the instant the node appears:
import { useCallback, useState } from 'react';
export default function MeasureOnMount() {
const [height, setHeight] = useState(0);
const measuredRef = useCallback((node) => {
if (node !== null) {
// The exact moment the node exists in the DOM:
setHeight(node.getBoundingClientRect().height);
}
// When the node is removed, React calls this again with null.
}, []);
return (
<div>
<p ref={measuredRef} style={{ lineHeight: 1.5 }}>
I get measured the instant I exist — no button, no effect.
</p>
<p>That paragraph is {Math.round(height)}px tall.</p>
</div>
);
}
(Newer React versions also let a callback ref return a cleanup function instead of receiving the null call, same idea, tidier syntax.)
Use case 2: values that should not re-render
An interval ID is a number the browser gives you. The UI never displays it; you only need it later to cancel. That makes it ref material, not state:
import { useEffect, useRef, useState } from 'react';
export default function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null);
function start() {
if (intervalRef.current !== null) return; // already running
intervalRef.current = setInterval(() => {
setSeconds((s) => s + 1);
}, 1000);
}
function stop() {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
useEffect(() => {
return () => clearInterval(intervalRef.current); // cleanup on unmount
}, []);
return (
<div>
<p>{seconds}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
<button onClick={() => setSeconds(0)}>Reset</button>
</div>
);
}
What happens:
start()stores the interval ID inintervalRef.current. The ID is bookkeeping, invisible, so it must not be state.- Every second, the interval fires
setSeconds(s => s + 1). That is state, because the UI shows it. stop()reads the ID out of the box and cancels it. No render is needed for any of this bookkeeping.- The effect's cleanup uses the same box if the component unmounts while running.
Notice the division of labor: seconds is state because the screen shows it; the interval ID is a ref because only your code needs it. And beware the classic bug this avoids: a plain let intervalId declared in the component body is reset on every render, the box survives; a local variable does not.
Use case 3: the "latest value" pattern
Async callbacks close over the values from the render in which they were created. If state changes before the callback fires, the callback still sees the old value. A ref fixes it, because every render can write into the same box that every callback reads:
import { useRef, useState } from 'react';
function sendMessage(roomId, text) {
console.log(`Sending "${text}" to room ${roomId}`);
}
export default function ChatRoom() {
const [roomId, setRoomId] = useState('general');
const [text, setText] = useState('');
const roomRef = useRef(roomId);
roomRef.current = roomId; // keep the box in sync during render
function handleSend() {
const draft = text;
setTimeout(() => {
// With plain `roomId` here, this closure would send to the OLD room.
// The ref always holds the room from the LATEST render.
sendMessage(roomRef.current, draft);
}, 3000);
}
return (
<div>
<select value={roomId} onChange={(e) => setRoomId(e.target.value)}>
<option value="general">#general</option>
<option value="random">#random</option>
</select>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={handleSend}>Send (3s delay)</button>
</div>
);
}
What happens:
- You select
#general, type "hi", and click Send. The timeout callback closes over the render whereroomIdwas'general'. - Within 3 seconds, you switch to
#random. State updates and the component re-renders, but the old callback still holds'general'. - During that newest render,
roomRef.current = roomIdre-synced the box to'random'. - The timer fires:
roomRef.currentreads'random'. The message goes to the room you are looking at now, not the room you were in when you clicked.
Writing roomRef.current = roomId during render is the one sanctioned render-time ref write: it only mirrors state, and you never read the box during render. It is the standard way to give async code access to current values.
Jargon: "stale closure". A function that captured a value from an older render and keeps seeing that old value even after newer renders exist. Refs cure it because all renders write into one shared box.
What refs are NOT for
- Anything the UI displays. If a value changes and the screen must change with it, that value is state. Store a user's typed name in a ref and the screen will never show it.
- Replacing state to "avoid re-renders". You will not get a faster app; you will get a UI that is silently out of date.
- Reading or writing
.currentduring render (other than the mirror pattern above). Render output should be predictable from props and state alone; a ref is an invisible extra input.
Ref vs state
| Question | useState | useRef |
|---|---|---|
| Does changing it re-render? | Yes | No |
| Same identity every render? | New value per render | One box, forever |
| Safe to read during render? | Yes | No (except one-time init) |
| The UI depends on the value? | Must be state | Never |
| Typical contents | Data the user sees | DOM nodes, timer IDs, latest-value mirrors |
forwardRef: passing the box through
A parent that wants to focus your component's inner <input> has a problem: ref is not a normal prop, React intercepts it. forwardRef lets your component receive a ref and hand it down to a real node inside:
import { forwardRef, useRef } from 'react';
const FancyInput = forwardRef(function FancyInput(props, ref) {
return <input ref={ref} className="fancy" {...props} />;
});
export default function LoginForm() {
const nameRef = useRef(null);
return (
<div>
<FancyInput ref={nameRef} placeholder="Your name" />
<button onClick={() => nameRef.current.focus()}>Focus name</button>
</div>
);
}
What happens:
LoginFormcreates a box and passes it asreftoFancyInput.- Because
FancyInputis wrapped inforwardRef, React delivers that box as the second argument, andFancyInputattaches it to its inner<input>. - The parent's box now points at the real DOM node inside the child,
nameRef.current.focus()works from outside.
(Newer React versions also let function components receive ref as a regular prop without forwardRef; you will see both styles in the wild.)
Diagram
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception: setting
ref.currenttriggers a re-render. Reality: nothing happens, React does not watch the box. The screen updates only when something else renders. - Misconception: refs are only for DOM elements. Reality: any value you want to keep without re-rendering, timer IDs, previous values, websocket handles.
- Misconception:
useRef(initial)re-initializes the box each render. Reality: the box is created once; the argument is ignored after the first render. - Misconception: you can read
.currentin JSX like state. Reality: it will be stale the moment you mutate without rendering, read refs in handlers and effects. - Misconception: a ref is "state that doesn't re-render". Reality: it is outside the reactive system entirely; React neither tracks it nor diffs it.
Why it works this way
- A stable box is possible because React stores it on the component's hook cell, one slot per hook call, kept across renders.
- React cannot know when you write
.current, it is a plain property on a plain object, and intercepting that would require wrapping every box in a proxy. Refs are deliberately the cheap, untracked path. - Handlers and effects always run after render, so giving them a shared mutable box never conflicts with render's purity.
- DOM access has to go through a box because the node does not exist until after commit, you cannot receive it as a normal return value.
Try it yourself
- In
RefVsState, click the ref button five times, then the state button: the ref paragraph jumps straight to 5. You have now seen staleness. - In the Stopwatch, replace
intervalRefwith a plainlet intervalId = nullin the component body. Start, then try to stop, you can't; every render resets the variable. - In the chat demo, replace
roomRef.currentwith theroomIdstate variable inside the timeout. Send, switch rooms quickly, the message goes to the wrong (old) room. - Give the callback-ref example a button that toggles the paragraph on and off; watch the callback fire with the node, then with
null, on each toggle.
Recap
useRef(x)returns{ current: x }, the same object on every render.- Mutating
.currentnever re-renders; refs live outside the reactive system by design. - DOM access: put the box in the
refattribute; React fills it after commit and empties it on unmount. Callback refs hand you the mount/unmount moments directly. - Invisible bookkeeping, interval IDs, sockets, subscriptions, belongs in refs, not state.
- Latest-value pattern: mirror state into a ref during render so async callbacks read the current value, not a stale closure.
- If the UI shows it, it is state. If only your code needs it, it is a ref.
forwardRefpasses a ref through your component to a DOM node inside it.