useRef: Beyond DOM Access—Using It as a Variable Store Without Re-renders

Chris is building a 'Stopwatch' feature this time.
When the "Start" button is clicked, time flows second by second. When "Stop" is clicked, it pauses.
Accustomed to React, Chris naturally reached for useState.
"Time is a changing value, and I need to remember the setInterval ID to stop the timer. I should make both of them State, right?"
// ❌ Chris's Inefficient Code
function Stopwatch() {
const [time, setTime] = useState(0);
const [timerId, setTimerId] = useState<number | null>(null);
const start = () => {
// Saving the timer ID to state -> Triggers a re-render!
const id = window.setInterval(() => {
setTime(t => t + 1);
}, 1000);
setTimerId(id);
};
const stop = () => {
if (timerId) clearInterval(timerId);
};
return (
<div>
<span>{time}s</span>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}The feature works. However, there is a problem. The moment timerId is set (setTimerId), the component re-renders even though there is absolutely no visual change on the screen.
He simply wanted to store some data, but React makes a fuss saying, "The state changed!" and repaints the screen.
The secret weapon used when you "want to persist a value but don't want to trigger a render" is useRef.
1. useRef: A Pocket That Doesn't Trigger Re-renders
We are usually taught to use useRef when selecting a specific DOM element instead of document.getElementById (e.g., inputRef.current.focus()). However, the true power of useRef lies in the fact that it is a "storage that persists for the component's lifecycle, irrelevant to rendering."
useRef returns a simple JavaScript object in the form of { current: ... }. We can put anything into this current property: numbers, strings, objects, or even functions.
The most important feature is that React does not re-render the component even if the current value changes.
Solution: Storing Timer ID in Ref
Let's optimize Chris's code using useRef.
// ✅ Optimized Code
function Stopwatch() {
const [time, setTime] = useState(0);
// Store values irrelevant to rendering in a ref.
const timerIdRef = useRef<number | null>(null);
const start = () => {
// Ignore if already running
if (timerIdRef.current) return;
timerIdRef.current = window.setInterval(() => {
setTime(t => t + 1);
}, 1000);
// Assigning a value to timerIdRef.current does NOT trigger a re-render.
};
const stop = () => {
if (timerIdRef.current) {
clearInterval(timerIdRef.current);
timerIdRef.current = null;
}
};
// ...
}Now, saving or clearing the timer ID does not cause unnecessary rendering. The screen updates only when the time actually changes (setTime).
2. Variable (let) vs. State vs. Ref: What Should I Use?
There is a common question beginners ask:
"Can't I just declare a variable like let timerId = 0 inside the component?"
No, you can't. The reason lies in how React's rendering works.
3. Advanced Use: Remembering Previous Values
Another excellent use case for useRef is "remembering the value from the previous render."
For example, to know if a stock price "rose" or "fell," you need to compare the previous price with the current price.
If you update the ref inside useEffect, you can save the value at a point after the rendering.
function PriceDisplay({ price }: { price: number }) {
const prevPriceRef = useRef<number>(price);
useEffect(() => {
// After rendering finishes, save the current price as the 'previous price'.
// This is for use in the NEXT render.
prevPriceRef.current = price;
}, [price]);
const prevPrice = prevPriceRef.current;
const direction = price > prevPrice ? 'Up 🔺' : 'Down 🔻';
return <div>{price}KRW ({direction})</div>;
}This pattern perfectly replaces the functionality of the componentDidUpdate(prevProps) lifecycle method, which functional components lack.
4. Caution: Do Not Read During Rendering
There is an iron rule you must follow when using useRef.
"Do not read or write ref.current inside rendering logic (the return statement or the component body)."
function BadComponent() {
const count = useRef(0)
// ❌ Forbidden: Rendering results become unpredictable.
count.current = count.current + 1;
// ❌ Forbidden: Do not display ref values directly on the screen.
// (Since the screen doesn't update when the value changes, the user sees stale data.)
return <div>{count.current}</div>;
}Modifying and reading refs must only happen inside Event Handlers or useEffect. If something needs to be drawn on the screen, that is the job of useState, not useRef.
Key Takeaways
We have mastered how to handle variables. Next, it's time to handle relationships between components.
What if a parent wants to focus an input inside a child component? We used to use forwardRef for this task, which was impossible with props alone, but this syntax is changing completely in React 19.
Continuing in: “The Retirement of forwardRef and useImperativeHandle: Now, ref is Just a Prop”