The React useEffect hook

Published January 8, 2022

The React useEffect hook is used to perform side-effects in components.

Here are some examples of common side effects:

  • data fetching
  • subscriptions
  • manually changing the DOM

Using the useEffect hook

The useEffect hook accepts 2 arguments:

useEffect(callback, [dependencies]);
  • Callback: The callback is the function that performs the logic for a side-effect. The callback is executed right after changes were being pushed to DOM.

  • Dependencies: The dependencies array is an optional array of dependencies. When dependencies are specified, useEffect will executes its callback function only if the dependencies have changed between renderings. The dependencies argument is used to control when the side-effect should run.

useEffect and the component lifecycle

We can configure the useEffect hook to perform side effects in a component at different times. These include:

  • After every render/re-render
  • After the first render
  • After certain props or state variables change
// This gets called after every render 
// (the first render, and every render after that)
useEffect(() => {    
  console.log('[useEffect] on every render');
});

// This gets called on the first render
useEffect(() => {    
  console.log('[useEffect] on first render');
}, []);

// This gets called on the first render 
// and when the prop or state variable 'name' changes
useEffect(() => {    
  console.log('[useEffect] on first render and name change');
}, [name]);

It's important that we configure useEffect to run at the right time and that we try to avoid unnecessarily re-running effects because they can affect performance.

Organizing side-effects

It's a good practice to avoid performing side-effects directly in the body of the component, which is where we return the HTML output that is displayed on the screen.

const Greeting = ({ name }) => {
  const message = `Hi ${name}`;
  // Bad practice
  document.title = `Greetings ${name}`;
  return <div>{message}</div>;
}

The updating of the document title is a side-effect here because it's not part of the component output in the return statement.

We should also avoid updating the document title every time the Greeting component renders. We should only set the document title when the name prop of the component changes.

import { useEffect } from 'react';

const Greeting = ({ name }) => {
  const message = `Hi ${name}`;
  
  useEffect(() => {
    document.title = `Greetings ${name}`;
  }, [name]);

  return <div>{message}</div>;
};

We fixed the Greeting component by moving the updating of the document title in the callback of useEffect. We added name as a dependency to useEffect so that it doesn't run unnecessarily on every component render, but only when the name actually changes between renders.

Asynchronous functions

The useEffect hook is a good place for fetching component data client-side. The useEffect hook itself cannot be an asynchronous function. However, it can invoke asynchronous functions.

useEffect(() => {
  if (!url) {
    return;
  }

  const fetchData = async () => {
    const response = await fetch(url);
    const data = await response.json();
    console.log(data);
  };

  fetchData();
}, [url]);

Let's assume that we have a URL from where to fetch data from. We can add a url variable to the useEffect hook as a dependency. Within the useEffect callback function, if the url is not defined, we exit the function. However, if the url is defined, then we call the fetchData() function asynchronously.

Notice that we do not need any await keyword before the call to fetchData(). The useEffect hook automatically handles this for us.

Within the fetchData() function, we use the Fetch API to fetch data from the url and then output it to the console.

Side-effect cleanup

Some side-effects performed by the useEffect hook need to be cleaned up. Here are some examples:

  • Closing a web socket
  • Clearing timers

We can create a side-effect cleanup by making the callback function of useEffect return a function.

useEffect(() => {
  // side-effect
  return function cleanup() {
    // side-effect cleanup function
  };
}, [dependencies]);

After the component's first render, useEffect will invoke its callback that performs a side-effect. The cleanup function is not invoked at this point.

On subsequent component renderings, useEffect will invoke the cleanup function from the previous side-effect that was executed. It does this to clean up after the previous side-effect. Once the execution of the cleanup function is complete, useEffect will then run the current side-effect for the current rendering of the component.

When a component is unmounted, that is, removed from view on the screen, useEffect will also invoke its cleanup function to clean up after the latest side-effect.

Side-effect cleanup example

Let's take a look at an example of cleaning up a side-effect.

const Counter = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const key = setInterval(() => {
      console.log(count);
      // triggers a component re-render      
      setCount((prevCount) => prevCount + 1);
    }, 1000);

    // cleanup function
    return () => clearInterval(key);
  }, [count]);

  return <span>{count}</span>;
};

This component increments a counter by one every second. The cleanup function is used to cancel the previous timer before starting a new one. This is to avoid spawning a new timer every second and having many of them running at the same time.

