Peter Kellner
Peter Kellner

Reputation: 15508

Canceling an Axios REST call in React Hooks useEffects cleanup failing

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;

Upvotes: 25

Views: 37641

Answers (5)

Mohammad Momtaz
Mohammad Momtaz

Reputation: 635

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();
    };
  }, []);
//...

Upvotes: 13

Dmitriy Mozgovoy
Dmitriy Mozgovoy

Reputation: 1597

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>
  );
}

Upvotes: 0

Caio Mar
Caio Mar

Reputation: 2624

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;

Upvotes: -2

Peter Kellner
Peter Kellner

Reputation: 15508

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;

Upvotes: 42

Shubham Khatri
Shubham Khatri

Reputation: 282120

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

Upvotes: 2

Related Questions