Why is the cleanup function from `useEffect` called on every render?
Asked Answered
M

6

79

I've been learning React and I read that the function returned from useEffect is meant to do cleanup and React performs the cleanup when the component unmounts.

So I experimented with it a bit but found in the following example that the function was called every time the component re-renders as opposed to only the time it got unmounted from the DOM, i.e. it console.log("unmount"); every time the component re-renders.

Why is that?

function Something({ setShow }) {
  const [array, setArray] = useState([]);
  const myRef = useRef(null);

  useEffect(() => {
    const id = setInterval(() => {
      setArray(array.concat("hello"));
    }, 3000);
    myRef.current = id;
    return () => {
      console.log("unmount");
      clearInterval(myRef.current);
    };
  }, [array]);

  const unmount = () => {
    setShow(false);
  };

  return (
    <div>
      {array.map((item, index) => {
        return (
          <p key={index}>
            {Array(index + 1)
              .fill(item)
              .join("")}
          </p>
        );
      })}
      <button onClick={() => unmount()}>close</button>
    </div>
  );
}

function App() {
  const [show, setShow] = useState(true);

  return show ? <Something setShow={setShow} /> : null;
}

Live example: https://codesandbox.io/s/vigilant-leavitt-z1jd2

Muscadel answered 13/7, 2019 at 21:46 Comment(0)
L
86

React performs the cleanup when the component unmounts.

I'm not sure where you read this but this statement is incorrect. React performs the cleanup when the dependencies to that hook changes and the effect hook needs to run again with new values. This behaviour is intentional to maintain the reactivity of the view to changing data. Going off the official example, let's say an app subscribes to status updates from a friends' profile. Being the great friend you are, you are decide to unfriend them and befriend someone else. Now the app needs to unsubscribe from the previous friend's status updates and listen to updates from your new friend. This is natural and easy to achieve with the way useEffect works.

 useEffect(() => { 
    chatAPI.subscribe(props.friend.id);

    return () => chatAPI.unsubscribe(props.friend.id);
  }, [ props.friend.id ])

By including the friend id in the dependency list, we can indicate that the hook needs to run only when the friend id changes.

In your example you have specified the array in the dependency list and you are changing the array at a set interval. Every time you change the array, the hook reruns.

You can achieve the correct functionality simply by removing the array from the dependency list and using the callback version of the setState hook. The callback version always operates on the previous version of the state, so there is no need to refresh the hook every time the array changes.

  useEffect(() => {
    const id = setInterval(() => setArray(array => [ ...array, "hello" ]), 3000);

    return () => {
      console.log("unmount");
      clearInterval(id);
    };
  }, []);

Some additional feedback would be to use the id directly in clearInterval as the value is closed upon (captured) when you create the cleanup function. There is no need to save it to a ref.

Lyceum answered 16/7, 2019 at 13:44 Comment(4)
That quote "React performs the cleanup when the component unmounts." comes directly from React: reactjs.org/docs/hooks-effect.htmlMotteo
Keep reading further, you'll see that it's not only when the component unmounts. Which is the point I'm making.Lyceum
Have to down vote this because the statement you quote is not wrong it is just incomplete. Also it comes from the offical docs so implying they read something wrong is not helpful.Ceylon
Coming from React: "React performs the cleanup when the component unmounts. However, as we learned earlier, effects run for every render and not just once. This is why React also cleans up effects from the previous render before running the effects next time."Railroader
H
4

The React docs have an explanation section exactly on this.

In short, the reason is because such design protects against stale data and update bugs.

The useEffect hook in React is designed to handle both the initial render and any subsequent renders (here's more about it).


Effects are controlled via their dependencies, not by the lifecycle of the component that uses them.

Anytime dependencies of an effect change, useEffect will cleanup the previous effect and run the new effect.

Such design is more predictable - each render has its own independent (pure) behavioral effect. This makes sure that the UI always shows the correct data (since the UI in React's mental model is a screenshot of the state for a particular render).

The way we control effects is through their dependencies.

To prevent cleanup from running on every render, we just have to not change the dependencies of the effect.

In your case concretely, the cleanup is happening because array is changing, i.e. Object.is(oldArray, newArray) === false

useEffect(() => {
  // ...
}, [array]);
//  ^^^^^ you're changing the dependency of the effect

You're causing this change with the following line:

useEffect(() => {
  const id = setInterval(() => {
    setArray(array.concat("hello")); // <-- changing the array changes the effect dep
  }, 3000);
  myRef.current = id;

  return () => {
    clearInterval(myRef.current);
  };
}, [array]); // <-- the array is the effect dep
Herzig answered 13/7, 2019 at 21:55 Comment(2)
Hi thank you for your reply. This makes it clear. But if I do not set the array as the dependency, i.e. I leave it as an empty array, the setInterval will only run once. Do you know how to fix this?Muscadel
Well that depends on when you want it to run? Maybe setTimeout is what you're looking for?Herzig
H
2

As others have said, the useEffect was depending on the changes of "array" that was specified in the 2nd parameter in the useEffect. So by setting it to empty array, that'd help to trigger useEffect once when the component mounted.

The trick here is to change the previous state of the Array.

setArray((arr) => arr.concat("hello"));

See below:

  useEffect(() => {
     const id = setInterval(() => {
         setArray((arr) => arr.concat("hello"));
     }, 3000);
     myRef.current = id;
     return () => {
        console.log("unmount");
        clearInterval(myRef.current);
     };
  }, []);

I forked your CodeSandbox for demonstration: https://codesandbox.io/s/heuristic-maxwell-gcuf7?file=/src/index.js

Hospital answered 8/10, 2020 at 10:0 Comment(1)
yes, correct answerMonecious
B
0

Looking at the code I could guess its because of the second param [array]. You are updating it, so it will call a re-render. Try setting an empty array.

Every state update will call a re-render and unmount, and that array is changing.

Berck answered 13/7, 2019 at 21:53 Comment(3)
I tried to set an empty array but the setInterval I had would only run onceMuscadel
There is a long post about handling setInterval with hooks, its not an ordinary task :) This may help you overreacted.io/making-setinterval-declarative-with-react-hooksBerck
"Every state update will call a re-render and unmount". No?Oat
K
0

It seems expected. As per the documentation here, useEffect is called after first render, every update and unmount.

https://reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects

Tip

If you’re familiar with React class lifecycle methods, you can think of useEffect Hook as componentDidMount, componentDidUpdate and before componentWillUnmount combined.

Krugersdorp answered 13/7, 2019 at 21:59 Comment(0)
T
0

This is a Jest test that shows the render and effect order.

As you can see from the expect, once the dependency foo changes due to the state update it triggers a NEW render followed by the cleanup function of the first render.

  it("with useEffect async set state and timeout and cleanup", async () => {
    jest.useFakeTimers();
    let theRenderCount = 0;
    const trackFn = jest.fn((label: string) => { });
    function MyComponent() {
      const renderCount = theRenderCount;
      const [foo, setFoo] = useState("foo");
      useEffect(() => {
        trackFn(`useEffect ${renderCount}`);
        (async () => {
          await new Promise<string>((resolve) =>
            setTimeout(() => resolve("bar"), 5000)
          );
          setFoo("bar");
        })();
        return () => trackFn(`useEffect cleanup ${renderCount}`);
      }, [foo]);
      ++theRenderCount;
      trackFn(`render ${renderCount}`);
      return <span data-testid="asdf">{foo}</span>;
    }
    const { unmount } = render(<MyComponent></MyComponent>);
    expect(screen.getByTestId("asdf").textContent).toBe("foo");
    jest.advanceTimersByTime(4999);
    expect(screen.getByTestId("asdf").textContent).toBe("foo");
    jest.advanceTimersByTime(1);
    await waitFor(() =>
      expect(screen.getByTestId("asdf").textContent).toBe("bar")
    );

    trackFn("before unmount");
    unmount();
    expect(trackFn.mock.calls).toEqual([
      ['render 0'],
      ['useEffect 0'],
      ['render 1'],
      ['useEffect cleanup 0'],
      ['useEffect 1'],
      ['before unmount'],
      ['useEffect cleanup 1']
    ])
  });
Tallbot answered 7/2, 2023 at 18:8 Comment(1)
"As you can see from the expect". No I cannot.Oat

© 2022 - 2024 — McMap. All rights reserved.