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

116 lines
4.9 KiB
Markdown

---
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<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
```tsx
// ✅ 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
```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<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
- [[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