| title |
aliases |
tags |
sources |
created |
updated |
| React useRef for Event Handler State — Avoid Stale useState in Pointer Events |
| react-useref-drag-state |
| pointer-event-stale-state |
| useref-event-handler |
|
| react |
| hooks |
| useref |
| usestate |
| events |
| drag |
| testing |
|
|
2026-05-01 |
2026-05-01 |
React useRef for Event Handler State — Avoid Stale useState in Pointer Events
React 18 useState setters are asynchronous — state updates are batched and do not take effect until the next render. Event handlers registered on the same element fire in the same tick, which means a pointerMove handler that runs immediately after pointerDown will read the pre-update (stale) state value set in pointerDown. The solution is to use useRef for flags that event handlers must read synchronously.
Key Points
useState setters are async: setDraggingIndex(i) in pointerDown is still null when pointerMove fires in the same event tick
useRef holds a mutable .current value that is visible synchronously to all code in the same render cycle
- Use
useRef for drag-in-progress flags, selected index tracking, and any state that event handlers must read without waiting for a re-render
- React Testing Library
fireEvent does not flush state synchronously — wrap state-dependent assertions in act()
useMemo hooks must be declared after all useState variables they reference (TypeScript TS2448: "used before declaration")
Details
The Stale State Bug
// ❌ BROKEN — pointerMove sees stale null because useState is async
function DraggableList({ cues }) {
const [draggingCueIndex, setDraggingCueIndex] = useState<number | null>(null);
const handlePointerDown = (index: number) => (e: React.PointerEvent) => {
setDraggingCueIndex(index); // schedules update — NOT yet applied
};
const handlePointerMove = (e: React.PointerEvent) => {
if (draggingCueIndex === null) return; // ← always null! fires in same tick
// drag logic never executes
};
}
The Fix: useRef for Synchronous Access
// ✅ CORRECT — ref is readable immediately in the same event tick
function DraggableList({ cues }) {
const draggingCueIndexRef = useRef<number | null>(null);
const [draggingCueIndex, setDraggingCueIndex] = useState<number | null>(null);
// ↑ useState still used for re-render trigger (visual feedback)
const handlePointerDown = (index: number) => (e: React.PointerEvent) => {
draggingCueIndexRef.current = index; // synchronous, readable immediately
setDraggingCueIndex(index); // triggers re-render for UI update
e.currentTarget.setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: React.PointerEvent) => {
if (draggingCueIndexRef.current === null) return; // ✓ correct value
// drag logic executes correctly
};
const handlePointerUp = () => {
draggingCueIndexRef.current = null;
setDraggingCueIndex(null);
};
}
Testing: act() Requirement
// ❌ BROKEN — assertion runs before state flush
fireEvent.pointerDown(element, { pointerId: 1 });
expect(screen.getByTestId("dragging-indicator")).toBeInTheDocument();
// Fails: state not yet applied
// ✅ CORRECT — wrap in act() to flush state updates
import { act } from "@testing-library/react";
act(() => {
fireEvent.pointerDown(element, { pointerId: 1 });
});
expect(screen.getByTestId("dragging-indicator")).toBeInTheDocument();
useMemo Declaration Order (TS2448)
// ❌ TypeScript error TS2448: Block-scoped variable 'items' used before its declaration
const sortedItems = useMemo(() => [...items].sort(), [items]); // references items
const [items, setItems] = useState<string[]>([]); // declared after
// ✅ Always declare useState before useMemo that depends on it
const [items, setItems] = useState<string[]>([]);
const sortedItems = useMemo(() => [...items].sort(), [items]);
Decision Guide: useState vs useRef
| Use case |
Correct hook |
| Value that triggers a re-render (visual state) |
useState |
| Value read synchronously in event handlers |
useRef |
| Value that needs both (drag index) |
Both — useRef for sync read, useState for render |
| DOM node reference |
useRef |
Related Concepts
Sources
- daily/2026-05-01.md — Sessions 15:31 and 19:07: drag-to-reorder feature broke because
pointerMove saw stale draggingCueIndex; fixed by introducing draggingCueIndexRef; also discovered useMemo ordering TS2448 and act() requirement in tests