Skip to content

useRef vs useState: When Should You Use Each React Hook?

Problem

When I was building a text-to-speech component in React, I ran into a confusing situation. I needed to track whether audio was playing, but I wasn’t sure whether to use useState or useRef.

My code looked like this:

TextPlayer.jsx
function TextPlayer() {
const [isPlaying, setIsPlaying] = useState(false);
const isPlayingRef = useRef(false);
useEffect(() => {
const utterance = new SpeechSynthesisUtterance('Hello world');
utterance.onend = () => {
setIsPlaying(false);
isPlayingRef.current = false;
};
// ... more code
}, []);
}

I had the same value stored as both state AND ref. This felt wrong, but I wasn’t sure why. When I posted about this on Reddit, the response was clear: “I can’t think of a use case where you would need a value to be both a state and a ref in the same component.”

So what’s the actual difference, and when should I use each one?

Environment

  • React 18.x
  • Modern browser with Speech Synthesis API

What’s the Difference?

The key difference is simple:

  • useState - Triggers a re-render when changed
  • useRef - Does NOT trigger a re-render when changed

Let me show you what I mean.

The Quick Rule

Here’s the rule I now follow:

If the value appears in JSX, use useState. If it’s for internal logic only (like interval IDs, DOM references, or callback state), use useRef.

Let me walk through some examples to make this clear.

Example 1: When useState is Wrong

I tried using useRef for a counter that I wanted to display:

BadCounter.jsx
import { useRef } from 'react';
export default function BadCounter() {
const countRef = useRef(0);
return (
<button onClick={() => {
countRef.current += 1;
}}>
Count: {countRef.current}
</button>
);
}

When I clicked the button, the number on screen didn’t change. Why? Because changing ref.current doesn’t tell React to re-render. The value updated, but the UI stayed the same.

The fix was simple - use useState instead:

GoodCounter.jsx
import { useState } from 'react';
export default function GoodCounter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}

Now it works because setCount triggers a re-render.

Example 2: When useRef is Right

But then I went too far the other direction. I started using useState for everything, even values that never appeared in the UI.

Here’s a stopwatch example where I used state for the interval ID:

BadStopwatch.jsx
import { useState, useEffect } from 'react';
export default function BadStopwatch() {
const [seconds, setSeconds] = useState(0);
const [intervalId, setIntervalId] = useState(null); // Wrong!
function handleStart() {
const id = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
setIntervalId(id);
}
function handleStop() {
clearInterval(intervalId);
setIntervalId(null);
}
return (
<>
<h1>{seconds} seconds</h1>
<button onClick={handleStart}>Start</button>
<button onClick={handleStop}>Stop</button>
</>
);
}

This works, but it causes an extra re-render every time I start or stop. The intervalId never appears in the UI, so why am I causing re-renders?

The better approach uses useRef:

Stopwatch.jsx
import { useState, useRef } from 'react';
export default function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null); // Right!
function handleStart() {
intervalRef.current = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
}
function handleStop() {
clearInterval(intervalRef.current);
}
return (
<>
<h1>{seconds} seconds</h1>
<button onClick={handleStart}>Start</button>
<button onClick={handleStop}>Stop</button>
</>
);
}

Now I only re-render when seconds changes (which I want to display), not when the interval ID changes (which I don’t display).

Example 3: The Speech Synthesis Case

Going back to my original problem, I needed to track whether audio was playing inside a callback. The callback needed to know the current playing state, but I also wanted to show “Playing…” in the UI.

Here’s the wrong approach (my original code):

BadTextPlayer.jsx
function TextPlayer() {
const [isPlaying, setIsPlaying] = useState(false);
const isPlayingRef = useRef(false); // Duplicate!
useEffect(() => {
const utterance = new SpeechSynthesisUtterance('Hello world');
utterance.onend = () => {
setIsPlaying(false);
isPlayingRef.current = false; // Keeping both in sync
};
}, []);
// ... rest of component
}

If I don’t display the playing status in the UI, I should just use useRef:

TextPlayer.jsx
function TextPlayer() {
const isPlayingRef = useRef(false);
const play = () => {
const utterance = new SpeechSynthesisUtterance('Hello world');
utterance.onend = () => {
isPlayingRef.current = false;
};
isPlayingRef.current = true;
speechSynthesis.speak(utterance);
};
return <button onClick={play}>Play</button>;
}

But if I DO want to show the status in the UI, I should just use useState:

TextPlayerWithStatus.jsx
function TextPlayer() {
const [isPlaying, setIsPlaying] = useState(false);
const play = () => {
const utterance = new SpeechSynthesisUtterance('Hello world');
utterance.onend = () => {
setIsPlaying(false);
};
setIsPlaying(true);
speechSynthesis.speak(utterance);
};
return (
<button onClick={play}>
{isPlaying ? 'Playing...' : 'Play'}
</button>
);
}

Pick ONE. Never both.

Example 4: DOM References

Another common use for useRef is accessing DOM elements directly:

Form.jsx
import { useRef } from 'react';
export default function Form() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<input ref={inputRef} />
<button onClick={handleClick}>Focus the input</button>
</>
);
}

The inputRef just holds a reference to the DOM element. Changing it doesn’t need to trigger a re-render.

Decision Checklist

When I’m not sure which to use, I ask myself these questions:

  1. Does the value appear in JSX?useState
  2. Is it a DOM element reference?useRef
  3. Is it an interval/timeout ID?useRef
  4. Does changing it need to trigger useEffect?useState
  5. Is it only for internal tracking in callbacks?useRef

Common Mistakes

Mistake 1: Duplicate State and Ref

Mistake 1 - Duplicate state and ref
// WRONG
const [isPlaying, setIsPlaying] = useState(false);
const isPlayingRef = useRef(false);

This is the anti-pattern I started with. Pick one.

Mistake 2: Using Ref for Display Values

Mistake 2 - Ref for display
// WRONG - UI won't update
const countRef = useRef(0);
return <button>{countRef.current}</button>;

Mistake 3: Using State for Non-UI Values

Mistake 3 - State for non-UI
// WRONG - unnecessary re-renders
const [intervalId, setIntervalId] = useState(null);
// RIGHT
const intervalRef = useRef(null);

The Reason

The core issue is understanding React’s re-render mechanism:

  • State changes tell React “something changed, update the UI”
  • Ref changes are invisible to React - they’re just mutable storage

When I store something in state that never appears in the UI, I’m telling React to do extra work for no reason. When I store something in ref that should appear in the UI, I’m hiding changes from React.

Summary

In this post, I explained the difference between useRef and useState in React. The key point is simple: use useState for values that appear in the UI (triggers re-render), and useRef for internal values that don’t affect rendering (no re-render).

Never store the same value as both state and ref. This anti-pattern creates unnecessary complexity and can lead to synchronization bugs.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments