useState vs useRef — what's the difference, and why the counter is stuck at zero
You're building a like counter. A neural net spotted useRef somewhere and wrote this:
const likes = useRef(0);
<button onClick={() => { likes.current += 1; console.log(likes.current); }}>
♥ {likes.current}
</button>
You click five times. The console honestly says 1, 2, 3, 4, 5. The button says 0. Then you open a nearby menu, and the button suddenly shows 5.
Nothing broke. useRef and useState both "remember a value", but they deal with the screen differently. Let's untangle it.
The main difference in one sentence
- useState is a scoreboard on the wall. Change the value and React re-renders the component, so everyone sees the new one.
- useRef is a notebook in your pocket. Write in it and the value is kept, but the screen doesn't find out. No re-render.
In the example above you were writing in the notebook. The number grew, but nobody updated the scoreboard. When the menu triggered a re-render for its own reasons, React read the notebook along the way — and showed 5.
Point by point
Re-render.
- useState:
setLikes(5)starts a new render. - useRef:
likes.current = 5starts nothing.
When you see the new value.
- useState: only on the next render. Right after
setLikes(likes + 1), aconsole.log(likes)line shows the old number. That's not a bug, it's a snapshot of the current render — more in the article on state. - useRef: instantly. Write to
.currentand the very next line sees the new value.
What to store.
- useState: anything the user sees — text, counters, whether a dialog is open, the selected tab.
- useRef: anything the screen doesn't need — a timer id, a reference to a DOM element, the previous value, a request abort controller.
Reading during render.
- useState: read it as much as you like; that's what it's for.
- useRef: better not to read it in JSX. The React docs explicitly ask you not to touch
.currentduring render — precisely because of the counter story above.
Does it survive a re-render.
- Both do. React keeps them between renders.
- But a plain variable declared with
letinside a component doesn't. Every render calls the component function again, andletstarts from scratch.
Where useRef is irreplaceable: a timer that won't stop
Here's the reverse trap. You're building a stopwatch and keep the timer id in a plain variable:
let timerId;
const start = () => { timerId = setInterval(tick, 1000); };
const stop = () => clearInterval(timerId);
You hit "Start" — seconds tick. You hit "Stop" — they keep ticking. Every second calls tick, tick updates state, the component re-renders — and let timerId is created again, empty. stop clears undefined. The real timer lives on by itself.
Put the id in useState and it'll work, but every setTimerId causes an extra render for a number nobody sees.
The right answer is the notebook:
const timerId = useRef(null);
const start = () => { timerId.current = setInterval(tick, 1000); };
const stop = () => clearInterval(timerId.current);
The value survives renders and doesn't touch the screen. Exactly what you need.
One more trick: the freshest value in delayed code
A user is typing a note. Three seconds after they hit "Send", you save the draft: setTimeout(() => save(text), 3000). In those three seconds they typed one more line. And the old version got saved.
The timer function remembered the text of the render it was created in. The new value appeared in the next render, and the timer knows nothing about it.
The notebook solves this too:
const textRef = useRef(text);
useEffect(() => { textRef.current = text; }, [text]);
setTimeout(() => save(textRef.current), 3000);
Inside the timer you read the notebook, not a snapshot. And you get whatever is in it right now. Note that you read .current in a callback, not in JSX — the rule from the section above still holds.
Which one fits whom
Ask yourself one question: if the value changes, should the user see it?
- Yes →
useState. Likes, input text, an open menu, loading. - No →
useRef. Timers, DOM elements, "has this already been sent", the previous value for comparison.
Not sure — pick useState. An extra re-render is almost never noticeable. A value that never shows up on screen is noticeable immediately and looks like a bug.
When you ask a neural net for React code, state the rule explicitly:
add a stopwatch with start and stopAdd a stopwatch with Start and Stop buttons to a React component. Keep elapsed seconds in useState — they are on screen. Keep the timer id in useRef — it is invisible and must not cause a re-render. Do not read ref.current in JSX. Clear the timer in the useEffect cleanup so it does not outlive unmounting.Can I swap useState for useRef to make things faster?
Not if the value is shown on screen. You'll save a render, but the screen will start lying. Optimize only when a profiler has shown a real problem.
Why use useRef for DOM elements?
To reach a specific element: focus an input, scroll to a block, measure a size. React puts the element into .current itself if you pass the ref to the ref attribute.
Short story-lessons, an agent simulator and daily practice — in our mobile app. Free.