DEV Community

KernelX
KernelX

Posted on

Why Your React Keyboard Handler Always Reads Old State

You add a keyboard shortcut with useEffect. It works — until it doesn't. After a few state updates, the handler starts behaving as if nothing changed. Values are stale. Actions fire on outdated data.

This is the stale closure problem, and it bit me hard while building BlockTeXu, a block-based visual LaTeX editor.

Site Top View


The Setup: A Global Keyboard Handler

BlockTeXu has keyboard shortcuts throughout: Ctrl+Z for undo, Delete to remove blocks, Enter to confirm dialogs. The natural way to add these is:

useEffect(() => {
  const handleKeyDown = (e: KeyboardEvent) => {
    if (e.key === 'Delete') {
      deleteSelectedBlock(selectedBlockId);  // ← uses state
    }
    if (e.ctrlKey && e.key === 'z') {
      undo();
    }
  };

  window.addEventListener('keydown', handleKeyDown);
  return () => window.removeEventListener('keydown', handleKeyDown);
}, []);  // ← empty dependency array
Enter fullscreen mode Exit fullscreen mode

This seems fine. But there's a subtle bug hiding here.


The Problem: Closures Capture Values, Not References

When handleKeyDown is created inside useEffect, it closes over the current value of selectedBlockId. With an empty dependency array [], the effect runs only once — on mount. The event listener is registered once and never re-registered.

Here's what that means:

Mount:  selectedBlockId = null  → handler captures null
User clicks block A:  selectedBlockId = 'block-A'
User presses Delete:  handler still sees null  ← stale!
Enter fullscreen mode Exit fullscreen mode

The function you passed to addEventListener is frozen in time. It will always read the value that existed when it was first created.

Key Insight: This isn't a React bug or an event listener quirk — it's how JavaScript closures work. The function literally holds a reference to the scope that existed at creation time, not a live pointer to current state.


Attempted Fix: Add Everything to the Dependency Array

The obvious fix:

useEffect(() => {
  const handleKeyDown = (e: KeyboardEvent) => {
    if (e.key === 'Delete') {
      deleteSelectedBlock(selectedBlockId);
    }
  };

  window.addEventListener('keydown', handleKeyDown);
  return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedBlockId, deleteSelectedBlock, undo, /* ... */]);
Enter fullscreen mode Exit fullscreen mode

This works, but has a cost: the listener is removed and re-added every time any dependency changes. With many state values in the dependency array, you get constant register/unregister cycles. For a global keyboard handler that touches most of the app's state, this becomes impractical.

You'd also need to be careful about referential stability of the callbacks (useCallback on everything they depend on), which adds more boilerplate and more opportunities to miss a dependency.


The Solution: useRef as a Stable Bridge

The cleanest fix uses useRef to bridge between the stable event listener and the current state:

// 1. Keep a ref that always points to the latest handler
const handlerRef = useRef<(e: KeyboardEvent) => void>(() => {});

// 2. Update the ref on every render — this is intentional
useEffect(() => {
  handlerRef.current = (e: KeyboardEvent) => {
    if (e.key === 'Delete') {
      deleteSelectedBlock(selectedBlockId);
    }
    if (e.ctrlKey && e.key === 'z') {
      undo();
    }
    // ... all your keyboard logic
  };
});  // no dependency array — runs every render

// 3. Register a stable listener that calls through the ref
useEffect(() => {
  const stable = (e: KeyboardEvent) => handlerRef.current(e);
  window.addEventListener('keydown', stable);
  return () => window.removeEventListener('keydown', stable);
}, []);  // runs only once
Enter fullscreen mode Exit fullscreen mode

Why this works:

  • The stable listener (stable) is registered once and never removed until unmount
  • stable always calls handlerRef.current — a ref lookup, not a closure capture
  • The ref's value is updated on every render with a fresh function that closes over current state
  • When the user presses a key, stable fires, reads handlerRef.current, and gets the latest handler

