---
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
