React 19 useEffectEvent: Solving the useEffect Closure Problem
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:
-
If
onSearchisn't memoized by the parent, it's a new function every render. The effect re-runs constantly, defeating debouncing. -
Adding
onSearchto dependencies means any parent re-render triggers the effect. -
Removing
onSearchfrom 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-runsWithout 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.