Skip to content
Chapter 24Lesson 5

useRef as the non-rendering escape hatch

Learn React's useRef hook, the escape hatch 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 . Every keystroke needs to cancel the timer the previous keystroke set and start a fresh one. The component has to remember the pending setTimeout ID between keystrokes, because clearing a timer means calling clearTimeout(id) with the exact ID you got back when you scheduled it.

The tools you already have both fail at this. A plain variable in the component body won’t survive, because the body is a function that re-runs top to bottom on every render, so let timerId is reset to undefined each time and loses the ID the instant a keystroke triggers a re-render. That points you toward useState, which does survive renders. But its setter schedules a render, so the component would re-render every time you store a timer ID. That render paints nothing new, since the user never sees the timer ID, yet it fires several times a second. The code works, but it’s wasteful in a way you’ll learn to spot.

What you actually 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 . It’s the fifth “where does this value live” question of this chapter, and the first whose honest answer is not state at all.

By the end of this lesson you’ll choose between state and a ref on reflex, decided by a single question, and you’ll know the small handful of things refs are actually for. We’ll start with what a ref is, because the whole lesson follows from one property of it.

A ref is a mutable box React never watches

Section titled “A ref is a mutable box React never watches”

A ref is a box with one slot, called 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 entire lesson, so keep all three in mind.

The box is stable. You get the same { current } object on every render, for the whole life of the component. This is the opposite of how state behaves: with useState, the value is a fresh snapshot each render, captured at that moment. With useRef, the box is the fixed thing, much like the setter from useState, which also keeps the same identity across renders. The box never changes; only what’s inside it does.

.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: there is 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.

That last fact is easier to accept once you see it in render counts. The tree below is a Parent holding a Widget. Toggle the implementation: in one version the Widget stores its value in state, in the other it stores the same value in a ref. Click the trigger and watch which boxes light up.

Storing a value: state vs ref

The state variant ticks the badge; the ref variant does nothing, every time. That silence is the feature.

One typing note before we move on. When a ref starts out empty and gets filled later, annotate the slot as nullable: useRef<HTMLInputElement | null>(null). A timer ID is null until the first keystroke, and a DOM node doesn’t exist until React commits it. That’s the same instinct that made you write useState<User | null>(null) for state that’s empty at first. When the initial value already pins the type, like useRef(0), inference handles it and you write nothing.

The question that decides: does the JSX read it?

Section titled “The question that decides: does the JSX read it?”

Here is the rule that the rest of the lesson builds on.

State is for values the render output reads. Refs are for values only handlers and effects read. If changing a value should change what the user sees, it’s state. If changing it should stay invisible until something else triggers a render, it’s a ref.

That single question, does the JSX read this value?, settles almost every case, and it heads off the two opposite mistakes beginners make.

The common one is state in disguise: a useState and its setter where the value never actually appears in the JSX. Examples are a timer ID for debouncing, a scroll offset you only read 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 nothing in the output depends on the value. To catch it, trace the state variable through the component: if it shows up in no JSX expression, it wanted to be a ref.

The opposite mistake is reaching for a ref where state belongs, reading a value the UI is supposed to reflect out of .current. You change .current, but the screen stays stale, because changing a ref is exactly the thing that doesn’t trigger a render. We’ll see this one in full when we get to DOM refs. For now, note that it’s the flip side of the same property that made the box useful: the silence that helps with timer IDs hurts when the UI needs to react.

Before the lesson goes further, try sorting some values yourself. For each chip below, ask the one question, does the rendered output read this, and drop it in the right bucket.

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.

State The JSX reads it
Ref Only handlers and effects read it
The text shown in a search field
The setTimeout ID used to debounce that field
The number on a visible cart badge
The previous value of a prop, kept to compare against
Whether a panel is open or closed
The scroll position read when a Scroll to top button is clicked
A count displayed on screen
An IntersectionObserver instance the component owns

The line worth keeping in mind is “if the JSX doesn’t read it, it doesn’t belong in state.” The rest of the lesson covers the two things a ref is for, then the rules that keep you out of trouble.

Remembering a value across renders: instance refs

Section titled “Remembering a value across renders: instance refs”

