How to Use useRef for Managing Mutable Values Without Re-renders
Understand and Master the useRef Hook in React.js

If you've spent any time working with React hooks, you've probably encountered situations where you need to store a value that persists across renders but doesn't need to trigger a re-render when it changes. That's exactly where useRef shines, and understanding when and how to use it can make your React applications significantly more efficient.
What Makes useRef Different?
At first glance, useRef might seem similar to useState. Both persist values across renders, both are React hooks, and both are tools for managing component state. But here's the crucial difference: when you update a ref, your component doesn't re-render. When you update state with useState, it does.
Think of useRef as a box that holds a value. You can put something in the box, take it out, or replace it with something else—all without React caring about what you're doing. The component won't re-render, no matter how many times you change what's inside.
const countRef = useRef(0);
// This will NOT cause a re-render
countRef.current = countRef.current + 1;
Compare this to useState:
const [count, setCount] = useState(0);
// This WILL cause a re-render
setCount(count + 1);
When Should You Actually Use useRef?
The question isn't just "can I use useRef?" but "should I use useRef?" Here are the scenarios where it makes the most sense.
Accessing DOM Elements Directly
This is probably the most common use case, and it's the one that feels most natural coming from vanilla JavaScript. Sometimes you just need direct access to a DOM node—maybe to focus an input, measure an element's dimensions, or integrate with a third-party library that expects a DOM reference.
function TextInputWithFocus() {
const inputRef = useRef(null);
const handleFocus = () => {
inputRef.current.focus();
};
return (
<div>
<input ref={inputRef} type="text" />
<button onClick={handleFocus}>Focus the input</button>
</div>
);
}
This pattern is clean, straightforward, and doesn't introduce any unnecessary re-renders.
Storing Previous Values
One particularly useful pattern is storing the previous value of a prop or state. This lets you compare current and previous values without the complexity of additional state management.
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
function Counter({ count }) {
const previousCount = usePrevious(count);
return (
<div>
<p>Current: {count}</p>
<p>Previous: {previousCount}</p>
<p>Changed by: {count - (previousCount || 0)}</p>
</div>
);
}
This works because useEffect runs after the render, so ref.current still holds the old value during the render phase.
Managing Timers and Intervals
If you've ever tried to clear a timer in a React component, you know the pain of losing the timer ID between renders. useRef solves this elegantly.
function Timer() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null);
const startTimer = () => {
if (intervalRef.current !== null) return;
intervalRef.current = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
};
const stopTimer = () => {
if (intervalRef.current === null) return;
clearInterval(intervalRef.current);
intervalRef.current = null;
};
useEffect(() => {
return () => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
}
};
}, []);
return (
<div>
<p>Seconds: {seconds}</p>
<button onClick={startTimer}>Start</button>
<button onClick={stopTimer}>Stop</button>
</div>
);
}
The ref keeps track of the interval ID across renders, and we can reliably clear it when needed.
Avoiding Stale Closures
Here's where things get interesting. Sometimes you need the latest value of a prop or state inside a callback that doesn't re-create on every render. This is where refs can save you from the dreaded stale closure problem.
function SearchComponent() {
const [query, setQuery] = useState('');
const latestQueryRef = useRef(query);
useEffect(() => {
latestQueryRef.current = query;
}, [query]);
const handleSearch = useCallback(() => {
// This always has the latest query value
// even though handleSearch doesn't change
fetch(`/api/search?q=${latestQueryRef.current}`)
.then(response => response.json())
.then(data => console.log(data));
}, []); // Empty deps - function never recreates
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button onClick={handleSearch}>Search</button>
</div>
);
}
Common Pitfalls and How to Avoid Them
Don't Read Refs During Render
This is the biggest mistake developers make with useRef. Reading ref.current during the render phase can lead to inconsistent behavior because refs don't trigger re-renders.
// ❌ Bad - reading ref during render
function BadExample() {
const countRef = useRef(0);
const increment = () => {
countRef.current++;
};
// This will always show 0 because changing the ref doesn't re-render
return <div>{countRef.current}</div>;
}
// ✅ Good - use state for values that affect the UI
function GoodExample() {
const [count, setCount] = useState(0);
const increment = () => {
setCount(count + 1);
};
return <div>{count}</div>;
}
Remember: Refs are Synchronous
Unlike state updates, which React batches and processes asynchronously, ref updates happen immediately and synchronously.
function Example() {
const ref = useRef(0);
const handleClick = () => {
ref.current = 1;
console.log(ref.current); // Logs 1 immediately
ref.current = 2;
console.log(ref.current); // Logs 2 immediately
};
}
This can be both a blessing and a curse. It's great when you need immediate access to a value, but it means you need to be careful about when and how you update refs.
useRef vs useState: Making the Right Choice
The decision between useRef and useState comes down to one key question: does this value need to trigger a re-render when it changes?
Use useState when:
The value affects what's rendered on screen
You need React to respond to changes in the value
The value is part of your component's visual state
Use useRef when:
The value doesn't affect the rendered output
You need to store metadata about your component
You're interacting with DOM elements directly
You need to avoid triggering re-renders for performance
Advanced Pattern: Combining useRef with useEffect
One powerful pattern is using refs to track whether a component has mounted or to prevent certain effects from running on the initial render.
function ComponentWithMountCheck() {
const [data, setData] = useState(null);
const hasMounted = useRef(false);
useEffect(() => {
if (!hasMounted.current) {
hasMounted.current = true;
return;
}
// This only runs on updates, not on mount
console.log('Data changed:', data);
saveDataToLocalStorage(data);
}, [data]);
return <div>{/* Your component */}</div>;
}
This pattern is particularly useful when you want different behavior on mount versus subsequent updates.
Performance Considerations
Using useRef appropriately can significantly improve your component's performance. Since refs don't cause re-renders, they're perfect for storing values that change frequently but don't need to update the UI.
Consider a scroll position tracker:
function ScrollTracker() {
const scrollPositionRef = useRef(0);
const [visibleSection, setVisibleSection] = useState('top');
useEffect(() => {
const handleScroll = () => {
scrollPositionRef.current = window.scrollY;
// Only update state (and trigger re-render) when crossing thresholds
if (scrollPositionRef.current > 500 && visibleSection !== 'bottom') {
setVisibleSection('bottom');
} else if (scrollPositionRef.current <= 500 && visibleSection !== 'top') {
setVisibleSection('top');
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [visibleSection]);
return <div>Current section: {visibleSection}</div>;
}
Here, we're tracking every scroll event with a ref (no re-renders), but only updating state when it actually matters for the UI.
Real-World Example: Debounced Search
Let's put it all together with a practical example that combines several concepts:
function DebouncedSearch() {
const [searchTerm, setSearchTerm] = useState('');
const [results, setResults] = useState([]);
const timeoutRef = useRef(null);
const requestRef = useRef(null);
const performSearch = async (term) => {
if (requestRef.current) {
requestRef.current.abort();
}
const controller = new AbortController();
requestRef.current = controller;
try {
const response = await fetch(`/api/search?q=${term}`, {
signal: controller.signal
});
const data = await response.json();
setResults(data);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Search failed:', error);
}
}
};
const handleSearchChange = (e) => {
const value = e.target.value;
setSearchTerm(value);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
if (value.trim()) {
performSearch(value);
} else {
setResults([]);
}
}, 300);
};
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (requestRef.current) {
requestRef.current.abort();
}
};
}, []);
return (
<div>
<input
type="text"
value={searchTerm}
onChange={handleSearchChange}
placeholder="Search..."
/>
<ul>
{results.map(result => (
<li key={result.id}>{result.title}</li>
))}
</ul>
</div>
);
}
This example demonstrates several key useRef patterns: managing timers, handling cleanup, and tracking mutable values that don't need to cause re-renders.
Wrapping Up
The useRef hook is one of those tools that seems simple on the surface but reveals its true power once you understand when and how to use it properly. It's not about replacing useState—it's about complementing it. Use refs for implementation details that don't affect the rendered output, and you'll write more efficient, cleaner React code.
The key takeaway? If changing a value should update what the user sees, use useState. If it's just something you need to remember between renders without affecting the UI, reach for useRef. Master this distinction, and you'll avoid a lot of common React performance issues while writing more maintainable code.






