React 18 - Avoiding Use Effect Getting Called Twice

Alan Richardson - Apr 25 '22 - - Dev Community

Update - Not Recommended

AG Grid no longer uses this approach or recommends that you use this Hook either. Instead ensure that your React components correctly implement their own clear down logic so that whether StrictMode is used or not your component works consistently.

Original Post Content

React 18 introduced a breaking change, when in Strict Mode, all components mount and unmount, then mount again.

For React Hooks in React 18, this means a useEffect() with zero dependencies will be executed twice.

Here is a custom hook that can be used instead of useEffect(), with zero dependencies, that will give the old (pre React 18) behaviour back, i.e. it works around the breaking change.

Here is the custom hook useEffectOnce without TypeScript:

export const useEffectOnce = ( effect )=> {

  const destroyFunc = useRef();
  const effectCalled = useRef(false);
  const renderAfterCalled = useRef(false);
  const [val, setVal] = useState(0);

  if (effectCalled.current) {
      renderAfterCalled.current = true;
  }

  useEffect( ()=> {

      // only execute the effect first time around
      if (!effectCalled.current) { 
        destroyFunc.current = effect();
        effectCalled.current = true;
      }

      // this forces one render after the effect is run
      setVal(val => val + 1);

      return ()=> {
        // if the comp didn't render since the useEffect was called,
        // we know it's the dummy React cycle
        if (!renderAfterCalled.current) { return; }
        if (destroyFunc.current) { destroyFunc.current(); }
      };
  }, []);
};
Enter fullscreen mode Exit fullscreen mode

And here is the hook again with TypeScript:

export const useEffectOnce = (effect: () => void | (() => void)) => {
  const destroyFunc = useRef<void | (() => void)>();
  const effectCalled = useRef(false);
  const renderAfterCalled = useRef(false);
  const [val, setVal] = useState<number>(0);

  if (effectCalled.current) {
    renderAfterCalled.current = true;
  }

  useEffect(() => {
    // only execute the effect first time around
    if (!effectCalled.current) {
      destroyFunc.current = effect();
      effectCalled.current = true;
    }

    // this forces one render after the effect is run
    setVal((val) => val + 1);

    return () => {
      // if the comp didn't render since the useEffect was called,
      // we know it's the dummy React cycle
      if (!renderAfterCalled.current) {
        return;
      }
      if (destroyFunc.current) {
        destroyFunc.current();
      }
    };
  }, []);
};
Enter fullscreen mode Exit fullscreen mode

In your application code, call useEffectOnce with zero dependencies instead of useEffect. Job Done.

// instead of this:
useEffect( ()=> {
    console.log('my effect is running');
    return () => console.log('my effect is destroying');
}, []);

// do this:
useEffectOnce( ()=> {
    console.log('my effect is running');
    return () => console.log('my effect is destroying');
});
Enter fullscreen mode Exit fullscreen mode

How it works in a nutshell, I observed in React 18, if the effect runs, and then gets destroyed again before it renders, we know it's a fake setup / destroy cycle. This works regardless of what React version, and regardless of whether Strict Mode is used or not.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .