useRef Beyond DOM Refs
useRef Creates a Mutable Box
Most people think of useRef as "the thing you use to access DOM elements." That's only half the story. useRef(initialValue) returns { current: initialValue } — a plain object with one mutable property. This object persists for the full lifetime of the component. Changing .current does not trigger a re-render.
const ref = useRef(0);
ref.current = 42; // No re-render
ref.current = 100; // No re-render
// The component function is NOT called again
This makes refs the perfect tool for values that need to persist across renders but are not part of the visual output.
Think of useRef as a pocket notebook that persists between renders. You can write anything in it (ref.current = value) and read it back later. React never looks at the notebook — it does not know when you write in it and does not re-render when you do. State is the whiteboard that React watches. Refs are the notebook in your pocket.
Pattern 1: Previous Value Tracking
Ever needed "what was the value before this render?" Turns out, that's a ref pattern:
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value; // Update after render
});
return ref.current; // Return value from previous render
}
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<p>
Current: {count}, Previous: {prevCount}
</p>
);
}
The timing is critical: ref.current is read during render (returning the old value), then updated in useEffect (which runs after render). On the next render, ref.current holds the previous render's value.
Pattern 2: Interval and Timer Management
function Stopwatch() {
const [time, setTime] = useState(0);
const [running, setRunning] = useState(false);
const intervalRef = useRef(null);
function start() {
if (intervalRef.current) return; // Prevent multiple intervals
setRunning(true);
intervalRef.current = setInterval(() => {
setTime(t => t + 1);
}, 1000);
}
function stop() {
setRunning(false);
clearInterval(intervalRef.current);
intervalRef.current = null;
}
function reset() {
stop();
setTime(0);
}
useEffect(() => {
return () => clearInterval(intervalRef.current); // Cleanup on unmount
}, []);
return (
<div>
<p>{time}s</p>
<button onClick={running ? stop : start}>
{running ? 'Stop' : 'Start'}
</button>
<button onClick={reset}>Reset</button>
</div>
);
}
The interval ID is stored in a ref because:
- It does not affect rendering
- It must persist across renders (start and stop are different events)
- State would cause unnecessary re-renders
Pattern 3: Render Count
function RenderCounter() {
const renderCount = useRef(0);
renderCount.current += 1;
return <p>This component has rendered {renderCount.current} times</p>;
}
Using state for this would cause an infinite loop: setState triggers a render, which increments the count, which calls setState again. Refs break the loop because changing .current does not trigger a render.
Pattern 4: Stable Callback (Escape Hatch)
This one's a gem. When you need the latest version of a callback without adding it to effect dependencies:
function useLatestCallback(callback) {
const ref = useRef(callback);
useEffect(() => {
ref.current = callback;
});
return useCallback((...args) => ref.current(...args), []);
}
// Usage: event handler that always sees latest state
function Chat({ roomId }) {
const [messages, setMessages] = useState([]);
const onMessage = useLatestCallback((message) => {
setMessages(prev => [...prev, message]);
// Can access latest roomId, messages, etc.
});
useEffect(() => {
const connection = connectToRoom(roomId);
connection.on('message', onMessage);
return () => connection.off('message', onMessage);
}, [roomId, onMessage]); // onMessage is stable — effect only re-runs when roomId changes
}
The useEvent proposal and refs as callbacks
The React team proposed useEvent (RFC #220) to solve this exact pattern. useEvent would create a stable function that always reads the latest props and state. Until useEvent ships, the ref-based pattern above is the standard workaround. The React Compiler may make this unnecessary by automatically memoizing event handlers.
Pattern 5: Accessing the Imperative DOM
Beyond simple element references, refs enable complex DOM interactions:
function AutoScrollList({ items }) {
const listRef = useRef(null);
const prevItemsLength = useRef(items.length);
useEffect(() => {
// Auto-scroll only when new items are added
if (items.length > prevItemsLength.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
prevItemsLength.current = items.length;
}, [items.length]);
return (
<ul ref={listRef} style={{ maxHeight: 300, overflow: 'auto' }}>
{items.map(item => <li key={item.id}>{item.text}</li>)}
</ul>
);
}
Never read ref.current during render to make conditional rendering decisions. Refs are mutable and untracked by React. In concurrent mode, React may call your component multiple times before committing, and each call could see a different ref value. Read refs in effects and event handlers only.
| What developers do | What they should do |
|---|---|
| Using state to track interval IDs, previous values, or render counts State triggers re-renders. Refs do not. For non-visual data that must persist across renders, refs are the correct choice. | Use refs — these values do not affect rendering and should not trigger re-renders |
| Forgetting to clean up intervals stored in refs Refs do not auto-clean. If the component unmounts with a running interval, it leaks. | Clear intervals in cleanup: useEffect(() => () => clearInterval(ref.current), []) |
| Reading ref.current during render to decide what to display Refs are mutable and untracked. Reading during render is unreliable with concurrent features. | Read refs only in effects and event handlers |
| Using useRef when state is needed — component shows stale data Changing ref.current does not trigger a re-render. The UI will not update. | If the value affects the UI, use state. Refs are invisible to React. |
- 1useRef creates a mutable { current } box that persists without causing re-renders
- 2Use refs for: interval IDs, previous values, render counts, DOM references, stable callbacks
- 3Never read ref.current during render to make UI decisions — read in effects and handlers only
- 4Always clean up resources stored in refs (intervals, subscriptions, connections) in effect cleanup
- 5If the value affects what the user sees, use state. If not, use refs.
Challenge: Build a usePreviousDistinct Hook
Challenge: Previous Distinct Value Tracker
// Build a hook that tracks the previous DISTINCT value.
// If the value does not change, previous should not update.
//
// Example:
// value: 1 → 1 → 2 → 2 → 3
// prev: undefined → undefined → 1 → 1 → 2
//
// Note: usePrevious would give: undefined → 1 → 1 → 2 → 2
function usePreviousDistinct(value) {
// Your implementation here
}
Show Answer
function usePreviousDistinct(value) {
const prevRef = useRef();
const currentRef = useRef(value);
if (!Object.is(currentRef.current, value)) {
prevRef.current = currentRef.current;
currentRef.current = value;
}
return prevRef.current;
}How it works:
currentReftracks the most recently seen distinct valueprevReftracks the value before the current distinct value- On each render, we check if the new value differs from the last distinct value
- Only when it differs do we shift: previous gets the old current, current gets the new value
- If the same value is passed repeatedly, neither ref changes
This runs during render (not in an effect) because we need the comparison to happen before the return. Since we only write to refs (no state changes), this is safe during render.