DEV Community

Heni Yangui
Heni Yangui

Posted on

πŸš€ Ref It Right: Leveraging Refs to Optimize React Effects

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]);
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
saayacode profile image
Aziz Bouali

Insightful thank you 😁