🚀 Ref It Right: Leveraging Refs to Optimize React Effects
Tags: react, frontend, javascript, webdev
Published: 2023-07-26 10:12:51

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:

```jsx
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:

```jsx
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:

```jsx
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:

```jsx
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:

```jsx
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! 🚀