The first use is the purer one, so we start here: a ref as plain memory. No DOM, no element, just a box for some value the component needs to carry across renders while the JSX ignores it. This is the answer to the debounce we opened with, so let’s finally write it.

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 will hold the pending timer ID between renders. It starts null, since no search is pending yet. Nothing here re-renders when we write to it later, 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. On every keystroke we clear whatever timer the last keystroke scheduled, reading the ID straight out of .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, and we store it back in .current so the next keystroke can cancel it. The search only fires 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; for now, just note that the obligation exists.

1 / 1

The shape is clear on one read: the ref is the memory that lets one keystroke reach back and cancel the previous one’s work. Notice that every touch of .current sits inside the handler. That isn’t incidental, it’s a rule we’ll state explicitly later in the lesson; this example is your first sighting of it.

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 sees the old one. It’s common enough that teams wrap it in a reusable usePrevious hook, and you’ll build exactly that kind of custom hook in a later chapter.

The second is a render counter for debugging: const renders = useRef(0) and renders.current++ to see how many times a component has rendered. It’s a throwaway diagnostic, but a clean illustration of the box: pure memory, zero UI.

None of these touch the DOM. They’re plain JavaScript values that React happens to keep alive across renders, parked in a box it never inspects. That’s the entire first use.

Reaching into the DOM: element refs and commit timing

Section titled “Reaching into the DOM: element refs and commit timing”

The second use does touch the DOM, and it comes with a timing subtlety that catches 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 for you. The question that trips people is when. React assigns the node after it commits the DOM to the screen. Commit is the same step you met in the render model, and the timing around it explains everything about DOM refs. Before commit there’s no node to point at, so the ref is null. After commit the node exists, and React fills the box.

That gap between the render running and the node existing is the part most people have to slow down for, so the figure below walks it one frame at a time.

render runs commit assign ref after commit
React RUNNING
SearchBox() <input> JSX (not real yet)
inputRef.current null
DOM / screen
nothing on screen yet
First render runs. The component function executes and returns the <input> JSX, but React hasn't put anything on screen yet — so inputRef.current is still null. Reading the ref here, in the render body, gets you null.
render runs commit assign ref after commit
React
render done — committing to the DOM
inputRef.current null
DOM / screen COMMITTED
<input> real node
React commits. It creates the real <input> element and inserts it into the document. Now there's an actual node — but the ref box hasn't been pointed at it yet.
render runs commit assign ref after commit
React
filling the ref box
inputRef.current <input>
DOM / screen
<input> real node
React sets inputRef.current to the node. This happens after the commit — the box now points at the live <input>.
render runs commit assign ref after commit
React EFFECTS / HANDLERS
inputRef.current?.focus()
inputRef.current <input>
DOM / screen
<input> real node ⚡ focused
Effects and handlers run. From here on, inputRef.current is the live element — .focus(), .scrollTo(), .getBoundingClientRect() all work. This is the only place it's safe to read.

That cycle has one lesson, which you can state from either side. Reading a DOM ref in the render body is wrong: it’s null on the first render and stale on every render after, because the node only ever gets assigned after the body has already run. Reading it in an effect or a handler is right, because those run after commit, when the box holds the live node. The takeaway is to read a DOM ref only in handlers and effects, never during render.

Once you’ve got a live node, what do you actually do with it? Refs into the DOM exist for a specific reason: there are things the DOM can do that React’s props and state simply don’t expose. Four of them cover almost everything.

  1. Focus management, inputRef.current?.focus(). You can’t “focus” something declaratively with a prop; focus is an action you tell the element to take.
  2. Measurement, boxRef.current?.getBoundingClientRect() to read an element’s real size and position after layout.
  3. Imperative media and element APIs, videoRef.current?.play() from a Play button’s handler. Playing a video is a command, not a piece of state.
  4. Scrolling, listRef.current?.scrollTo({ top: 0 }) to jump a scroll container to the top.

Notice that every read uses optional chaining, ?.. 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 is a value that might not be there, so you guard the read.

What those four have in common is that they’re all imperative operations: you tell the element to do something. 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 version 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. The input’s value already lives in state, because React put it there. Reaching into the DOM to read it back goes around the source of truth to fetch what you already hold, and it desyncs the moment state and the DOM disagree.

