--- title: "React useRef for Event Handler State — Avoid Stale useState in Pointer Events" aliases: [react-useref-drag-state, pointer-event-stale-state, useref-event-handler] tags: [react, hooks, useref, usestate, events, drag, testing] sources: - "daily/2026-05-01.md" created: 2026-05-01 updated: 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 ```tsx // ❌ BROKEN — pointerMove sees stale null because useState is async function DraggableList({ cues }) { const [draggingCueIndex, setDraggingCueIndex] = useState(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 ```tsx // ✅ CORRECT — ref is readable immediately in the same event tick function DraggableList({ cues }) { const draggingCueIndexRef = useRef(null); const [draggingCueIndex, setDraggingCueIndex] = useState(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 ```tsx // ❌ 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) ```tsx // ❌ TypeScript error TS2448: Block-scoped variable 'items' used before its declaration const sortedItems = useMemo(() => [...items].sort(), [items]); // references items const [items, setItems] = useState([]); // declared after // ✅ Always declare useState before useMemo that depends on it const [items, setItems] = useState([]); 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 - [[wiki/concepts/zustand-async-hydration]] — another async state access pattern where data is not available synchronously on first render - [[wiki/tech-patterns/react-vite-typescript]] — React + Vite + TypeScript stack where this pattern applies - [[wiki/concepts/websocket-react-token-guard]] — React `useEffect` + async state dependency management ## 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