Skip to content

Storing Mutable Values with useRef

useRef is not just for DOM access. It can hold any mutable value that persists across renders without causing re-renders. Unlike useState, changing ref.current does not trigger a component update — making refs ideal for values that need to persist but don’t affect the UI directly.


function Timer() {
const intervalRef = useRef(null);
const [seconds, setSeconds] = useState(0);
const start = () => {
intervalRef.current = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
};
const stop = () => {
clearInterval(intervalRef.current);
intervalRef.current = null;
};
useEffect(() => {
return () => clearInterval(intervalRef.current); // Cleanup on unmount
}, []);
return (
<div>
<p>{seconds}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}

Why a ref? The interval ID needs to be accessible from both start and stop handlers, but it doesn’t affect the UI. Using state would cause unnecessary re-renders.

function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value; // Store after render
});
return ref.current; // Return previous value (before the effect ran)
}
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>Now: {count}, Before: {prevCount ?? '—'}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
);
}
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
const roomIdRef = useRef(roomId);
// Keep the ref in sync with the prop
useEffect(() => {
roomIdRef.current = roomId;
}, [roomId]);
useEffect(() => {
const socket = new WebSocket(`wss://chat.example.com/${roomId}`);
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
// Use the ref to avoid stale closure issues
console.log(`New message in room ${roomIdRef.current}`);
setMessages(prev => [...prev, message]);
};
return () => socket.close();
}, [roomId]); // Effect re-runs when roomId changes
}
function RenderCounter() {
const renderCount = useRef(0);
renderCount.current += 1; // Safe: no re-render triggered
return <p>Rendered {renderCount.current} times</p>;
}
function useCache(compute, deps) {
const cache = useRef(null);
const prevDeps = useRef(null);
// If deps haven't changed, return cached result
if (prevDeps.current && deps.every((d, i) => d === prevDeps.current[i])) {
return cache.current;
}
// Deps changed — recompute and cache
prevDeps.current = deps;
cache.current = compute();
return cache.current;
}

ScenarioBest ChoiceWhy
Timer/interval IDuseRefDoesn’t affect UI
Previous valueuseRefDoesn’t trigger re-render
Callback ref for stale closureuseRefStable reference across renders
Render counteruseRefNo re-render needed
Displayed valueuseStateUI must update
Form inputuseStateUI must reflect value

⚠ Using ref for displayed values

// ❌ BAD — typing in input won't update the display
const text = useRef('');
<input onChange={e => text.current = e.target.value} />
<p>You typed: {text.current}</p> // Never updates!
// ✅ GOOD — useState triggers re-render
const [text, setText] = useState('');
<input onChange={e => setText(e.target.value)} />
<p>You typed: {text}</p>

  • useRef can store any mutable value, not just DOM nodes.
  • Changing ref.current does NOT trigger a re-render.
  • Use refs for: interval IDs, previous values, avoiding stale closures, caching, render counts.
  • Use useState for values that the UI needs to display.