React 19 useEffectEvent: Solving the useEffect Closure Problem

8 min read1553 words

Every React developer has hit the stale closure problem in useEffect. You set up an event listener, but it captures old state values. You add the state to dependencies, and the effect runs too often. You reach for refs as a workaround, and your code becomes a tangled mess. React 19's useEffectEvent hook finally solves this cleanly.

The Stale Closure Problem

Here's the scenario I've encountered dozens of times. You want to debounce a search input and call a callback with the query:

// The problematic version
function SearchInput({ onSearch }: { onSearch: (query: string) => void }) {
  const [query, setQuery] = useState('');
 
  useEffect(() => {
    const timeout = setTimeout(() => {
      onSearch(query); // Which version of query? Which version of onSearch?
    }, 500);
 
    return () => clearTimeout(timeout);
  }, [query, onSearch]); // ESLint forces both dependencies
 
  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search..."
    />
  );
}

The problems cascade:

  1. If onSearch isn't memoized by the parent, it's a new function every render. The effect re-runs constantly, defeating debouncing.

  2. Adding onSearch to dependencies means any parent re-render triggers the effect.

  3. Removing onSearch from dependencies violates exhaustive-deps and risks calling a stale callback.

The Ref Workaround

Before React 19, the standard solution involved refs:

function SearchInput({ onSearch }: { onSearch: (query: string) => void }) {
  const [query, setQuery] = useState('');
  const onSearchRef = useRef(onSearch);
 
  // Sync the ref with the latest callback
  useLayoutEffect(() => {
    onSearchRef.current = onSearch;
  }, [onSearch]);
 
  useEffect(() => {
    const timeout = setTimeout(() => {
      onSearchRef.current(query); // Always the latest callback
    }, 500);
 
    return () => clearTimeout(timeout);
  }, [query]); // Only query in dependencies
 
  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
    />
  );
}

This works, but the pattern is verbose and easy to get wrong. You need useLayoutEffect to update the ref synchronously before effects run. Every callback in your effect needs this treatment.

useEffectEvent Eliminates the Boilerplate

React 19's useEffectEvent creates a stable function that always reads the latest values:

import { useEffectEvent } from 'react';
 
function SearchInput({ onSearch }: { onSearch: (query: string) => void }) {
  const [query, setQuery] = useState('');
 
  const handleSearch = useEffectEvent((searchQuery: string) => {
    onSearch(searchQuery); // Always the current onSearch
  });
 
  useEffect(() => {
    const timeout = setTimeout(() => {
      handleSearch(query);
    }, 500);
 
    return () => clearTimeout(timeout);
  }, [query]); // handleSearch doesn't need to be in dependencies
 
  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
    />
  );
}

The handleSearch function is stable across renders. It doesn't need to be in the dependency array. But when called, it reads the current onSearch prop, not a stale closure.

How useEffectEvent Works

Think of useEffectEvent as creating a portal to the current render's values. The function identity stays the same, but its body executes with fresh values.

const logCurrentCount = useEffectEvent(() => {
  console.log(count); // Always logs the current count
});
 
useEffect(() => {
  const interval = setInterval(() => {
    logCurrentCount(); // Stable function, fresh values
  }, 1000);
 
  return () => clearInterval(interval);
}, []); // Empty deps - effect never re-runs

Without useEffectEvent, this would log the same count forever (the value when the effect first ran). With it, each call logs the current value.

Real-World Pattern: Analytics Tracking

Analytics hooks often need to access current route or user data without re-subscribing to events:

function usePageTracking() {
  const pathname = usePathname();
  const user = useUser();
 
  const trackPageView = useEffectEvent(() => {
    analytics.track('page_view', {
      path: pathname,
      userId: user?.id,
      timestamp: Date.now(),
    });
  });
 
  useEffect(() => {
    trackPageView();
  }, [pathname]); // Re-track on route change, not user change
}

The analytics call always has the current user data, but the effect only runs when the pathname changes. Without useEffectEvent, you'd need both pathname and user in dependencies, triggering analytics on every user state update.

Pattern: WebSocket Message Handlers

WebSocket connections shouldn't reconnect when handlers change:

function useWebSocket(url: string, onMessage: (data: any) => void) {
  const handleMessage = useEffectEvent((event: MessageEvent) => {
    const data = JSON.parse(event.data);
    onMessage(data); // Always current handler
  });
 
  useEffect(() => {
    const ws = new WebSocket(url);
 
    ws.addEventListener('message', handleMessage);
 
    return () => {
      ws.close();
    };
  }, [url]); // Reconnect only when URL changes
}
 
// Usage
function Chat({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);
 
  useWebSocket(`wss://chat.example.com/${roomId}`, (data) => {
    // This callback can reference any current state
    setMessages((prev) => [...prev, data]);
  });
 
  return <MessageList messages={messages} />;
}

The parent component passes a fresh callback every render (it closes over setMessages). Without useEffectEvent, each render would create a new WebSocket connection. With it, the connection stays stable while the handler always uses the current callback.