The rule in one line: reach for a ref only when the DOM exposes a capability React doesn’t, such as focus, measurement, media, scroll, or an imperative selection or clipboard target. Never use one to re-read or re-implement what props and state already own. Reading input values, toggling a class, or hiding something with style.display are all state’s job. If you find a ref doing them, the value wanted to be state.

A few related surfaces are worth recognizing here, whether you’ve already met them or will meet them later:

  • In React 19, ref is just a regular prop. You can hand a ref down to a child and the child spreads it onto an element. You saw that when you passed refs through components in an earlier chapter, and the old forwardRef wrapper is on its way out, so you won’t need it. This lesson is the other case: a component owning its own ref via useRef, in the same component that uses it.
  • When a parent genuinely needs to call a method on a child, say a <VideoPlayer> exposing play() and pause(), that’s useImperativeHandle, also from that earlier chapter. It’s rare in practice; usually lifting state or a key reset is the better reach, and you’ll know it when you actually need it.
  • Passing a function as the ref prop, ref={node => {…}}, is a ref callback, which is how you measure on mount or merge multiple refs. That earlier chapter covers it; for now, just know the option exists.

With both uses covered, here’s the rule that’s been implicit in every example: never read or write a ref during render. Touch .current only inside handlers and effects.

The reason is purity. From the render model, 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 now 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. Either way you’ve reintroduced exactly the unpredictability that hooks exist to prevent. Handlers and effects run after render, outside the pure window, which is why they’re the only safe place.

There is precisely one sanctioned exception, and it’s narrow: 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 breaks the during-render rule on a technicality, and the technicality is the point: the write is idempotent and self-guarding. It runs exactly once. 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. That’s why it’s allowed.

Contrast it with the obvious-looking alternative, useRef(new MarkdownParser()). That builds a fresh parser on every render and throws all but the first away, because the argument to useRef is only used on the first render, yet the expression is still evaluated every time. This is the same problem as the eager-versus-lazy initializer you saw with useState: useState(expensiveThing()) runs the work every render and discards it, while the lazy form defers it. The fix is the same too. The guarded if is useRef’s lazy form, and it’s 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.

That’s the whole point of a ref: React never watches the box. Changing .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.

It’s 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.

The box is stable for the component’s whole life — you get the same { current } object on every render. Only what’s inside it changes. (Contrast useState, where the value is a fresh snapshot each render.)

One forward-looking note, because it’s a direct consequence of the rule you just learned. A later chapter covers the React Compiler in depth; here you only need the part that touches refs.

The compiler can’t see inside a ref. To it, .current is opaque mutable state it has no way to track, since it can’t know when you wrote to it or what’s in it. So it leans on the rule: it assumes you only touch refs in handlers and effects, never during render. If you break that and read or write .current while rendering, you’ve put a value the compiler can’t reason about into the part of the component it’s trying to optimize, and its caching can no longer be trusted to be correct.

What catches this for you isn’t a runtime warning, it’s the linter, at the moment you write the code. The react-hooks lint rules the course’s setup requires include one that flags 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 the same, which is to move the access into a handler or an effect. Follow the rule and the compiler can do its job. That’s the same arrangement the rest of the hooks surface has with the compiler.

Putting it together: pick the box by what the JSX reads

Section titled “Putting it together: pick the box by what the JSX reads”

You now have a clean either/or for where a value lives:

  • State when the render reads the value and a change should repaint the screen.
  • Ref when only handlers and effects read it and a change should stay invisible. This comes in two forms: instance memory (timer IDs, previous values, render counts) and DOM handles (focus, measure, media, scroll).

That slots useRef into the reflex this whole chapter has been building. You reach for each tool only when its situation arises: useState for values that drive the UI; derive instead of storing when a value is computable from what you already have; lift, or push to the URL, or fetch from the server depending on where a value truly belongs; useReducer when coordinated transitions multiply; and now useRef for values that survive renders but the UI ignores. The first split is still settled by one question every time, does the JSX read it?, which is worth remembering as: if the JSX doesn’t read it, it doesn’t belong in state.

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.

Preview
    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.

    The two canonical React docs pages for refs, one per form.