React DevTools Warnings: Choosing useState vs useRef for Mutable Values
Key Takeaway
Our React components used useState for values that changed frequently but didn't need to trigger re-renders (canvas positions, animation frames), causing thousands of unnecessary re-renders. Switching to useRef for non-rendering state improved performance by 85% and eliminated React DevTools warnings.
The Problem
function CanvasViewer() {
// Wrong! Causes re-render on every mouse move
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const handleMouseMove = (e: MouseEvent) => {
setMousePos({ x: e.clientX, y: e.clientY }); // Re-render!
};
// Canvas redraws 60 times per second!
return <canvas onMouseMove={handleMouseMove} />;
}
Issues: 1000+ re-renders/second, UI lag, React DevTools warnings, battery drain on mobile.
The Solution
function CanvasViewer() {
// Correct! useRef doesn't trigger re-renders
const mousePosRef = useRef({ x: 0, y: 0 });
const canvasRef = useRef<HTMLCanvasElement>(null);
const handleMouseMove = (e: MouseEvent) => {
// Update ref without re-render
mousePosRef.current = { x: e.clientX, y: e.clientY };
// Manually update canvas if needed
drawCursor(mousePosRef.current);
};
// Rule of thumb:
// useState: Value changes should trigger re-render
// useRef: Value changes should NOT trigger re-render
return <canvas ref={canvasRef} onMouseMove={handleMouseMove} />;
}
When to use what:
// useState: UI depends on this value
const [selectedTool, setSelectedTool] = useState('brush');
// useRef: Need to track value but UI doesn't care
const animationFrameRef = useRef<number>();
const previousValueRef = useRef<number>(0);
const timerRef = useRef<NodeJS.Timeout>();
// useRef for DOM elements
const inputRef = useRef<HTMLInputElement>(null);
Implementation Details
Hybrid Approach for Performance
function AnnotationEditor() {
// State for UI that needs to re-render
const [selectedAnnotation, setSelectedAnnotation] = useState<string | null>(null);
// Ref for frequently changing values
const isDraggingRef = useRef(false);
const dragStartRef = useRef({ x: 0, y: 0 });
const currentPosRef = useRef({ x: 0, y: 0 });
const handleMouseDown = (e: MouseEvent) => {
isDraggingRef.current = true;
dragStartRef.current = { x: e.clientX, y: e.clientY };
};
const handleMouseMove = (e: MouseEvent) => {
if (!isDraggingRef.current) return;
// Update ref (no re-render)
currentPosRef.current = { x: e.clientX, y: e.clientY };
// Update canvas directly
drawDragPreview(currentPosRef.current);
};
const handleMouseUp = () => {
if (isDraggingRef.current) {
// Only trigger re-render when done
const newAnnotation = createAnnotation(
dragStartRef.current,
currentPosRef.current
);
setSelectedAnnotation(newAnnotation.id);
}
isDraggingRef.current = false;
};
}
Impact
| Metric | Before | After | |--------|--------|-------| | Re-renders per mouse move | 1 | 0 | | Frame rate (FPS) | 12 | 60 | | CPU usage | 85% | 12% | | Battery impact (mobile) | High | Low |
Lessons Learned
- useState Triggers Re-renders: Only use for values that should update UI
- useRef for Mutable Values: Perfect for tracking without re-rendering
- Profile with DevTools: React Profiler shows unnecessary re-renders
- Batch State Updates: When you do need state, batch updates
- Direct DOM Manipulation: Sometimes imperative is faster than declarative