Search code examples
react-nativereact-reduxreact-hooksuse-effect

React Native - Can't perform a React state update on an unmounted component, useEffect cleanup function


I know there are already a lot of questions on this, but I have tried all the solutions I could find and none of them are working. I am performing an API request using react-redux that returns an image. The first time it executes fine, but if I go back on the navigation stack and then back into where the request is being performed, I get the following warning:

Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.

The code in the component where this appear to be occurring is:

const image = useSelector((state) => state.images.image);

const dispatch = useDispatch();

const loadImage = useCallback(async () => {
  setError(null);
  setIsLoading(true);
  try {
    await dispatch(actions.fetchImage());
  } catch (err) {
    setError(err.message);
  }
  setIsLoading(false);
}, [dispatch]);

useEffect(() => {
  setIsLoading(true);
  loadImage().then(() => {
    setIsLoading(false);
  });
}, [setIsLoading, loadImage]);

The most promising solution I found is this, but this did not get rid of the error:

useEffect(() => {
  let mounted = true;
  setIsLoading(true);
  loadImage().then(() => {
    if (mounted) {
      setIsLoading(false);
    }
  });

  return function cleanup() {
    mounted = false;
  };
}, [setIsLoading, loadImage]);

Any ideas?


Solution

  • First observation is that you are only cancelling the setters in your useEffect, but not in loadImage.

    Async functions with (state) effects are known to be tricky. And while you can probably solve it using the solution you proposed, it makes your code harder to read and maintain.

    useAsync hook

    I would suggest to use the useAsync hook from react-use

    Unfortunately, as you figured out, this library is not compatible with React Native.

    I took the liberty of creating a basic useAsync hook that should be usable enough for your use case, and should work in React Native (I have no project at hand to test it in):

    type UseAsyncResult<R> =
      | { loading: false; error: undefined; value: R }
      | { loading: boolean; error: any; value: undefined };
    
    function useAsync<T extends () => Promise<R>, R>(
      fn: T,
      deps: unknown[]
    ): UseAsyncResult<R> {
      const mounted = useRef(true);
    
      const [state, setState] = useState<UseAsyncResult<R>>({
        loading: true,
        error: undefined,
        value: undefined
      });
    
      useEffect(() => {
        return () => {
          mounted.current = false;
        };
      }, []);
    
      useEffect(() => {
        fn()
          .then((value) => {
            if (mounted.current) {
              setState({ loading: false, error: undefined, value });
            }
          })
          .catch((error) => {
            if (mounted.current) {
              setState({ loading: false, error, value: undefined });
            }
          });
      }, deps);
    
      return state;
    }
    

    It uses a boolean as you mentioned in your solution to see if the component is still mounted, and only performs state changes when mounted.

    With useAsync your code becomes something like:

    const image = useSelector((state) => state.images.image);
    
    const dispatch = useDispatch();
    
    const { loading, error } = useAsync(() => dispatch(actions.fetchImage()), [dispatch]);
    

    As you can see the hook maintains the loading and error values for you, without needing to do anything, so you can focus on the actual functionality. If your async function actually returns some value that you want to use, the value will be placed in a property called value that you can use if loading is false and error has no value.

    Remember that the async function you pass to the useAsync hook should also not perform any state updates, or you will still receive the same error messages.