Under the Hood
Frontend

Refs vs. state: why the hot path skips setState

A ref is a box React hands you and then never looks at again; state is a box React watches so closely that touching it triggers a full render, diff, and commit. This lesson walks the real stages a render costs, then shows why a drag handler that fires 100 times a second should write to a ref and the DOM directly, calling setState exactly once when the gesture ends.

Refs vs. state: why the hot path skips setState

Drag a slider handle across the screen and console.log the number of times your component function runs. If that handle's position lives in useState, the number will be in the hundreds for a gesture that took two seconds. Nothing about the resulting UI looks wrong — the handle tracks your cursor smoothly — which is exactly why this is easy to miss. The cost isn't a visible bug. It's hundreds of renders, diffs, and commits, happening on a machine that was also supposed to be doing other things, like keeping the rest of the page responsive.

The fix isn't a clever optimization hook. It's noticing that React gave you a second kind of box — a ref — that it deliberately does not watch, and using it for exactly the values that change too fast to justify React's attention every single time.

Here's the shape of this lesson: what a ref actually is and how it differs from state at the mechanism level, what a render really costs once you break it into its real stages, and the pattern — ref for the live value, direct DOM write for the pixel, one setState at the end — that lets a 100Hz gesture cost React exactly one render.

A ref is a box React was handed and told to ignore

useRef(initialValue) returns one thing: an object shaped like { current: initialValue }. That object is created once, on the first render, and React returns the same object on every subsequent render of that component — it doesn't recreate it, doesn't inspect it, doesn't compare its previous value to its next one. You can write ref.current = anything from anywhere — an event handler, a setTimeout callback, another ref's effect — and as far as React's rendering machinery is concerned, nothing happened. No render gets scheduled. No component function gets re-invoked. The object just sits there, holding whatever you last put in it, until something else reads it.

Compare that to what happens when you call a useState setter. setValue(next) doesn't just store next somewhere — it tells React "this component's output may now be different." React responds by scheduling a re-render: it re-runs your component function from top to bottom with the new state in scope, producing a new tree of React elements, and then hands that tree off to the rest of the render pipeline to figure out what, if anything, actually needs to change in the real DOM.

That's the entire distinction, and it's worth stating as bluntly as possible: a ref is memory; state is a signal. Both can hold the exact same value — a number, a string, an object — and from a "what data does my component have" standpoint they look interchangeable. But writing to one is invisible to React and writing to the other is a request for React to do a considerable amount of work. Reaching for useState because you need somewhere to put a value, without asking whether React needs to react to that value changing, is how a component ends up re-rendering for no reason anyone can point to later.

What a render actually costs, in three real stages

"Renders are expensive" is true, but it's a hand-wave until you can name what's actually happening. Every time React re-renders a component, three distinct stages run, each with its own real cost:

  1. Render. React calls your component function again, top to bottom, with the current props and state. This builds a brand-new tree of React elements describing what the UI should look like. This is plain JavaScript execution — every hook call, every inline computation, every child component your function renders — and it happens even if the resulting tree turns out to be identical to the last one.
  2. Diff / reconcile. React compares the new element tree to the tree from the previous render, node by node, to figure out the minimal set of real DOM operations needed to get from one to the other. This comparison itself costs CPU time proportional to the size of the tree, independent of whether anything actually changed.
  3. Commit. React applies the DOM mutations the diff identified — setting attributes, moving nodes, updating text — and then runs any effects that were scheduled as a result (useEffect, useLayoutEffect). Real DOM writes are the most expensive line item here, because the browser may need to recalculate layout and repaint as a consequence.

None of these three stages skip themselves just because the state update was "small." Move a slider handle by one pixel and call setState with the new position, and React still re-runs the whole component function, still builds a whole new tree, still diffs the whole thing, still commits whatever changed. A single call is cheap enough that you'd never notice it in isolation — a handful of milliseconds, often less. The problem is frequency, not weight. A pointermove handler firing during a drag, or a requestAnimationFrame loop during a scroll-linked animation, can call setState 60 to 100 times a second. Multiply a few milliseconds by a hundred and you're spending a meaningful fraction of every second on work whose only visible output is "the handle moved," work that's now competing with the browser's own job of painting each frame — which is precisely what shows up to a user as jank: motion that stutters instead of gliding.

The pattern: ref for the value, direct DOM write for the pixel, setState once at the end

