A common misconception in React is expecting components to re-render when values stored in useRef
change. This misunderstanding can lead to bugs where UI doesn’t update as expected.
The Core Behavior of useRef
useRef
creates a mutable object that persists across renders, but changing it does not trigger re-renders:
function Counter() {
const countRef = useRef(0); // { current: 0 }
const increment = () => {
countRef.current += 1; // ❌ No re-render
console.log(countRef.current); // Value updates but UI stays same
};
return (
<div>
<button onClick={increment}>Increment</button>
<p>Count: {countRef.current}</p> {/* Won't update! */}
</div>
);
}
Why useRef Doesn’t Cause Re-renders
- Performance Optimization: Refs are designed for mutable values that shouldn’t affect rendering
- Explicit Updates: React only re-renders in response to state/props changes
- Escape Hatch: Refs provide a way to store values outside React’s reactivity system
When to Use useRef vs useState
Feature | useRef | useState |
---|---|---|
Triggers re-render | ❌ No | ✅ Yes |
Value persistence | ✅ Between renders | ✅ Between renders |
Use case | DOM references, mutable values that shouldn’t trigger updates | Data that should drive UI updates |
Correct Patterns
1. Using useRef for Non-UI Values
function Timer() {
const intervalRef = useRef(null); // Good for timer IDs
useEffect(() => {
intervalRef.current = setInterval(() => {
console.log('Tick');
}, 1000);
return () => clearInterval(intervalRef.current);
}, []);
// ... No need to display intervalRef in UI
}
2. Combining with useState for Updates
function Counter() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
const increment = () => {
const newCount = countRef.current + 1;
countRef.current = newCount;
setCount(newCount); // ✅ Triggers re-render
};
return (
<div>
<button onClick={increment}>Increment</button>
<p>Count: {count}</p> {/* Updates correctly */}
</div>
);
}
Common Mistakes and Fixes
❌ Expecting UI Updates from Ref Changes
function UserProfile() {
const userRef = useRef({ name: 'Alice' });
const updateName = () => {
userRef.current.name = 'Bob'; // ❌ No re-render
};
return (
<div>
<p>{userRef.current.name}</p> {/* Stays 'Alice' */}
<button onClick={updateName}>Update</button>
</div>
);
}
✅ Fix: Use state for UI-bound values
function UserProfile() {
const [user, setUser] = useState({ name: 'Alice' });
const updateName = () => {
setUser({ ...user, name: 'Bob' }); // ✅ Triggers re-render
};
return (
<div>
<p>{user.name}</p> {/* Updates to 'Bob' */}
<button onClick={updateName}>Update</button>
</div>
);
}
Advanced Patterns
1. Accessing Previous State
function Counter() {
const [count, setCount] = useState(0);
const prevCountRef = useRef();
useEffect(() => {
prevCountRef.current = count; // Update ref after render
});
return (
<div>
<p>Current: {count}, Previous: {prevCountRef.current}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
);
}
2. Imperative Handle Pattern
function FancyInput(props, ref) {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus(),
value: () => inputRef.current.value
}));
return <input ref={inputRef} />;
}
Debugging Tips
- Log ref values in useEffect to verify updates:
useEffect(() => {
console.log('Ref value:', myRef.current);
});
- Use React DevTools to inspect ref values
- Verify whether you really need UI updates (use state) or just value persistence (use ref)
Key Takeaways
useRef
changes don’t trigger re-renders – useuseState
if you need UI updates- Refs are perfect for:
- Storing mutable values that shouldn’t affect rendering
- DOM node references
- Previous value tracking
- Combine refs with state when you need both persistence and reactivity
- Always access ref values via the
.current
property
Understanding this distinction helps prevent bugs where your component’s UI doesn’t update as expected while properly leveraging refs for their intended purposes.