After 1 second, setCount updates count to 1. The state update triggered by setCount causes the component to re-render. When the component re-renders, it calls the useEffect cleanup function to stop the previous timer. Once the cleanup function is complete, useEffect proceeds by starting a new timer that waits 1 second before updating count to 2 with setCount.

What happens if we don't include a cleanup function as the last line of the useEffect function? The answer is that the clearInterval function will never be called and a new interval will start every second. This would cause multiple intervals to run simultaneously, making the count value jump uncontrollably in large increments all the way to infinity.

The dependency pitfall

Here is an example of a common pitfall with the useEffect hook related to missing dependencies.

const ChangeCounter = () => {
  const [value, setValue] = useState("");
  const [count, setCount] = useState(-1);

  // Danger!
  useEffect(() => setCount(prevCount => prevCount + 1));

  const onChange = ({ target }) => {
    setValue(target.value);
  };

  return (
    <div>
      <input type="text" value={value} onChange={onChange} />
      <div>{count} changes.</div>
    </div>
  );
};

This component counts the number of changes performed on a text box by the user.

When this component loads, there is an immediate infinite loop problem. Why is that? Let's take a closer look at what's going on.

The dependency pitfall problem

Since useEffect has no dependency provided, it will run on the first component render and every re-render after that. The callback of useEffect makes use of setCount which updates the state of count and causes a component re-render. The useEffect callback will run on every re-render and trigger a new re-render, creating an infinite loop where count increments quickly to infinity, without any changes to the text box.

The dependency pitfall solution

We can fix this by providing the right dependencies for useEffect.

useEffect(() => {
  setCount(prevCount => prevCount + 1);
}, [value, setCount]);

We provide value as a dependency, since we only want the counter to run when the value of the text box changes. We also provide setCount as a dependency, since the callback of useEffect makes use of this method to update the state of count.

In summary, the infinite loop of component renderings is caused by updating local state in useEffect without having any dependency argument. To avoid the infinite loop, we must properly manage useEffect’s dependencies. This allows us to control when the side-effect should run.

The object pitfall

Another common pitfall with the useEffect hook is the object pitfall. It occurs when we use objects as dependencies.

const ChangeCounter = () => {
  const [item, setItem] = useState({ value: "", count: -1 });

  useEffect(() => {
    // a new object for 'item' is created here
    setItem(prevItem => ({ ...prevItem, count: prevItem.count + 1 }));
  }, [item]);

  const onChange = ({ target }) => {
    setItem({ ...item, value: target.value });
  };

  return (
    <div>
      <input type="text" value={item.value} onChange={onChange} />
      <p>{item.count} changes.</p>
    </div>
  );
}

This component counts the number of changes performed on a text box by the user. Rather than just using local state to store the number of changes like the previous example, we now use an object to store the last value entered and the number of changes.

When this component loads, there is an immediate infinite loop problem. Why is that? Let's take a closer look at what's going on.

The object pitfall problem

Remember that a useEffect hook with a dependency argument of [] or [dependency], where dependency is a variable, will get called on the first component render. The item object is used as a dependency of useEffect. Inside the useEffectcallback, setItem is called. It increments the counter by one but it also creates a new object for item within setItem. Since item is now a new object, useEffect detects that its item dependency has changed and re-runs its callback function. This causes another setItem invocation that again updates the state with a new item object. Thus, we have an infinite loop problem.

The object pitfall solution

We can solve the infinite loop problem by changing the dependency from item to item.value. We only want the side-effect to run and increment the count for us when the item's value is updated by a change made to the text box.

useEffect(() => {
  setItem(prevItem => ({ ...prevItem, count: prevItem.count + 1 }));
}, [item.value]);

In summary, avoid using objects as useEffect dependencies. Use only a specific property of an object instead.

Conclusion

The useEffect hook manages side-effects in React function components. The callback argument holds the side-effect logic. The dependencies arugment allows you to add props or state variables to useEffect as dependencies.

The useEffect hook will invoke its callback function after the component has first mounted (first initial render), and then on subsequent component re-renders, if any of its dependencies has changed.

The useEffect hook can be an effective way to run asynchronous operations within a component.

Remember to avoid the dependency and object pitfalls to make the most effective use of the useEffect hook.