Ref it right: using refs to optimize React effects
Update: This article was written before React shipped a built-in solution. Since React 19.2, the pattern described here is available as useEffectEvent. The ref approach still works on older versions, and it’s a good way to understand what the hook does under the hood.
Performance optimization in React can be tricky, especially when it comes to unnecessary rerenders. We usually reach for memoization, like useMemo and useCallback, to deal with them. They help, but sometimes they don't fully solve the problem. In this article, I'll go through a lesser-known but really useful pattern that uses refs to stop effects from rerunning when they don't need to.
The problem: effects rerunning in React
Let's look at a common custom hook, useEventListener. This is how you'd normally write it:
import { useEffect } from 'react';
function useEventListener(eventName, handler) {
useEffect(() => {
document.addEventListener(eventName, handler);
return () => {
document.removeEventListener(eventName, handler);
};
}, [eventName, handler]);
}
The hook looks fine, but there's an issue hiding in it that can lead to performance problems. Here's how it's used inside a component:
import React from 'react';
import useEventListener from './useEventListener';
function MyComponent() {
const handleClick = (event) => {
// Handle the click event here
};
useEventListener('click', handleClick);
// Rest of the component's code
}
The issue is the handleClick function. Every time the component renders, a new handleClick function is created. So the useEffect inside useEventListener sees a new dependency on every render, even though the function does exactly the same thing.
That means the effect reruns, detaching and reattaching the event listener over and over, even when the dependency that actually matters (eventName) hasn't changed. It gets worse if the effect does something heavier, like connecting to a socket instead of attaching an event listener. As the app grows and the component rerenders more often, this can turn into a real performance bottleneck.
Common solutions: do they work?
useCallback
At first glance, useCallback looks like the answer. It memoizes the callback so it keeps the same reference across renders, which should stop the effect from rerunning. Here's how it's used:
import { useCallback } from 'react';
function MyComponent() {
// state definition
const handleClick = useCallback((event) => {
// Handle the click event here
}, [someState]);
useEventListener('click', handleClick);
// Rest of the component's code
}
useCallback helps, but it doesn't fully solve the problem. As soon as one of the callback's dependencies changes (someState here), the effect in useEventListener reruns and the event listener gets reattached. It happens less often than before, but it still happens. On top of that, you don't want your codebase littered with useCallback wrappers every time someone uses this hook. It's boilerplate, and it makes the code harder to maintain.
Removing handler from the effect dependencies
Another idea is to just remove the handler from the dependencies:
import { useEffect } from 'react';
function useEventListener(eventName, handler) {
useEffect(() => {
document.addEventListener(eventName, handler);
return () => {
document.removeEventListener(eventName, handler);
};
// Exclude `handler` from dependency array
}, [eventName]);
}
It looks like a quick fix, but careful. If you use the handler inside the effect without listing it as a dependency, everyone will start shouting at you, and the first one to shout is eslint!
Jokes aside, this does stop the handler from rerunning the effect. But it introduces a new problem: the listener keeps using the first version of the handler, not the latest one. If the handler changes between renders, the event fires a stale version of it. So this doesn't work either.
Ref it right: using refs
As you might have guessed, the solution is useRef, and this one actually works. Here's the improved useEventListener:
import { useEffect, useRef } from 'react';
function useEventListener(eventName, handler) {
const handlerRef = useRef();
useEffect(() => {
handlerRef.current = handler;
}, [handler]);
useEffect(() => {
const eventListener = (...args) => handlerRef.current(...args);
document.addEventListener(eventName, eventListener);
return () => {
document.removeEventListener(eventName, eventListener);
};
}, [eventName]);
}
We use handlerRef to hold the handler function. The first effect runs whenever the handler changes, so the ref always has the latest version.
The second effect is where the trick is. It creates an eventListener function that reads handlerRef.current and calls it with whatever arguments it receives. Since eventListener closes over handlerRef, it always gets the latest handler, no matter how many times the component renders. This works because a ref keeps the same object reference for the whole lifetime of the component.
The nice thing about refs is that you don't need to add them to effect dependencies. Even if you do, it changes nothing, since the reference never changes.
Wrapping up
That's it. This problem is pretty common, and you've probably run into it in almost every React app you've worked on.
What I find interesting is that this pattern is used a lot, especially in libraries, but it's rarely talked about explicitly, which is why I wanted to write about it. Maybe it has a name I don't know about?
It's also worth noting that this is a React-specific problem. It comes from React's mental model of rerunning everything on every render. If you've used Vue, Svelte, or Solid, you probably haven't hit this, since they render differently.
Hope this helps next time you run into it!