DEV Community

Alan Richardson for AG Grid

Posted on • Originally published at blog.ag-grid.com on

React 18 - Avoiding Use Effect Getting Called Twice

Author Credit: Niall Crosby

React 18 - Avoiding Use Effect Getting Called Twice

React 18 introduced a huge breaking change, when in Strict Mode, all components mount and unmount, then mount again. The reason for this is for paving the way for a feature that isn't in React yet, so as far as React 18 is concerned, there is no reason.

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.

We use this workaround in AG Grid, which is a very popular library, so this fix has been "battle tested".

Also to be clear to any AG Grid users who read this - this isn't anything you need to do in your application, it's a workaround we implemented internally. You don't need to do anything different with AG Grid because of this.

Discussion (8)

Collapse
joelnet profile image
JavaScript Joel

Probably just a minor copy/paste bug, but there is an extra open parenthesis here that doesn't have a matching close below.

export const useEffectOnce = ( effect => {
Enter fullscreen mode Exit fullscreen mode

I am also seeing a problem when Strict mode is not enabled or app is build in production mode, the unmount event will not fire.

Here's my code for testing:

export const MyComponent = () => {
  useEffect(() => {
    console.log("useEffect enter");

    return () => {
      console.log("useEffect exit");
    };
  }, []);

  useEffectOnce(() => {
    console.log("useEffectOnce enter");

    return () => {
      console.log("useEffectOnce exit");
    };
  });

  return <b>Hi</b>;
};
Enter fullscreen mode Exit fullscreen mode

Output in production mode when mounting and unmounting the component once.

useEffect enter
useEffectOnce enter
useEffect exit
Enter fullscreen mode Exit fullscreen mode

useEffectOnce exit was expected to fire here.

Collapse
eviltester profile image
Alan Richardson Author

Thanks, we've updated the code to fix typos and hopefully addressed Jack's point below.

Collapse
jherr profile image
Jack Herrington

useEffectOnce exit is not going to fire as far as I can see. This hook works to defeat the hook being called twice, but the cleanup functions will never get called as far as I can see.

I honestly don't know how to fix this. I've been trying everything I can think of.

Collapse
eviltester profile image
Alan Richardson Author

Thanks for pointing this out Jack. Niall updated the code, hopefully it is better.

Collapse
mike7petrusenko profile image
Mike

What is the difference between development and production hook behavior? Could you please explain.

Collapse
skydiver profile image
Martin M.
Collapse
iamyoki profile image
Yoki

Final code like this might be better.

function useEffectOnce(effect) {
  const effectFn = useRef(effect)
  const destroyFn = useRef()
  const effectCalled = useRef(false)
  const rendered = useRef(false)
  const [, refresh] = useState(0)

  if (effectCalled.current) {
    rendered.current = true
  }

  useEffect(() => {
    if (!effectCalled.current) {
      destroyFn.current = effectFn.current()
      effectCalled.current = true
    }

    refresh(1)

    return () => {
      if (rendered.current === false) return
      if (destroyFn.current) destroyFn.current()
    }
  }, [])
}
Enter fullscreen mode Exit fullscreen mode
Collapse
sunil_prasad1 profile image
Sunil Prasad

This happens only in development mode not in production mode . So should we change the code to handle behaviour only for the development mode.