React useRef Not Updating the UI - Why .current Changes Don't Show
6 min read
“You can prove the value changed. Log it, inspect it, it's right there - updated, correct, current. The screen just never heard about it, because nothing you did was ever wired to a render.”
Here's the part of the mental model this bug breaks: changing a piece of
data and causing a re-render are two completely separate things in React.
They happen to be the same thing for useState - but only because
useState's setter does both on purpose. Nothing else does.
let announcement = "Welcome to the dojo!";export default function AnnouncementBoard() { function handleDismiss() { announcement = "Announcement dismissed."; } return ( <div> <p>{announcement}</p> <button onClick={handleDismiss}>Dismiss</button> </div> );}
announcement is a plain JavaScript variable, declared outside the
component. handleDismiss reassigns it - and that reassignment absolutely
works, at the JavaScript level. If you log announcement after clicking,
you'll see the new string.
But React has no idea this variable exists. It is not state, it is not a
prop, it is not anything React tracks. Reassigning it changes what the
next render would read - if there were a next render. There isn't,
because nothing told React to schedule one. The <p> keeps showing whatever
it showed during the last render React actually ran, which was before the
click.
Symptom
The click handler runs - you can confirm it with a log or a breakpoint, and
the underlying value genuinely changes. The rendered output is completely
unaffected, as if the click did nothing at all.
It's a reasonable next guess. useRef gives you a mutable container that
persists across renders, which sounds like exactly what you want for
"a value that changes." Try it:
Click "Toggle Theme." The background color changes correctly - that part
is driven by theme, which is real state. But "Current mode" stays on
"Light Mode" forever, even when the background has gone dark.
useRef(initialValue) evaluates initialValue exactly once, on the first
render, and stores it in .current. On every render after that, React does
not touch .current and does not re-run the expression that created it.
modeLabel.current is permanently "Light Mode" - the result of evaluating
theme === "dark" ? ... : ... back when theme was still "light".
The Sensei's Hint
useRef solves "persist a value across renders without losing it." It does
not solve "recompute a value when something changes" - that's what a plain
expression evaluated during render does, or useMemo if it's expensive.
A ref's .current is frozen at whatever it was set to; render doesn't
refresh it for you.
Click the box ten times. countRef.current genuinely becomes 10 -
countRef.current += 1 is a real mutation, it works exactly as written. But
"Clicks: 0" never changes.
countRef.current += 1 mutates the ref. It does not call any function React
provided for the purpose of scheduling a render. As far as React's render
scheduler is concerned, nothing happened. The <p> was rendered once, with
countRef.current equal to 0 at that time, and it will keep showing that
same JSX output until something - anything - causes React to render this
component again. Nothing here does.
export default function SecretClicker() { const [count, setCount] = useState(0); function handleClick() { setCount((c) => c + 1); } return ( <div> <div onClick={handleClick}>Click me</div> <p>Clicks: {count}</p> </div> );}
setCount does two things, bundled together: it updates the value React
will hand back on the next render, and it schedules that next render to
happen. That bundle is the entire reason useState exists. A plain
variable and a ref both give you the first half. Neither gives you the
second.
For the theme label, the fix is even simpler - delete the ref entirely and
compute the value as a plain expression during render:
No ref, no state, no effect. theme is already state, so it already causes
a re-render when it changes - and on that re-render, this line just runs
again with the new theme and produces the right label. There's nothing to
keep in sync because nothing is being stored; it's recalculated every time
for free.
None of this means useRef is broken or wrong - it has a real job. It's for
values that need to persist across renders but should not trigger a
render when they change: a reference to a DOM node for .focus() or
.scrollIntoView(), a timer ID you need to clearInterval later, a
"previous value" you want to compare against without re-rendering for the
comparison itself.
Constraints
Ask one question: "when this value changes, does the screen need to show
something different?" If yes, it belongs in useState (or is derived,
during render, from something that is). If no - if it's bookkeeping that
React itself never needs to react to - useRef is correct, and reaching
for useState instead would cause unnecessary re-renders.
Start with "Announcement Stays on Screen" - it has no ref at all, just a
plain variable, which makes the "changing a value doesn't cause a render"
idea as bare as it gets. "Mode Label Never Switches" adds useRef and shows
the specific trap of computing something once into .current and expecting
it to update. "Click Counter Never Shows on Screen" is the purest version of
the mechanism - one ref, one mutation, one display that never moves - work
through it last to confirm the diagnosis is the same every time: nothing
called a state setter, so nothing scheduled a render.