React useEffectEvent: A New Dimension for Reusable Hooks
Creating reusable hooks in React is a powerful tool, but it often leads to subtle problems with useEffect dependencies. Developers constantly reach for useRef to work around ever-changing functions and avoid unnecessary re-renders. React is introducing useEffectEvent to solve this problem in a more elegant and declarative way.
The Problem: Dependencies and the Closure Trap
In the standard approach, every external function or value used inside useEffect must be included in its dependency array. This mechanism guarantees that useEffect always operates on fresh data from the latest render.
The problem arises when one of the dependencies is a function (e.g., the onOutsideClick callback), which is often recreated on every render of the parent component.
- If you add it to the dependencies,
useEffectwill be needlessly re-triggered after every render, which can lead to performance issues (like constantly removing and adding event listeners). - If you omit it from the dependencies,
useEffectwill "remember" the function from the initial render. This is the so-called "stale closure" trap—the effect will forever call an old version of the function, ignoring any updates.
The "Old Way" with useRef
To work around this, a popular pattern was to store the latest version of the function in a reference (useRef). A ref is a mutable object that "survives" re-renders, which allows you to omit the function itself from the useEffect dependency array.
Here is a classic example of the useOnOutsideClick hook:
import React, { useEffect, RefObject } from "react";
type OnOutsideClickConfig = {
onOutsideClick?: (event: MouseEvent) => void;
enabled?: boolean;
};
const useOnOutsideClick = (
ref: RefObject<HTMLElement>,
config: OnOutsideClickConfig = {},
) => {
const { onOutsideClick, enabled = true } = config;
const configRef = React.useRef<OnOutsideClickConfig>(config);
useEffect(() => {
configRef.current = config;
}, [onOutsideClick, enabled]); // This useEffect only exists to update the ref
useEffect(() => {
if (!configRef.current.enabled) return;
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
configRef.current.onOutsideClick?.(event);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [enabled]); // onOutsideClick is not needed here
};
export type { OnOutsideClickConfig };
export { useOnOutsideClick };
This solution works, but it's not very intuitive and requires an extra useEffect just to update the reference, which obscures the code's intent.
The
useReftrick here is used to avoid the need to pass dependencies touseEffect, and it removes the responsibility from the consumer to useuseCallbackoruseMemowhenuseOnOutsideClickis used.
The New Approach: useEffectEvent
The useEffectEvent hook was designed to formally separate logic that should not be reactive from the effect itself, which is reactive. A function wrapped in useEffectEvent (called an "Effect Event") will always have access to the latest props and state, but it will never trigger a re-run of useEffect. Under the hood, React ensures that the reference to this function remains stable.
Refactoring useOnOutsideClick
Let's see how useEffectEvent simplifies our hook:
import { useEffect, useEffectEvent, RefObject } from "react";
type OnOutsideClick = (event: MouseEvent) => void;
const useOnOutsideClick = (
ref: RefObject<HTMLElement>,
onOutsideClick?: OnOutsideClick,
enabled: boolean = true,
) => {
// `onEvent` has a stable reference but will always call the latest `onOutsideClick`
const onEvent = useEffectEvent((event: MouseEvent) => {
if (!enabled) return; // Conditional logic moved into the event
if (ref.current && !ref.current.contains(event.target as Node)) {
onOutsideClick?.(event);
}
});
useEffect(() => {
document.addEventListener("mousedown", onEvent);
return () => {
document.removeEventListener("mousedown", onEvent);
};
}, []); // The dependencies are empty!
};
export { useOnOutsideClick };
The code is significantly cleaner and more readable. The need for useRef and the extra useEffect disappears. The outside click logic has been extracted into onEvent, and useEffect now only handles what is truly reactive: adding and removing the listener.
Note that for readability, we simplified the hook's API by moving from a config object to separate arguments. The key change, however, is the removal of useRef and the extra useEffect.
Going a Step Further: A Truly Empty Dependency Array
In the previous refactored version, ref and enabled might still seem like candidates for the useEffect dependency array. However, the final, most optimal version we've shown above has an empty array []. Why?
refis stable: Therefobject returned byuseReforcreateRefis guaranteed to have a stable reference throughout the component's lifecycle. It never changes, so putting it in the dependency array is redundant.enabledlogic moved to the event: Instead of conditionally runninguseEffectbased onenabled, we moved that logic inside theuseEffectEvent. This way,useEffectruns only once (attaching the listener), and theonEventhandler itself decides whether to execute its logic based on the current value ofenabled. This simplifies the effect's lifecycle—the listener is always active, but it only acts when needed.
This approach gives us the simplest and most performant useEffect, which runs only once when the component mounts and cleans up when it unmounts.
Two Directions for Hooks in React
The introduction of useEffectEvent highlights an interesting direction for React's API: a formal distinction between two types of logic in components.
-
Reactive Logic (Closure-dependent): This is the standard behavior of
useEffect. The effect is tightly coupled with the values from a specific render and must be re-run when those values change. A perfect example is data fetching:useEffect(() => { fetchData(productId); }, [productId]); // We want to fetch new data when `productId` changes. -
Non-reactive ("Event-like") Logic: This is the job for
useEffectEvent. It defines an event that can be called from within an effect. We want it to always have access to the latest data, but a change in the event's logic itself should not reset the effect. An example is analytics tracking:const onLog = useEffectEvent((details) => { logAnalytics('visit', { ...details, userId }); // `userId` is always up-to-date }); useEffect(() => { // ...logic for tracking a page visit onLog({ page: 'HomePage' }); }, []); // Runs only once, but `onLog` is never "stale".
Reacting to Complexity: A Comparison with Angular and a Vision for the Future
The evolution of React and the introduction of new APIs like useEffectEvent are part of a broader trend in the world of front-end frameworks. As powerful libraries mature, they inevitably become more complex, which can raise the barrier to entry for new developers.
Angular faced a similar challenge. Its advanced change detection system, based on Zone.js, was powerful but felt "magical" and difficult to debug for many developers (and IT WAS REALLY SLOW). The introduction of Signals was a conscious step toward simplifying the reactivity model, making it more explicit, predictable, and performant.
React is at an analogous point. The rules of hooks, particularly the need to manually manage dependency arrays in useEffect, useCallback, and useMemo, are one of the biggest challenges in learning React. It requires a deep understanding of closures and the rendering lifecycle.
The answer to this complexity is the React Compiler. This compiler, which has already reached a stable 1.0 version, aims to automate dependency management and memoization. Instead of forcing the developer to decide what to put in a dependency array, the compiler analyzes the code and optimizes it automatically.
The vision for the future, perhaps in React v20 or v21, is that thanks to the compiler, the library's API could be significantly simplified. Perhaps the need to use useCallback or useMemo in application code will disappear entirely, and useEffect will become far more intuitive. This is a direction that will lower the barrier to entry and allow developers to focus on business logic rather than micro-optimizations.
Summary
useEffectEvent is a powerful and long-awaited tool that solves a fundamental problem in creating reusable hooks. It allows us to write cleaner, more predictable code by eliminating the need for useRef workarounds. More importantly, it introduces a clear distinction between reactive logic and events. Looking at the bigger picture, this is a step towards a simpler and more accessible React, where tools like the React Compiler will lift the burden of managing complexity from developers, enabling faster and more intuitive application development.
Currently engaged in mentoring, sharing insights through posts, and working on a variety of full-stack development projects. Focused on helping others grow while continuing to build and ship practical solutions across the tech stack. Visit my Linkedin or my site for more 🌋🤝