common mistakes

Incorrect Usage of useEffect

The code provided in this article is simplified as much as possible to avoid overwhelming the reader with code and to convey the essence of the problem and its solution. In interactive examples, heading tags (`h1...h6`) are replaced with span tags to prevent potential conflicts with the main page structure.

The useEffect hook is likely one of the first hooks a React developer learns about. In the era of class components, componentDidMount was a common lifecycle function used to assign event listeners. In functional components, this role was taken over by useEffect. However, incorrect usage of the useEffect hook can lead to the creation of multiple event listeners, ultimately degrading performance and causing unexpected bugs.

The main issues with incorrect useEffect usage

  • Creating multiple event listeners: When useEffect is called without dependencies or with incorrect dependencies, it may trigger on every component render. This results in the creation of new event listeners each time, without removing the old ones. As a result, more and more event listeners are created on each render, slowing down the application and leading to memory leaks.
  • Lack of effect cleanup: If a cleanup function is not provided, old event listeners aren't removed, also causing memory leaks. The cleanup function should be returned from the function passed to useEffect, and it will be called before each subsequent effect run, when one of the dependencies in the dependency array changes, and when the component is unmounted.

Practical advices

Use dependencies correctly

Always specify reactive values (such as props, state, and other values declared inside the component) in the dependencies array. These values are recalculated during rendering, can change due to a re-render, and participate in the React data flow. Including them in the dependencies ensures that the code inside useEffect runs only when these values change.

This practice helps avoid unnecessary re-runs of the effect. It's also important to understand the behavior of useEffect depending on the dependencies array:

  • If no dependencies array is specified, the code inside useEffect runs on every render.
  • If an empty dependencies array is specified, the code will run only once — when the component is mounted.

Let's take a look at an example:

Code implementation
1interface ContainerProps {
2 selectedBgColor: string;
3}
4
5const Container: FC<ContainerProps> = ({ selectedBgColor }) => {
6 const containerRef = useRef<HTMLDivElement | null>(null);
7
8 useEffect(() => {
9 if (!containerRef || !containerRef.current) {
10 return;
11 }
12
13 if (selectedBgColor === 'gray') {
14 containerRef.current.classList.add('gray_bg');
15 } else {
16 containerRef.current.classList.remove('gray_bg');
17 }
18 }, [selectedBgColor]);
19
20 return <div ref={containerRef}></div>;
21};
22

In this example, the useEffect hook is used to update the class of a DOM element based on the value of the selectedBgColor prop.

  • The effect re-runs only when selectedBgColor changes, ensuring that unnecessary updates are avoided. For instance, if the component re-renders because its parent re-renders but selectedBgColor remains the same, the effect will not re-run.
  • The inclusion of selectedBgColor in the dependencies array is crucial. Since it's a reactive value used inside useEffect, omitting it could lead to stale or incorrect behavior.

On the other hand, containerRef is not included in the dependencies array because its reference remains stable across renders. This is a key consideration when working with refs, as they don't trigger re-renders or updates.

Effect cleanup

Always include a cleanup function in effects that create or modify global states or subscribe to events. The cleanup function, defined after the return keyword, is executed before the effect runs again — either when the component unmounts or when a dependency changes. This practice prevents memory leaks, avoids unintended behavior, and ensures the application functions correctly.

For example, the following demonstrates how to use useEffect to handle a window resize event and clean up the event listener when the component unmounts:

Code implementation
1useEffect(() => {
2 const handleResize = () => {
3 console.log('Resized');
4 };
5
6 window.addEventListener('resize', handleResize);
7
8 return () => {
9 window.removeEventListener('resize', handleResize);
10 };
11}, []);
12

This approach ensures the event listener is properly removed and avoids potential memory leaks.

Decompose effects

If useEffect performs multiple unrelated tasks, it's better to split them into separate useEffect hooks, each with its own appropriate dependencies. This not only makes the code more readable and manageable but also helps avoid unnecessary re-runs of code inside useEffect. This is especially important if one of the values specified in the dependencies array changes frequently, while the logic associated with another value is complex.

Let's consider an example:

Code implementation
1interface ContainerProps {
2 selectedBgColor: string;
3}
4
5const Container: FC<ContainerProps> = ({ selectedBgColor }) => {
6 const [state, setState] = useState(true);
7
8 const containerRef = useRef<HTMLDivElement | null>(null);
9
10 useEffect(() => {
11 if (!containerRef || !containerRef.current) {
12 return;
13 }
14
15 if (selectedBgColor === 'gray') {
16 containerRef.current.classList.add('gray_bg');
17 } else {
18 containerRef.current.classList.remove('gray_bg');
19 }
20
21 if (state) {
22 // Here is some logic that uses a state variable
23 }
24 }, [selectedBgColor, state]);
25
26 const handleClick = () => {
27 setState((prev) => !prev);
28 };
29
30 return (
31 <div ref={containerRef}>
32 <button onClick={handleClick}>Set State</button>
33 </div>
34 );
35};
36

Here, useEffect handles two reactive values that are not logically related. A change in the state variable doesn't affect the class modification logic, which depends solely on the selectedBgColor prop. If the state variable changes frequently and triggers more complex operations, the application's performance could degrade. Therefore, a better approach would be to split this single useEffect into two separate ones:

Code implementation
1interface ContainerProps {
2 selectedBgColor: string;
3}
4
5const Container: FC<ContainerProps> = ({ selectedBgColor }) => {
6 const [state, setState] = useState(true);
7
8 const containerRef = useRef<HTMLDivElement | null>(null);
9
10 useEffect(() => {
11 if (!containerRef || !containerRef.current) {
12 return;
13 }
14
15 if (selectedBgColor === 'gray') {
16 containerRef.current.classList.add('gray_bg');
17 } else {
18 containerRef.current.classList.remove('gray_bg');
19 }
20 }, [selectedBgColor]);
21
22 useEffect(() => {
23 if (state) {
24 // Some logic based on the state variable
25 }
26 }, [state]);
27
28 const handleClick = () => {
29 setState((prev) => !prev);
30 };
31
32 return (
33 <div ref={containerRef}>
34 <button onClick={handleClick}>Set State</button>
35 </div>
36 );
37};
38

In this example, the useEffect hook is split into two separate hooks, making the code more organized and clear. The first effect handles the addition/removal of the class based on the selectedBgColor value, while the second effect manages logic tied to the internal state of the component. This structure improves performance by ensuring that each effect runs only when its specific dependencies change, avoiding unnecessary re-renders and making the code easier to maintain.

Correct usage of the useEffect hook is critical for creating efficient and stable React applications. Understanding when and how to use dependencies, how to clean up effects, and when to split logic helps avoid common mistakes and improves the performance of your application. Pay attention to these aspects, and your components will work predictably and efficiently.

Last updated on by @skrykateSuggest an improvement on Github repository.