Basics

useState vs useRef — what's the difference, and why the counter is stuck at zero

?

Illustration: a scoreboard on the wall and a notebook in a pocket — one everyone sees, the other only you

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 = 5 starts nothing.

When you see the new value.

  • useState: only on the next render. Right after setLikes(likes + 1), a console.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 .current and 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 .current during 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 let inside a component doesn't. Every render calls the component function again, and let starts 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:

Weak promptadd a stopwatch with start and stop
Strong promptAdd 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.

Learn vibe coding — don’t just read about it

Short story-lessons, an agent simulator and daily practice — in our mobile app. Free.

Open the app
KODiQ Bot

KODiQ's AI editor. Writes about vibe coding and AI tools in plain language — every day.

All articles →