CodeTips#11: useEffect the right way

Why useEffect is necessary and why we should avoid it when it comes to data fetching

Recently, I’ve been delving into the basics of front-end development with React, one observation I’ve made is that many people in the front-end world usually retrieve their data with useEffect. In this article, I will explain why this might not be ideal in most situations and how we can substitute or improve this approach. But first, we must understand "what" side effects are and how the useEffect hook works.

Navigation

  1. Understanding Side Effects in React
  2. How useEffect Works
  3. Why useEffect is Needed
  4. Why We Shouldn’t Fetch Data with useEffect
  5. What Alternatives Do We Have?
  6. Conclusion
  7. References

Understanding Side Effects in React

In React, side effects refer to consequences that occur outside the component. React components should ideally remain entirely pure, which entails refraining from modifying any objects or variables that existed before their invocation and ensuring consistent output for the same input. Consider the following example:

function Square({ number }) {
  return (
    <div>
      The square of {number} is {number * number}.
    </div>
  );
}

While this example appears straightforward, it adheres to the fundamental rules outlined above, fostering predictability in our codebase.

However, there are scenarios where introducing side effects into our code becomes necessary, such as when triggering animations or fetching data from external APIs. In such cases, we employ React event handler functions executed by React when specific actions occur, such as clicking a button. But when we need these side effects just after the component mounts we can use the useEffect hook.

How useEffect Works

The useEffect hook runs on the client side immediately after the initial render, and it takes two arguments: a function with the effects and a dependency array, where you should put your effects dependencies. When the re-render happens, the function will trigger again if some of the props in the dependency array have changed since the last render.

useEffect(() => {
  // Effect function
  console.log(randomValue);
}, [randomValue]); // Dependency array

Another thing that we should be aware of in useEffect is that inside the effects function, we can return a cleanup function that will be called just before the component unmounts. We can use it to revert any changes made by effects, like closing connections, removing event listeners, and cleaning timeouts. We can also use it to avoid race conditions when fetching data.

useEffect(() => {
  const options = { roomId };
  const connection = createConnection();
  connection.connect();

  return () => connection.disconnect(); // Cleanup function
}, [roomId]);

When useEffect is Needed

useEffect allows React components to synchronize with external systems. This synchronization is critical due to React’s distinct lifecycle phases: mounting, rendering, and unmounting. However, external APIs do not often seamlessly align with these phases. Consequently, useEffect is indispensable for synchronizing with external systems, as React lacks awareness of operations like data fetching or analytics.

One example where we need the useEffect hook is to implement the functionality to close a modal component when the user presses the Escape key, here we utilize the useEffect hook to synchronize our code with the user’s actions. With the useEffect hook, we can detect when the user clicks the Escape key and trigger the action to close the modal in response.

const Modal = ({ closeModal }) => {
  React.useEffect(() => {
    function handleKeyDown(event) {
      if (event.code === "Escape") {
        closeModal();
      }
    }
    window.addEventListener("keydown", handleKeyDown);

    return () => {
      window.removeEventListener("keydown", handleKeyDown);
    };

  }, []);

  // Rest of the component
  return <div>.....
};

To handle key events from the user, we add an event listener for the "keydown" event. These events occur outside the control of our component and are external to our system. Therefore, we use the useEffect hook to synchronize them with the Modal component.

Using useEffect, we ensure that the keydown event listener is properly managed and is in sync with the lifecycle of the Modal component, effectively handling the user’s key events and executing the corresponding code.

Why We Shouldn’t Fetch Data with useEffect

It can be a bit counterintuitive, right? If useEffect is meant to synchronize external data, why shouldn’t we fetch data with it? Well, the simple answer is that we have better options than useEffect.

In real projects, we usually don’t want to fetch the data and call it a day. If we do, we may run into issues like race conditions, unnecessary bandwidth usage, errors breaking the application, etc.

To improve a project, we can set up a cache to reduce unnecessary bandwidth usage, manage errors, implement retries for failed fetches, and prevent race conditions through deduplication. With this, however, we may encounter challenges in scaling, maintenance, and debugging.

What Alternatives Do We Have?

If you work using a framework based on React like Next.js, it already has a built-in data fetching mechanism. Also, if you are in the newer versions, you can fetch the data on the server-side.

Something that works in both React and its frameworks are libraries like SWR and React Query. These libraries will handle most of the trouble fetching data you might have and require fewer lines of code.

Ok, but what if we are not using a framework and use bare-bones React to fetch data instead? React’s official documents advise handling race conditions by ignoring old requests. And whenever possible, extracting the useEffect logic into a custom hook to make the code more maintainable.

Look at this example:

useEffect(() => {
  let ignore = false;

  fetch(url)
    .then((response) => response.json())
    .then((json) => {
      if (!ignore) {
        setData(json);
      }
    });

  return () => {
    ignore = true;
  };
}, [url]);

This code uses a special technique from React’s useEffect called "ignoring". For instance, imagine you have a button that fetches data. When you click the button continuously, it can be frustrating if the component doesn’t update as expected.

In this case, when the component is about to unmount (for example, when you navigate away), it sets a flag called ignore to true. This means that any subsequent updates to the data will be ignored.

For example, let’s say you click page 1, then quickly click page 2 before the first request finishes. Normally, if the first request takes longer to complete than the second one, you would see the result of the first request (page 1) even though you clicked on page 2.

Using this "ignoring" technique, when the component unmounts after clicking page 1, it sets ignore to true. Then, when the data from page 1 finally arrives, the code checks if ignore is still false and since it’s now true, it ignores the update, ensuring that the component won’t update unnecessarily if you click multiple times quickly.

Conclusion

Due to scalability issues, it’s better to avoid fetching data solely via useEffect. Consider leveraging external libraries or utilizing NextJS or other React-based frameworks.

If you read along so far, thank you for your attention. We should always strive to make our code maintainable and easy to debug. I hope that this article helped you understand useEffect a little better.

References

We want to work with you. Check out our "What We Do" section!