Performance optimization in React can be a real challenge, especially when dealing with unnecessary rerenders and other performance bottlenecks. We often turn to memoization techniques, like useMemo
and useCallback
, to address these issues. However, while these can be effective, sometimes they donโt solve the issue completely. ๐ In this article, we'll explore a lesser-known, yet highly useful pattern that harnesses the power of refs to tackle performance issues caused by effect reexecution.
The Problem: Effect Reexecution in React ๐ค
To understand the issue, let's take a look at a common custom hook example - the useEventListener
hook. 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]);
}
This hook might seem perfectly fine, but there's an underlying issue that can lead to performance problems. Let's take a look at how this hook is 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 lies in the handleClick
function. Every time this component renders, a new instance of the handleClick
function is created. As a result, the useEffect
hook inside useEventListener
considers this function as a new dependency every time the component renders, even if the function's implementation remains the same.
This can lead to unwanted reexecutions of the effect, causing the event listener to be attached and detached repeatedly, even when the actual dependencies (i.e. eventName
) remain unchanged. This could get even worse if the example was actually connecting to external resources like sockets instead of attaching event listeners. As the application grows, and the component rerenders frequently, this can become a performance bottleneck.
Exploring Common Solutions: Do they work? ๐ต๏ธโโ๏ธ
The useCallback
solution ๐ฃ
At first glance, you might think useCallback
is the answer. After all, it creates a memoized version of the provided callback, ensuring that the callback maintains its reference across renders, which should prevent unnecessary reexecutions of effects. 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
}
While useCallback
is a good optimization, it does not solve the problem completely. The moment the callback dependencies change (i.e. someState
in this example), the effect in the useEventListener
hook will reexecute, and the event listener will be re-attached. While the reexecutions might be less frequent compared to defining the handler directly, it's still not an ideal solution for this particular problem. Not only that but you definitely donโt want the codebase to become littered with useCallback
wrappers every time you use this custom hook as it leads to boilerplate code and makes the code harder to maintain.
Removing handler
from effect dependencies ๐
โโ๏ธ
Another approach that might come to mind is removing the handler from the effect 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]);
}
While this change might seem like a quick fix, beware! If you use the handler inside the effect without including it in the dependencies, everyone will start shouting at you, and the first to shout is eslint! ๐ฃ๏ธ
On a serious note, this approach indeed prevents the handler from causing unnecessary reexecutions of the effect. However, it also introduces a new problem. When the event occurs, the effect will still use the initial instance of the handler function, not the latest one. This means that if the handler function changes between renders, the event listener might fire an outdated version of the handler. Therefore, this solution won't work as intended.
The Ref It Right Solution: Leveraging Refs ๐ฏ
As you might have guessed, this solution revolves around the useRef
hook, and it actually solves the problem! Let's take a closer look at the improved version of our useEventListener
hook:
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]);
}
With this updated implementation, we use the handlerRef
to store a reference to the handler
function. The first effect runs whenever the handler function changes, ensuring that the latest version of the handler is always stored in the ref.
Now comes the magic. In the second effect, we create a new eventListener
function that reads the handlerRef.current
value and calls it with the received arguments. Since the eventListener
closes over the handlerRef
, it will always access the latest version of the handler
, no matter how many times the component renders. This is due to the nature of refs, since, as their name suggests, they maintain the same object reference throughout the component lifecycle.
The cool thing about refs is that you donโt need to include them in effect dependencies, and even if you did, they donโt affect the dependencies array as they hold the same object reference.
Wrapping up ๐
That's it. Interestingly, this problem is quite common, and you wouldn't be alone if you've encountered it in almost every React app.
What's even more intriguing is that although this pattern is widely used, especially in libraries, it isn't often mentioned explicitly, which is why I felt compelled to share it with you. Maybe it has a name that I'm not aware of? Perhaps it's hiding in some obscure corner of the React ecosystem.
Itโs also worth noting that this is a react-specific problem that is a consequence of reactโs unique mental model of reexecuting everything on every rerender. If you're familiar with other frameworks like Vue, Svelte, or Solid, you probably haven't encountered this particular problem, as they follow different rendering paradigms.
I hope this article has been insightful and helps you in your React endeavors. Happy coding! ๐
Top comments (1)
Insightful thank you ๐