23

I'm obviously not cleaning up correctly and cancelling the axios GET request the way I should be. On my local, I get a warning that says

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.

On stackblitz, my code works, but for some reason I can't click the button to show the error. It just always shows the returned data.

https://codesandbox.io/s/8x5lzjmwl8

Please review my code and find my flaw.

useAxiosFetch.js

import {useState, useEffect} from 'react'
import axios from 'axios'

const useAxiosFetch = url => {
    const [data, setData] = useState(null)
    const [error, setError] = useState(null)
    const [loading, setLoading] = useState(true)

    let source = axios.CancelToken.source()
    useEffect(() => {
        try {
            setLoading(true)
            const promise = axios
                .get(url, {
                    cancelToken: source.token,
                })
                .catch(function (thrown) {
                    if (axios.isCancel(thrown)) {
                        console.log(`request cancelled:${thrown.message}`)
                    } else {
                        console.log('another error happened')
                    }
                })
                .then(a => {
                    setData(a)
                    setLoading(false)
                })
        } catch (e) {
            setData(null)
            setError(e)
        }

        if (source) {
            console.log('source defined')
        } else {
            console.log('source NOT defined')
        }

        return function () {
            console.log('cleanup of useAxiosFetch called')
            if (source) {
                console.log('source in cleanup exists')
            } else {
                source.log('source in cleanup DOES NOT exist')
            }
            source.cancel('Cancelling in cleanup')
        }
    }, [])

    return {data, loading, error}
}

export default useAxiosFetch

index.js

import React from 'react';

import useAxiosFetch from './useAxiosFetch1';

const index = () => {
    const url = "http://www.fakeresponse.com/api/?sleep=5&data={%22Hello%22:%22World%22}";
    const {data,loading} = useAxiosFetch(url);

    if (loading) {
        return (
            <div>Loading...<br/>
                <button onClick={() => {
                    window.location = "/awayfrom here";
                }} >switch away</button>
            </div>
        );
    } else {
        return <div>{JSON.stringify(data)}xx</div>
    }
};

export default index;
Peter Kellner
  • 14,748
  • 25
  • 102
  • 188

5 Answers5

40

Here is the final code with everything working in case someone else comes back.

import {useState, useEffect} from "react";
import axios, {AxiosResponse} from "axios";

const useAxiosFetch = (url: string, timeout?: number) => {
    const [data, setData] = useState<AxiosResponse | null>(null);
    const [error, setError] = useState(false);
    const [errorMessage, setErrorMessage] = useState(null);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        let unmounted = false;
        let source = axios.CancelToken.source();
        axios.get(url, {
            cancelToken: source.token,
            timeout: timeout
        })
            .then(a => {
                if (!unmounted) {
                    // @ts-ignore
                    setData(a.data);
                    setLoading(false);
                }
            }).catch(function (e) {
            if (!unmounted) {
                setError(true);
                setErrorMessage(e.message);
                setLoading(false);
                if (axios.isCancel(e)) {
                    console.log(`request cancelled:${e.message}`);
                } else {
                    console.log("another error happened:" + e.message);
                }
            }
        });
        return function () {
            unmounted = true;
            source.cancel("Cancelling in cleanup");
        };
    }, [url, timeout]);

    return {data, loading, error, errorMessage};
};

export default useAxiosFetch;
Ascherer
  • 8,223
  • 3
  • 42
  • 60
Peter Kellner
  • 14,748
  • 25
  • 102
  • 188
  • cool solution! do we actually need the `unmounted` check inside of the `then` when we cancel the request on unmount anyways? – fabb Feb 25 '20 at 11:14
  • @fabb The `unmounted` variable is redundant, since the return function inside `useEffect()` is already designed to run when the component is unmounted. Source: https://reactjs.org/docs/hooks-effect.html#example-using-hooks-1 – MForMarlon Sep 25 '20 at 17:08
  • 2
    Unmounted is necessary if the request is not cancelled or some other tasks cannot be cancelled. If you slow down the network and make the component unmounted before the network call returns, you will see errors "Can not perform a React state update on an unmounted component." – SXC Mar 03 '21 at 04:38
  • Thank you! You've helped me a lot – demogorgorn Aug 10 '21 at 20:15
13

Based on Axios documentation cancelToken is deprecated and starting from v0.22.0 Axios supports AbortController to cancel requests in fetch API way:

    //...
React.useEffect(() => {
    const controller = new AbortController();
    axios.get('/foo/bar', {
    signal: controller.signal
    }).then(function(response) {
     //...
     }).catch(error => {
        //...
     });
    return () => {
      controller.abort();
    };
  }, []);