Once you see the render pipeline as three stages that all re-run on every setState, the fix for a high-frequency interaction falls out naturally: don't call setState on every event. Instead, split the work into two tracks that run at very different frequencies.

On every pointermove during the drag, write the new position straight into a ref: positionRef.current = newX. That's a plain assignment — no render, no diff, no commit, effectively free. Then, for the thing the user actually needs to see, skip React's render pipeline entirely and write directly to the DOM node you already have a reference to: node.style.transform = `translateX(${newX}px)` . The browser updates the pixel on screen without React ever being told anything changed, because nothing changed as far as React's model of the world is concerned — you reached past it.

Only when the gesture ends — on pointerup — do you call setState(positionRef.current). That single call is where you "commit" the final value into React's own state, so that any other part of the component tree that legitimately needs to know the current position — a label showing the numeric value, a sibling component whose layout depends on it, anything driven by ordinary props and state — gets exactly one clean, correct update instead of being dragged through hundreds of intermediate ones it never needed to see.

Try it: watch the render counters, not the motion

The demo below puts this pattern side by side with the naive one so the difference stops being theoretical. Drag the left handle across its track for a couple of seconds — it calls setState on every single pointermove, and a counter next to it, incremented directly in the render function, ticks up in real time. Then drag the right handle for about the same duration — it writes to a ref and updates its own position with a direct DOM write scheduled via requestAnimationFrame, only touching React state once, on release. Watch both counters, not the handles themselves: the motion will look equally smooth on both sides, because the browser is painting either way. What differs is invisible to the eye and enormous in the numbers — one gesture costs React one render, the other costs it a number roughly equal to how many pointermove events your input device fired.

Drag both handles across their tracks for a couple of seconds each, then compare the render counts. The motion is equally smooth in both — only one of them is paying React a render fee on every event.

every pointermove → setState

renders:

ref + direct DOM write, one setState at the end

renders: · committed x: 0

The requestAnimationFrame scheduling the right-hand demo relies on deserves more than a one-line mention — coalescing many rapid writes down to "at most one DOM write per frame" is its own small discipline, with a guard-flag pattern worth seeing in full. Batching DOM writes with requestAnimationFrame picks that up in detail.

When this trade is and isn't worth making

This pattern isn't free, and it isn't a general replacement for useState. What you're actually trading away is React's purity guarantee for the duration of the interaction: while the drag is in progress, the DOM shows a position that React's own state doesn't know about. Nothing else in your component tree can correctly reference "the current drag position" during that window, because as far as React is concerned, it hasn't happened yet. If some other part of your UI genuinely needs to react to every intermediate position — not just the final one — this pattern actively works against you, and you'd need a different approach entirely.

That trade is worth making specifically when two things are both true: the event fires at high frequency (a pointermove stream during a drag, a scroll handler, an animation tick — anything landing in the tens-to-hundreds-per-second range), and the visual feedback only needs to be correct in the DOM, not reflected through React's own state, until the interaction settles. It is very much not worth making for a text input that re-renders once per keystroke, a checkbox that re-renders once per click, or any interaction firing at human speed rather than device-polling speed. A component that renders a handful of times a second from ordinary setState calls has no measurable jank problem to solve, and reaching for refs and manual DOM writes there only adds a second, unmanaged source of truth to a component that didn't need one. If you've been tracking drag gestures at all, this same tension — a value that must update on every event but should only "count" once the gesture resolves — showed up already in Pointer events and setPointerCapture; this lesson is the render-cost half of that same story.

Go deeper

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. You write `ref.current = 5` inside an event handler. What, specifically, does React do in response, and how is that different from what happens after `setValue(5)`?
  2. A colleague says a single setState call for a one-pixel position change is 'basically free.' Name the three stages that still run on that call, and explain why the frequency of the call, not its size, is what causes jank.
  3. Why is it unsafe to read ref.current during a component's render and use that value to decide what to display?
  4. Describe the three-stage split this lesson recommends for a drag handler: what happens on every pointermove, and what happens only once, on pointerup?
  5. During an active drag using this pattern, is React's own state 'aware' of the handle's current position? What are the consequences of your answer for a sibling component that wanted to display that position live?
  6. A form field re-renders once per keystroke using ordinary useState. Should it be rewritten using the ref-plus-direct-DOM-write pattern from this lesson? Justify the answer in terms of event frequency.
  7. In the live demo, both handles move with visually identical smoothness. What does the render counter reveal that the motion itself does not?