The ref acts as a mutable variable that the stable function can "look through" at runtime.

Mount:       stable = (e) => handlerRef.current(e)   ← registered once
Render 1:    handlerRef.current = fn that sees selectedBlockId = null
User clicks: selectedBlockId = 'block-A'
Render 2:    handlerRef.current = fn that sees selectedBlockId = 'block-A'
Key press:   stable() → handlerRef.current() → sees 'block-A' ✅
Enter fullscreen mode Exit fullscreen mode

The Complete Pattern

Here's the full implementation in BlockTeXu:

function useKeyboardHandler(
  blocks: PlacedBlock[],
  selectedId: number | null,
  onDelete: (id: number) => void,
  onUndo: () => void,
  onRedo: () => void,
) {
  const handlerRef = useRef<(e: KeyboardEvent) => void>(() => {});

  // Update ref every render
  useEffect(() => {
    handlerRef.current = (e: KeyboardEvent) => {
      // Ignore if focus is inside an input/textarea
      const target = e.target as HTMLElement;
      if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;

      if (e.key === 'Delete' && selectedId !== null) {
        e.preventDefault();
        onDelete(selectedId);
      }
      if (e.ctrlKey && e.key === 'z') {
        e.preventDefault();
        onUndo();
      }
      if (e.ctrlKey && e.key === 'y') {
        e.preventDefault();
        onRedo();
      }
    };
  });

  // Register stable listener once
  useEffect(() => {
    const handler = (e: KeyboardEvent) => handlerRef.current(e);
    window.addEventListener('keydown', handler);
    return () => window.removeEventListener('keydown', handler);
  }, []);
}
Enter fullscreen mode Exit fullscreen mode

One important detail: if target is INPUT/TEXTAREA, return early. Without this, pressing Delete while typing in an input field would trigger your delete shortcut. Always guard global key handlers against active form elements.


React 19.2 Update: useEffectEvent

After publishing the Japanese version of this article on Zenn, a reader pointed out that React 19.2 (released as stable) ships useEffectEvent — an official solution to exactly this problem.

import { useEffectEvent } from 'react';

function useKeyboardHandler(selectedId: number | null, onDelete: ...) {
  const handleKeyDown = useEffectEvent((e: KeyboardEvent) => {
    if (e.key === 'Delete' && selectedId !== null) {
      onDelete(selectedId);
    }
  });

  useEffect(() => {
    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, []);  // no need to list selectedId in deps
}
Enter fullscreen mode Exit fullscreen mode

useEffectEvent creates a function that:

  • Always reads the latest values (like our useRef approach)
  • Has a stable identity (like a ref wrapper)
  • Is excluded from dependency lint rules (React knows it's always fresh)

The useRef pattern we built manually is essentially what useEffectEvent does under the hood. The hook makes the intent explicit and removes the boilerplate.

As of React 19.2, useEffectEvent is the canonical solution. If you're on React 19.2+, use it directly. For earlier versions, the useRef bridge pattern is the way to go.


Comparison

Approach Pros Cons
Empty deps [] Simple Always reads stale state
Full deps array Correct Re-registers on every change, dependency hell
useRef bridge Correct, registers once Two useEffect calls, manual boilerplate
useEffectEvent Correct, clean Requires React 19.2+

Key Takeaways

  1. Stale closures happen when an event listener captures state from a specific render and never updates
  2. useRef bridge pattern: update a ref every render, register one stable listener that reads through the ref
  3. useEffectEvent (React 19.2+) is the official replacement — same semantics, no boilerplate
  4. Always guard global key handlers against INPUT/TEXTAREA focus

The useRef bridge isn't React-specific. The same pattern applies whenever you need a stable function reference that always reads current values — WebSocket message handlers, requestAnimationFrame callbacks, timer callbacks, and more.


Next post: How KaTeX + html-to-image produced blank white images — the SVG foreignObject font-loading problem and the two-call workaround.

Try BlockTeXu at blocktexu.com/en

Top comments (0)