116 lines
4.9 KiB
Markdown
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
|