//...
Mohammad Momtaz
  • 545
  • 6
  • 12
  • Does this cause the code in the .then(.. to stop executing in mid execution (say multiple state updates) if signal is called? – Peter Kellner Feb 21 '22 at 20:59
  • yes the .then(.. stop executing and if you check the inspection network tab of dev tools it shows the status of cancelled – Mohammad Momtaz Feb 23 '22 at 05:12
  • 1
    @MohammadMomtaz Thanks for your solution. I'm getting an error `Uncaught (in promise) Cancel {message: 'canceled'}`. This is easily fixed with a `.catch(error => {})` clause. Would you mind adding that to your example? – mcarson May 17 '23 at 15:05
  • @Mcarson Thanks for your suggestion, the catch error handling code is added – Mohammad Momtaz May 20 '23 at 14:51
2

The issue in your case is that on a fast network the requests results in a response quickly and it doesn't allow you to click the button. On a throttled network which you can achieve via ChromeDevTools, you can visualise this behaviour correctly

Secondly, when you try to navigate away using window.location.href = 'away link' react doesn't have a change to trigger/execute the component cleanup and hence the cleanup function of useEffect won't be triggered.

Making use of Router works

import React from 'react'
import ReactDOM from 'react-dom'
import {BrowserRouter as Router, Switch, Route} from 'react-router-dom'

import useAxiosFetch from './useAxiosFetch'

function App(props) {
  const url = 'https://www.siliconvalley-codecamp.com/rest/session/arrayonly'
  const {data, loading} = useAxiosFetch(url)

  // setTimeout(() => {
  //   window.location.href = 'https://www.google.com/';
  // }, 1000)
  if (loading) {
    return (
      <div>
        Loading...
        <br />
        <button
          onClick={() => {
            props.history.push('/home')
          }}
        >
          switch away
        </button>
      </div>
    )
  } else {
    return <div>{JSON.stringify(data)}</div>
  }
}

ReactDOM.render(
  <Router>
    <Switch>
      <Route path="/home" render={() => <div>Hello</div>} />
      <Route path="/" component={App} />
    </Switch>
  </Router>,
  document.getElementById('root'),
)

You can check the demo working correctly on a slow network

Shubham Khatri
  • 270,417
  • 55
  • 406
  • 400
0

Fully cancellable routines example, where you don't need any CancelToken at all (Play with it here):

import React, { useState } from "react";
import { useAsyncEffect, E_REASON_UNMOUNTED } from "use-async-effect2";
import { CanceledError } from "c-promise2";
import cpAxios from "cp-axios"; // cancellable axios wrapper

export default function TestComponent(props) {
  const [text, setText] = useState("");

  const cancel = useAsyncEffect(
    function* () {
      console.log("mount");

      this.timeout(props.timeout);
   
      try {
        setText("fetching...");
        const response = yield cpAxios(props.url);
        setText(`Success: ${JSON.stringify(response.data)}`);
      } catch (err) {
        CanceledError.rethrow(err, E_REASON_UNMOUNTED); //passthrough
        setText(`Failed: ${err}`);
      }

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

  return (
    <div className="component">
      <div className="caption">useAsyncEffect demo:</div>
      <div>{text}</div>
      <button onClick={cancel}>Abort</button>
    </div>
  );
}
Dmitriy Mozgovoy
  • 1,419
  • 2
  • 8
  • 7
  • This does not use axios directly. There are lots of solutions that involved other libraries that wrap axios like this. If you use axios directly, you still do need to handle the cancellation issue, though now, with abortController as someone else points out. – Peter Kellner Feb 21 '22 at 20:57
  • @PeterKellner Yes, that's the point of using a wrapper instead of manually aborting the request. It would be interesting to look at some of these "lots of libraries" :) I'm aware of AbortController as I added it to Axios, but it won't abort other asynchronous steps you might have, only the Axios request itself. Also, you will end up with some code duplication or you will have to code your own request hook for reuse within your components. Of course, this is not a serious problem, but still... – Dmitriy Mozgovoy Feb 23 '22 at 22:07
-2

This is how I do it, I think it is much simpler than the other answers here:

import React, { Component } from "react";
import axios from "axios";

export class Example extends Component {
    _isMounted = false;

    componentDidMount() {
        this._isMounted = true;

        axios.get("/data").then((res) => {
            if (this._isMounted && res.status === 200) {
                // Do what you need to do
            }
        });
    }

    componentWillUnmount() {
        this._isMounted = false;
    }

    render() {
        return <div></div>;
    }
}

export default Example;
Caio Mar
  • 2,344
  • 5
  • 31
  • 37