useRef for non-rendering values
Learn React's useRef hook for values that outlive a render but stay out of the UI, from debounce timers to direct DOM access.
Picture a search input that waits until you stop typing before it hits the server, a debounce. Each keystroke must cancel the timer the previous keystroke set and start a fresh one, which means calling clearTimeout(id) with the exact ID you got back when you scheduled it. So the component has to remember the pending setTimeout ID between keystrokes.
The tools you already have both fail at this. A plain let timerId in the component body resets to undefined on every render, so it loses the ID the instant a keystroke re-renders. useState survives renders, but its setter schedules a render, so storing a timer ID would re-render the component several times a second, painting nothing new each time, since the user never sees the ID.
What you need is a third kind of memory: one that survives across renders like state, but is invisible to React like a plain variable. That’s useRef.
What useRef returns: a stable, mutable box
Section titled “What useRef returns: a stable, mutable box”A ref is a box with one slot, current, and you put whatever you want in it.
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
timerRef.current = setTimeout(runSearch, 300);clearTimeout(timerRef.current ?? undefined);useRef(initialValue) returns an object that looks like { current: initialValue }. Three facts about it carry the whole lesson.
The box is stable. You get the same { current } object on every render, for the life of the component. State works the other way: useState hands you a fresh snapshot of the value each render. With a ref, the box is the fixed thing; only what’s inside it changes.
.current is freely mutable. It’s a plain object property. Read it, assign to it, or mutate the thing it points at. There’s no setter and no ceremony: timerRef.current = id is just an assignment.
Writing .current does not re-render. This is the property the rest of the lesson depends on. React does not watch the box: no subscription, no Object.is check, no reconciliation. You change .current and React has no idea anything happened. That’s what fixes the debounce, because you can stash a timer ID a dozen times a second and the component sits perfectly still.
You can see that last fact in render counts. The tree below is a Parent holding a Widget. Toggle the implementation: one version stores the Widget’s value in state, the other stores it in a ref. Click the trigger and watch which boxes light up.
The state variant ticks the badge; the ref variant does nothing, every time. That silence is the feature.
One typing note. When a ref starts out empty and gets filled later, annotate the slot as nullable: useRef<HTMLInputElement | null>(null). A DOM node doesn’t exist until React commits it, and a timer ID is null until the first keystroke. That’s the same instinct behind useState<User | null>(null). When the initial value already pins the type, like useRef(0), inference handles it and you write nothing.
State or ref: does the JSX read the value?
Section titled “State or ref: does the JSX read the value?”State is for values the render output reads. Refs are for values only handlers and effects read. Ask one question of any value: does the JSX read it? If yes, it’s state; if no, it’s a ref. That question settles almost every case, and it heads off the two opposite mistakes beginners make.
The common one is state in disguise: a useState whose value never appears in the JSX, like a timer ID for debouncing, a scroll offset read only inside a click handler, or a “has the user interacted yet” flag checked only by an effect. Each setState fires a render that paints nothing new, because no output depends on the value. To catch it, trace the state variable through the component: if it appears in no JSX expression, it wanted to be a ref.
The opposite mistake is reaching for a ref where state belongs: you read a value the UI should reflect out of .current, change .current, and the screen stays stale, because changing a ref is exactly what doesn’t trigger a render. We’ll return to this one with DOM refs.
For each value, ask the one question — does the rendered JSX read it? Yes means state, no means ref. Drag each item into the bucket it belongs to, then press Check.
setTimeout ID used to debounce that fieldScroll to top button is clickedIntersectionObserver instance the component ownsInstance refs: memory across renders
Section titled “Instance refs: memory across renders”The first use is the purer one: a ref as plain memory. No DOM, no element, just a box for a value the component carries across renders while the JSX ignores it. This is the answer to the debounce we opened with.
const SearchInput = ({ onSearch }: { onSearch: (query: string) => void }) => { const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleChange = (event: ChangeEvent<HTMLInputElement>) => { const query = event.target.value; clearTimeout(timerRef.current ?? undefined); timerRef.current = setTimeout(() => onSearch(query), 300); };
return <input type="search" onChange={handleChange} placeholder="Search…" />;};The box. timerRef holds the pending timer ID between renders. It starts null, since no search is pending. Writing to it later never re-renders, which is why it’s a ref and not state.
const SearchInput = ({ onSearch }: { onSearch: (query: string) => void }) => { const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleChange = (event: ChangeEvent<HTMLInputElement>) => { const query = event.target.value; clearTimeout(timerRef.current ?? undefined); timerRef.current = setTimeout(() => onSearch(query), 300); };
return <input type="search" onChange={handleChange} placeholder="Search…" />;};Cancel the previous timer. Each keystroke clears the timer the last keystroke scheduled, reading the ID straight from .current. (Passing undefined to clearTimeout is a harmless no-op on the first keystroke, when .current is still null.)
const SearchInput = ({ onSearch }: { onSearch: (query: string) => void }) => { const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleChange = (event: ChangeEvent<HTMLInputElement>) => { const query = event.target.value; clearTimeout(timerRef.current ?? undefined); timerRef.current = setTimeout(() => onSearch(query), 300); };
return <input type="search" onChange={handleChange} placeholder="Search…" />;};Schedule a fresh one and remember it. setTimeout returns a new ID, which we store back in .current so the next keystroke can cancel it. The search fires only once typing pauses for 300ms.
const SearchInput = ({ onSearch }: { onSearch: (query: string) => void }) => { const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleChange = (event: ChangeEvent<HTMLInputElement>) => { const query = event.target.value; clearTimeout(timerRef.current ?? undefined); timerRef.current = setTimeout(() => onSearch(query), 300); };
return <input type="search" onChange={handleChange} placeholder="Search…" />;};The open thread. Every read and write of .current happens inside the handler, never in the render body. A real app would also clear the pending timer if the component unmounts mid-type, so a stray search doesn’t fire into a component that’s gone; that cleanup lives in an effect, which the next chapter teaches.
The ref is the memory that lets one keystroke reach back and cancel the previous one’s work. Every touch of .current sits inside the handler, never in the render body, a rule we’ll state explicitly later.
Two more instance-ref uses show up often enough to name, though you won’t write them today.
The first is the previous value of a prop or state, kept so this render can compare against the last: const prevValue = useRef(value), updated in an effect after each render so the next render still sees the old one. Teams often wrap it in a reusable usePrevious hook.
The second is a render counter for debugging: const renders = useRef(0) with renders.current++ to count how many times a component has rendered. A throwaway diagnostic, but a clean picture of the box: pure memory, zero UI.
None of these touch the DOM. They’re plain JavaScript values React keeps alive across renders, parked in a box it never inspects.
Element refs and commit timing
Section titled “Element refs and commit timing”The second use does touch the DOM, and it comes with a timing subtlety that trips up almost everyone the first time.
You attach a ref to an element with the ref prop, <input ref={inputRef} />, and React puts the live DOM node into inputRef.current. The catch is when: React assigns the node only after it commits the DOM to the screen. Commit is the same step you met in the render model. Before it there’s no node to point at, so the ref is null; after it the node exists, and React fills the box.
The figure below walks that cycle one frame at a time.
The cycle has one lesson, stated from either side. Reading a DOM ref in the render body is wrong: the node is assigned only after the body runs, so the ref is null on the first render and stale on every render after. Reading it in an effect or a handler is right, because those run after commit, when the box holds the live node. So read a DOM ref only in handlers and effects, never during render.
Once you have a live node, what do you do with it? Refs into the DOM exist because the DOM can do things that React’s props and state don’t expose. Four cases cover almost everything.
- Focus management,
inputRef.current?.focus(). There’s no prop for focus; it’s an action you tell the element to take. - Measurement,
boxRef.current?.getBoundingClientRect()to read an element’s real size and position after layout. - Imperative media and element APIs,
videoRef.current?.play()from a Play button’s handler. Playing a video is a command, not state. - Scrolling,
listRef.current?.scrollTo({ top: 0 })to jump a scroll container to the top.
Every read uses optional chaining, ?., because the node can be null: not yet committed, or conditionally rendered and currently absent. boxRef.current?.focus() quietly does nothing when there’s no node instead of throwing. This is the same value != null discipline the course applies everywhere: a ref slot might be empty, so you guard the read.
All four are imperative: you tell the element to do something rather than describe what it should be. That distinction draws the line for the second beginner mistake.
A ref is for capabilities the DOM exposes that React doesn’t. It is not for re-implementing what props and state already own. The clearest case is reading a controlled input’s text out of inputRef.current.value.
const handleSubmit = () => { // the value is already in state — why ask the DOM? const text = inputRef.current?.value ?? ''; onSubmit(text);};Defeats the controlled pattern. React already put the value in state. Reaching into the DOM to read it back goes around the source of truth, and it desyncs the moment state and the DOM disagree.
const [text, setText] = useState('');
const handleSubmit = () => { onSubmit(text);};Reads the source of truth. text is the state that drives the input, so it’s already current: no node, no ?., no chance of disagreeing with the DOM. The ref earns its place only when the DOM offers something state can’t, like focus, measurement, media, or scroll.
The rule in one line: reach for a ref only when the DOM exposes a capability React doesn’t, such as focus, measurement, media, or scroll. Never use one to re-read or re-implement what props and state already own. Reading input values, toggling a class, hiding something with style.display: all of these are state’s job. If you find a ref doing them, the value wanted to be state.
A few related surfaces are worth recognizing, whether you’ve met them or will later:
- In React 19,
refis a regular prop, so you can pass one down to a child that spreads it onto an element. This lesson is the other case: a component owning its own ref viauseRef. - When a parent needs to call a method on a child, say a
<VideoPlayer>exposingplay()andpause(), that’suseImperativeHandle. It’s rare; lifting state or akeyreset is usually the better reach. - Passing a function as the
refprop,ref={node => {…}}, is a ref callback, used to measure on mount or merge multiple refs.
Never read or write a ref during render
Section titled “Never read or write a ref during render”One rule governs every example so far: never read or write a ref during render. Touch .current only inside handlers and effects.
The reason is purity. A component must be a pure function: same inputs, same output, no side effects while rendering. A ref is mutable memory that lives outside that contract. Read it during render and your output depends on a value React isn’t tracking, so the same inputs can produce different output. Write it during render and you’ve performed a side effect mid-render. Handlers and effects run after render, outside the pure window, which is why they’re the only safe place.
There is one sanctioned exception: lazy ref initialization. Sometimes the initial .current should be an expensive object built once, such as a class instance, a parser, or a heavy lookup table. You write it like this:
const parserRef = useRef<MarkdownParser | null>(null);
if (parserRef.current === null) { parserRef.current = new MarkdownParser();}This write is idempotent and self-guarding, which is why it’s allowed. The first render finds null and fills the box; every render after finds it full and skips the if. After that first time, the if is purely a read.
Contrast the obvious-looking useRef(new MarkdownParser()). The argument is only used on the first render, but it’s still evaluated every render, so this builds a fresh parser each time and throws all but the first away. It’s the eager-versus-lazy problem you saw with useState: useState(expensiveThing()) runs the work every render and discards it, while the lazy form defers it. The guarded if is useRef’s lazy form, and the only write to .current you should ever do during render.
Each claim is about how a ref behaves across a component's render lifecycle. Mark each statement True or False.
Writing to ref.current triggers a re-render.
.current is a plain assignment — no subscription, no Object.is check, no reconciliation. (A useState setter, by contrast, does schedule a render.)Reading a DOM ref in the render body gives you the committed element.
null on the first render and stale after that. React assigns the live node to .current only after it commits the DOM — so the render body has already run by the time the node exists. Read DOM refs in handlers and effects, never in render.The { current } box keeps the same identity across every render of a component.
{ current } object on every render. Only what’s inside it changes. (Contrast useState, where the value is a fresh snapshot each render.)Reveal card-by-card review
Refs and the React Compiler
Section titled “Refs and the React Compiler”The React Compiler can’t see inside a ref. To it, .current is opaque mutable state: it can’t track when you wrote to it or what’s in it, so it assumes you only touch refs in handlers and effects, never during render. Read or write .current while rendering and you’ve fed the compiler a value it can’t reason about, and its caching can no longer be trusted.
The linter catches this as you type. The react-hooks rules the course requires flag a ref read or write during render. Treat that flag as a correctness problem, not a style nit: a ref touched during render is a latent bug, and the fix is always to move the access into a handler or an effect.
Putting it together: pick the box by what the JSX reads
Section titled “Putting it together: pick the box by what the JSX reads”The split is settled by one question every time: does the JSX read it? If it does, the value belongs in state, where a change repaints the screen; if only handlers and effects read it, it belongs in a ref, where a change stays invisible, whether that ref holds instance memory or a DOM handle.
Now you’ll write both forms yourself, with the tests checking your work. Build a SearchBox that does two things at once: focuses its input the moment it mounts (a DOM ref), and debounces its onChange by 300ms using a stored timer ID (an instance ref).
Make the input focus the moment it mounts (a DOM ref), and debounce onSearch so it fires 300ms after the last keystroke using a stored timer ID (an instance ref). The counter shows how many times onSearch has fired — type fast and it should tick once per pause, not once per keystroke. Touch every .current inside the handler or the previewed effect, never in the render body.
Reference solution
function SearchBox({ onSearch }: { onSearch: (query: string) => void }) { const inputRef = useRef<HTMLInputElement | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { inputRef.current?.focus(); }, []);
const handleChange = (event: ChangeEvent<HTMLInputElement>) => { const query = event.target.value; clearTimeout(timerRef.current ?? undefined); timerRef.current = setTimeout(() => onSearch(query), 300); };
return ( <input ref={inputRef} type="search" placeholder="Search…" onChange={handleChange} className="rounded border border-gray-300 px-3 py-1.5" /> );}inputRef holds the DOM node so the effect can call .focus() once the input is committed; timerRef holds the pending timer ID so each keystroke can clearTimeout the last one before scheduling a new setTimeout. Both refs are read and written only inside the handler and the effect, never in the render body. Add useEffect to the import from react.
Get the input focusing and the counter ticking exactly once per pause, and you’ve written both forms of ref the way you’ll write them in real components.
External resources
Section titled “External resources”The two canonical React docs pages for refs, one per form.