Pattern: Click Outside Detection

Custom hooks for detecting clicks outside an element benefit significantly:

function useClickOutside(
  ref: RefObject<HTMLElement>,
  handler: (event: MouseEvent) => void,
  enabled = true
) {
  const handleClickOutside = useEffectEvent((event: MouseEvent) => {
    if (ref.current && !ref.current.contains(event.target as Node)) {
      handler(event);
    }
  });
 
  useEffect(() => {
    if (!enabled) return;
 
    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, [enabled]); // Only enabled affects subscription
}
 
// Usage
function Dropdown({ onClose }: { onClose: () => void }) {
  const ref = useRef<HTMLDivElement>(null);
 
  useClickOutside(ref, () => {
    onClose(); // Always current onClose
  });
 
  return <div ref={ref}>{/* dropdown content */}</div>;
}

The hook's API is clean. Consumers don't need to memoize their handlers. The hook handles staleness internally.

useEffectEvent vs useCallback

These hooks solve different problems:

useCallback memoizes a function for render-phase stability. Use it when passing callbacks to child components or as dependencies:

// Child component receives stable callback
const handleClick = useCallback(() => {
  doSomething(value);
}, [value]);
 
return <ExpensiveChild onClick={handleClick} />;

useEffectEvent creates effect-internal event handlers that access current values without dependency tracking:

// Effect-internal handler, not passed to children
const onTick = useEffectEvent(() => {
  console.log(currentValue);
});
 
useEffect(() => {
  const id = setInterval(onTick, 1000);
  return () => clearInterval(id);
}, []);

Key differences:

| Aspect | useCallback | useEffectEvent | |--------|-------------|----------------| | Purpose | Memoize for render stability | Stable handlers in effects | | Dependency array | Required | None | | Fresh values | Only when deps change | Always | | Use case | Pass to children | Effect event listeners | | Can be in deps | Yes | No (intentionally excluded) |

When Arguments Matter

Sometimes you need to pass reactive values as arguments rather than accessing them from scope:

function useVisitTracker(roomId: string) {
  const onVisit = useEffectEvent((visitedRoomId: string) => {
    logVisit(visitedRoomId, user.id); // user.id from scope (always current)
  });
 
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('visit', () => {
      onVisit(roomId); // Pass roomId as argument
    });
 
    return () => connection.disconnect();
  }, [roomId]); // Reconnect when roomId changes
}

The roomId is passed as an argument because it's the reactive value that should trigger effect re-runs. The user.id is accessed from scope because we always want the current user, not the user at connection time.

Limitations to Understand

React 19+ only: This hook isn't backported to React 18. For older versions, continue using the ref pattern.

Effect-internal only: Don't use useEffectEvent for callbacks passed to child components. It's not designed for render-phase use:

// Wrong - don't do this
const handleClick = useEffectEvent(() => { /* ... */ });
return <Button onClick={handleClick} />; // Button won't re-render correctly
 
// Right - use useCallback for component props
const handleClick = useCallback(() => { /* ... */ }, [deps]);
return <Button onClick={handleClick} />;

Not for derived values: If you're computing values that should trigger re-renders, use useMemo or regular state:

// Wrong - this is computation, not an event
const computedValue = useEffectEvent(() => items.filter(predicate));
 
// Right - use useMemo for derived values
const computedValue = useMemo(() => items.filter(predicate), [items, predicate]);

Migration from Ref Pattern

If you're using the ref pattern today, migration is straightforward:

// Before
const callbackRef = useRef(callback);
useLayoutEffect(() => {
  callbackRef.current = callback;
}, [callback]);
 
useEffect(() => {
  element.addEventListener('event', () => {
    callbackRef.current();
  });
}, []);
 
// After
const onEvent = useEffectEvent(() => {
  callback();
});
 
useEffect(() => {
  element.addEventListener('event', onEvent);
}, []);

Remove the ref, remove the useLayoutEffect sync, wrap the callback access in useEffectEvent.

TypeScript Integration

useEffectEvent has full TypeScript support:

import { useEffectEvent } from 'react';
 
interface Message {
  id: string;
  content: string;
}
 
function useMessageHandler(onMessage: (msg: Message) => void) {
  // Type is inferred: (msg: Message) => void
  const handleMessage = useEffectEvent((msg: Message) => {
    onMessage(msg);
  });
 
  // handleMessage has the correct type throughout
  useEffect(() => {
    socket.on('message', handleMessage);
    return () => socket.off('message', handleMessage);
  }, []);
}

The return type matches the function you provide, maintaining type safety.

The Mental Model

Think of effect events as "event handlers for effects." Just like onClick handlers automatically see current state without dependencies, effect events let your effects have handlers that see current values.

Regular code in effects captures values at effect-run time. Effect events capture values at call time. This distinction makes many patterns simpler and eliminates an entire category of bugs around stale closures.

For any situation where you're reaching for refs to keep callbacks fresh in effects, useEffectEvent is likely the cleaner solution. It's one of those hooks that makes you wonder how we managed without it.