I used to think useRef was just for accessing DOM elements. Boy, was I wrong. That naive assumption cost me hours of debugging and probably made my apps slower than they needed to be. Let me save you from making the same mistakes.
The Moment It Clicked
Picture this: I’m building a timer component (sound familiar from my interview stories?). My first instinct was to reach for useState for everything — the time value, the running state, even the interval ID.
It worked, but something felt off. Every second, the entire component would re-render. For a simple timer, maybe not a big deal. But multiply that by dozens of components with similar patterns, and suddenly your app feels sluggish.
That’s when I learned the hard way that not all values need to trigger re-renders.
So What’s the Real Difference?
Before diving into war stories, let’s get the basics straight:
useState is for values that affect what users see. When state changes, React says “Hey, something the user cares about changed, let’s update the UI!”
useRef is for values that your component needs to remember but don’t directly affect the UI. Think of it as a box where you can store stuff between renders without telling React about it.
The Technical Breakdown
// useState - triggers re-renders
const [count, setCount] = useState(0);
// useRef - no re-renders, ever
const countRef = useRef(0);
When you call setCount(1), React schedules a re-render. When you do countRef.current = 1, React doesn't even know you changed anything.
Real-World Example: The Timer That Taught Me Everything
Let me show you the evolution of my timer component. It’s a perfect case study for when to use what.
Version 1: The useState Disaster
const Timer = () => {
const [time, setTime] = useState(0);
const [isRunning, setIsRunning] = useState(false);
const [intervalId, setIntervalId] = useState(null); // Red flag!
useEffect(() => {
if (isRunning) {
const id = setInterval(() => {
setTime(prevTime => prevTime + 1);
}, 1000);
setIntervalId(id); // This triggers a re-render for no reason!
} else {
clearInterval(intervalId);
setIntervalId(null); // Another unnecessary re-render!
}
return () => clearInterval(intervalId);
}, [isRunning, intervalId]); // intervalId in dependencies = potential bugs
// ... rest of component
};
What’s wrong here? The interval ID doesn’t need to be state. Users don’t care about the interval ID — they care about the time. But I’m forcing React to re-render every time I store or clear the interval ID.
Version 2: The useRef Revelation
const Timer = () => {
const [time, setTime] = useState(0); // UI cares about this
const [isRunning, setIsRunning] = useState(false); // UI cares about this
const intervalRef = useRef(null); // UI doesn't care about this
useEffect(() => {
if (isRunning) {
intervalRef.current = setInterval(() => {
setTime(prevTime => prevTime + 1);
}, 1000);
} else {
clearInterval(intervalRef.current);
}
return () => clearInterval(intervalRef.current);
}, [isRunning]); // Much cleaner dependency array!
// ... rest of component
};
Much better! The interval ID is stored in a ref because it’s just a housekeeping detail. The component only re-renders when the actual time or running state changes.
When I Use useState vs useRef (My Mental Model)
After building dozens of components, here’s my decision-making process:
Use useState When:
- Users can see the change: counters, form inputs, loading states
- Other components might need the value: passing props, lifting state up
- You need to trigger side effects: useEffect dependencies that should cause re-runs
Use useRef When:
- Storing references: DOM elements, third-party library instances
- Housekeeping values: interval IDs, timeout IDs, previous values
- Performance-sensitive scenarios: values that change frequently but don’t affect UI
- Caching expensive calculations: results that persist between renders
The Gotchas That Bit Me
Gotcha #1: Reading useRef During Render
I once tried to display a ref value directly:
const MyComponent = () => {
const countRef = useRef(0);
const increment = () => {
countRef.current += 1;
};
return (
<div>
<p>Count: {countRef.current}</p> {/* This won't update! */}
<button onClick={increment}>Increment</button>
</div>
);
};
Why this doesn’t work: Changing countRef.current doesn't trigger a re-render, so the display never updates. If you need to show the value, use useState.
Gotcha #2: The Stale Closure Trap
const BadExample = () => {
const [count, setCount] = useState(0);
const countRef = useRef(count);
const handleAsync = async () => {
await someAsyncOperation();
console.log(countRef.current); // Might be stale!
};
// Forgot to update the ref when state changes
return <button onClick={handleAsync}>Do Something</button>;
};
The fix: Keep refs in sync when needed:
const GoodExample = () => {
const [count, setCount] = useState(0);
const countRef = useRef(count);
useEffect(() => {
countRef.current = count; // Keep ref updated
}, [count]);
// Now countRef.current is always current
};
Gotcha #3: DOM Refs and TypeScript
If you’re using TypeScript, DOM refs need proper typing:
const inputRef = useRef<HTMLInputElement>(null);
const focusInput = () => {
inputRef.current?.focus(); // Use optional chaining!
};
Advanced Patterns I Actually Use
Pattern 1: Tracking Previous Values
const usePrevious = (value) => {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
};
const MyComponent = ({ userId }) => {
const prevUserId = usePrevious(userId);
useEffect(() => {
if (prevUserId && prevUserId !== userId) {
// User changed, do something
console.log(`User changed from ${prevUserId} to ${userId}`);
}
}, [userId, prevUserId]);
};
Pattern 2: Debounced Input Without Re-renders
const useDebounce = (callback, delay) => {
const timeoutRef = useRef(null);
return useCallback((...args) => {
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => callback(...args), delay);
}, [callback, delay]);
};
const SearchComponent = () => {
const [query, setQuery] = useState('');
const debouncedSearch = useDebounce((searchTerm) => {
// API call here
console.log('Searching for:', searchTerm);
}, 500);
const handleInputChange = (e) => {
const value = e.target.value;
setQuery(value);
debouncedSearch(value); // No extra re-renders from timeout IDs!
};
return <input value={query} onChange={handleInputChange} />;
};
The Performance Impact You Can’t Ignore
Here’s something that blew my mind: I had a dashboard with 50+ components, each with a timer or interval. By switching from useState to useRef for interval IDs and other housekeeping values, I reduced re-renders by about 60%.
Before: Every second, 50+ components re-rendered unnecessarily. After: Only components with actual UI changes re-rendered.
The user experience went from “slightly laggy” to “buttery smooth.”
My Simple Decision Framework
When I’m coding, I ask myself:
- “Will users see this change?” → useState
- “Do I need to pass this to other components?” → useState
- “Should this trigger useEffect?” → useState
- “Is this just for my component’s internal bookkeeping?” → useRef
Most of the time, that’s all I need to decide.
The Bottom Line
useState and useRef aren’t competing — they’re complementary. useState handles the state that users care about, while useRef handles the behind-the-scenes stuff that makes your components work smoothly.
The key insight? Not everything needs to trigger a re-render. Learning when to use each hook is what separates developers who build fast, responsive apps from those who wonder why their apps feel sluggish.
Next time you reach for useState, pause for a second. Ask yourself: “Does the UI need to update when this changes?” If not, useRef might be your friend.
And if you’re still unsure, err on the side of useState first. It’s easier to optimize a working component than to debug a broken one.
*What’s been your experience with these hooks? I’d love to hear about the “aha moments” that clicked for you in the comments and If you found this helpful, I’m sharing more interview stories and career insights on LinkedIn and Twitter. Connect with me there for more such information.
Checkout my previous articles:
#React #Hooks #useState #useRef #Performance #WebDevelopment #JavaScript #Frontend
A message from our Founder
**Hey, Sunil here. I wanted to take a moment to thank you for reading until the end and for being a part of this community.
Did you know that our team run these publications as a volunteer effort to over 3.5m monthly readers? We don’t receive any funding, we do this to support the community. ❤️
If you want to show some love, please take a moment to **follow me on LinkedIn, TikTok, **Instagra**m. You can also subscribe to our **weekly newslette**r.
And before you go, don’t forget to clap and follow the writer️!
Comments
Loading comments…