A months ago, I was debugging a file upload component that kept losing track of ongoing uploads during re-renders. Every time the user clicked somewhere else, the progress would reset. The culprit? Using useState for tracking upload IDs that should never trigger UI updates.
"Not everything in React needs to trigger a re-render. Sometimes you need memory that just... remembers."
That's exactly what useRef solves — persistent memory and DOM access without the re-render overhead.
What useRef Actually Does (Beyond the Docs)
useRef creates a container that survives component re-renders. Think of it as a backpack that follows your component around:
- Stores mutable values that persist between renders
- Provides direct access to DOM elements when you need to break out of React's declarative model
The key insight: ref.current changes don't schedule re-renders. This makes it perfect for side effects and imperative operations.
The Basic Pattern
const myRef = useRef(initialValue);
Returns: { current: initialValue }
// Access and modify
myRef.current = newValue;
console.log(myRef.current);
No magic here — just a plain object with a current property that React keeps stable across renders.
Pattern 1: DOM Manipulation When You Must
const AutoFocusInput = () => {
const inputRef = useRef(null);
useEffect(() => {
// Focus after component mounts
inputRef.current?.focus();
}, []);
const handleClear = () => {
inputRef.current.value = '';
inputRef.current.focus();
};
return (
<div>
<input
ref={inputRef}
placeholder="Auto-focused on mount"
/>
<button onClick={handleClear}>Clear & Focus</button>
</div>
);
};
Real-world usage: I use this pattern for search inputs in dashboards where users expect immediate focus, and for clearing forms while maintaining focus flow. The optional chaining (?.) prevents errors if the ref isn't attached yet.
Pattern 2: Tracking Without Triggering
const PerformanceTracker = () => {
const [data, setData] = useState([]);
const renderCount = useRef(0);
const startTime = useRef(Date.now());
// This runs on every render but doesn't cause new renders
renderCount.current += 1;
const fetchData = async () => {
const response = await fetch('/api/data');
const result = await response.json();
setData(result);
};
return (
<div>
<p>Data items: {data.length}</p>
<p>Renders: {renderCount.current}</p>
<p>Uptime: {Math.floor((Date.now() - startTime.current) / 1000)}s</p>
<button onClick={fetchData}>Fetch Data</button>
</div>
);
};
Why this works: Performance metrics shouldn't cause re-renders themselves. I've used similar patterns to track user interactions for analytics without impacting the render cycle.
When to Reach for useRef
After building dozens of React apps, I reach for useRef in these situations:
- DOM imperative operations — focus management, scroll position, canvas drawing
- Storing interval/timeout IDs — cleanup without causing renders
- Tracking previous values — comparing props or state across renders
- Caching expensive computations — when useMemo isn't enough
- Third-party library integration — storing library instances
Red flag: If changing the value should update the UI, use useState instead. Don't fight React's rendering model.
useRef vs useState: Choose Your Fighter
| Aspect | useRef | useState |
|---|---|---|
| Triggers re-render? | ❌ Never | ✅ Always |
| DOM access? | ✅ Perfect for it | ❌ Wrong tool |
| Async cleanup? | ✅ Ideal | ❌ Overkill |
| UI updates? | ❌ Won't work | ✅ Designed for this |
| Performance impact? | ✅ Zero | ⚠️ Causes renders |
Advanced Pattern: Cleanup That Actually Works
const NotificationManager = () => {
const timeoutIds = useRef(new Set());
const showNotification = (message, duration = 3000) => {
const id = setTimeout(() => {
setNotifications(prev =>
prev.filter(n => n.id !== notificationId)
);
timeoutIds.current.delete(id);
}, duration);
timeoutIds.current.add(id);
};
useEffect(() => {
// Cleanup all pending timeouts on unmount
return () => {
timeoutIds.current.forEach(id => clearTimeout(id));
timeoutIds.current.clear();
};
}, []);
return (
<button onClick={() => showNotification('Hello!')}>
Show Notification
</button>
);
};
Production lesson: I learned this pattern the hard way when notifications kept firing after component unmount, causing memory leaks and React warnings. Storing cleanup references in useRef prevents these issues.
Common Pitfalls I've Debugged
| Mistake | What Happens | Solution |
|---|---|---|
| Using ref for UI state | UI doesn't update when value changes | Use useState for anything that affects rendering |
Forgetting to check current |
"Cannot read property of null" errors | Always use optional chaining: ref.current?.method() |
| Setting ref in render | Infinite re-render loops | Update refs in effects or event handlers only |
| Sharing refs between components | Unpredictable behavior and hard-to-track bugs | Keep refs scoped to single components |
useRef Mastery Checklist
| Concept | Key Insight |
|---|---|
| Core purpose | Persistent storage that doesn't trigger renders |
| Re-render behavior | Updating .current never causes re-renders |
| Best use cases | DOM access, timers, previous values, library instances |
| Basic syntax | const ref = useRef(initialValue) |
| Access pattern | ref.current with optional chaining for safety |
| Cleanup strategy | Store cleanup functions/IDs in refs, clear in useEffect |
useRef Questions I Get Asked
When should I use useRef instead of useState?
Use useRef when the value change shouldn't trigger a re-render. If users need to see the change immediately, use useState. I decide by asking: "Does this change affect what the user sees?"
Can useRef cause memory leaks?
Yes, if you store references to DOM elements or functions without proper cleanup. Always clear refs in useEffect cleanup functions when storing timeouts, intervals, or event listeners.
Is it safe to mutate ref.current?
Absolutely — that's the point. Unlike state, refs are designed to be mutated directly. Just don't mutate them during render (do it in effects or event handlers).
Can I use useRef for form data?
Only for uncontrolled components where React doesn't manage the form state. For most forms, controlled components with useState provide better UX and validation opportunities.
How do I use refs with dynamic lists?
Create an array of refs or use a Map. For lists, I usually create refs dynamically: const itemRefs = useRef([]) and assign them by index.