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:
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), useuseRef.
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:
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:
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:
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:
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):
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:
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:
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:
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:
- Does the value appear in JSX? →
useState - Is it a DOM element reference? →
useRef - Is it an interval/timeout ID? →
useRef - Does changing it need to trigger useEffect? →
useState - Is it only for internal tracking in callbacks? →
useRef
Common Mistakes
Mistake 1: Duplicate State and Ref
// WRONGconst [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
// WRONG - UI won't updateconst countRef = useRef(0);return <button>{countRef.current}</button>;Mistake 3: Using State for Non-UI Values
// WRONG - unnecessary re-rendersconst [intervalId, setIntervalId] = useState(null);
// RIGHTconst 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