obsidian/wiki/concepts/react-useref-event-handler-state.md
2026-05-05 11:17:44 +01:00

4.9 KiB

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
daily/2026-05-01.md
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

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