4

I have an Axios GET request that starts fetching an API when clicking a button. This request takes a long time (around a minute) and I would like to cancel it and start a new one when the button is clicked again.

Also, I would also like to be able to cancel the request if it is pending and I refresh the page.

I tried everything I found here on SO but nothing seems to work. I also read the Axios doc about cancelling : https://github.com/axios/axios#cancellation.

All this lead me there :

Updated code (see edit below)

const CancelToken = axios.CancelToken;
let cancel;

function App() {
    useEffect(() => {
        async function getPairs() {
            try {
                if (cancel) {
                    cancel();
                }

                const res = await axios.get('http://localhost:3000/binance/pairs?timeframe='+timeframe+'&strat='+strat, {
                    cancelToken: new CancelToken(function executor(c) {
                    cancel = c;
                    })
                });

                if (res.status === 200 || res.data.status === 'success') {
                    setPairs(res.data.pairs);
                    setReloadConfig(false);
                }
            }
            catch (err) {
                if (axios.isCancel(err)) {
                    console.log("cancelled");
                } else {
                throw err;
                }
            }
        }
    
        // reloadConfig is a React useState hook that is set to true when clicking a button 
        if (reloadConfig) {
            getPairs();
        }
    }, [reloadConfig]);
}

export default App;

Note that reloadConfig is a React useState hook that is set to true when clicking a button, therefore triggering the useEffect hook and the Axios request.

Problem is, if I click the button multiple times, no request is cancelled and a new one is created every time. I can see all the requests being treated in my terminal.

How can I cancel the last pending request and create a new one when clicking the button triggering the useEffect ?

Also, how can I cancel a pending request if I refresh the page ?

----- Edit -----

After twiking a bit with the code, I found out that placing cancelToken and cancel variables before the component function declaration made me move forward. Now, cancel is not undefined anymore and I get the cancelled message from the catch block :

catch (err) {
    if (axios.isCancel(err)) {
        console.log("cancelled");
    }
}

If I console.log(cancel), it returns this :

ƒ cancel(message) {
    if (token.reason) {
      // Cancellation has already been requested
      return;
    }

    token.reason = new Cancel(message);
    resolvePromise(token.reason);
}

So it tells me cancellation was successful (if I understand correctly ?), but I can still see all the requests being logged in the backend terminal and still being processed. What's wrong with it ?

Thank you very much

----- Edit 2 -----

The solutions showed above all worked. The problem was a misunderstanding on my part : when I do a call to my API to fetch the results, the results are actually fetched by a library that itself fetches one URI per result, meaning that one call to my API makes multiple calls to another API via said library.

I can say the solutions showed above by the community worked because I tried them on a sandbox with a "normal" mock API. I think what happens in my case is that, the library I use with my API calls +200 URLs, so I guess one call was cancelled but not the other 199.

If anyone have a solution to cancel everything that would be great but I think that's more of a backend situation than frontend one at this point.

Thanks to everyone for your help and patience.

Tom687
  • 182
  • 2
  • 14
  • Is the cancel variable defined when the getPairs function is called? Try to add a console.log(cancel) when the function starts to see if it defined, if it's not, you may want to block a new action until the cancel is created. About the cancel when user refreshes the page, see if one of those solutions works for you: https://stackoverflow.com/questions/50026028/react-how-to-detect-page-refresh-f5 – Alcides Bezerra Apr 06 '21 at 14:47
  • The `cancel` variable is declared in the body of the file (`App.js`, not in any function or anything), just before the `useEffect` hook, just as show in the code provided. When `console.log` on `cancel`, it is in fact `undefined`. What do you mean by "block a new action" ? Thanks – Tom687 Apr 06 '21 at 14:50
  • I edited the code and now I get the "cancellation message from `if (axios.isCancelled(err)) console.log('cancelled')`. But somehow, I can still see all the requests being processed in the backend terminal. Any idea ? (I edited the original post with more details). Thank you very much – Tom687 Apr 06 '21 at 15:40

5 Answers5

6

Since you are depending on useEffect to fire up your request, you can use the cleanup function of useEffect to cancel your API request before a new one is executed.

function App() {
    useEffect(() => {
        let CancelToken = axios.CancelToken;
        let source = CancelToken.source();
        async function getPairs() {
            try {

                const res = await axios.get('http://localhost:3000/binance/pairs?timeframe='+timeframe+'&strat='+strat, {
                    cancelToken: source.token
                });

                if (res.status === 200 || res.data.status === 'success') {
                    setPairs(res.data.pairs);
                    setReloadConfig(false);
                }
            }
            catch (err) {
                if (axios.isCancel(err)) {
                    console.log("cancelled");
                } else {
                throw err;
                }
            }
        }
    
        // reloadConfig is a React useState hook that is set to true when clicking a button 
        if (reloadConfig) {
            getPairs();
        }

        return () => {
             source.cancel('Cancelled due to stale request');
        }
    }, [reloadConfig]);
}

export default App;

Note that you should define your cancel variable within useEffect otherwise it will be re-initialzed to undefined on next render if you directly define it within the component.

Shubham Khatri
  • 270,417
  • 55
  • 406
  • 400
  • Unfortunately, doesn't work for me. `console.log(cancel)` shows that `cancel` is `undefined` in this case. I wrote the code exactly as you wrote it. – Tom687 Apr 06 '21 at 16:13
  • are you sure, your API didn't return a response before you tried to cancel it – Shubham Khatri Apr 06 '21 at 16:18
  • Sure, the response from the API takes around one minute to come, and I clicked the button in just a few seconds. Any idea ? – Tom687 Apr 06 '21 at 16:21
  • I updated my answer with a different technique to use cancelToken. Do check and let me know if that solves your problem? – Shubham Khatri Apr 06 '21 at 17:54
  • What it does now is block the new request from being fired up but still continues with the previous one. Thanks for helping, I greatly appreciate it – Tom687 Apr 06 '21 at 18:11
  • That is totally weird, Can you create a sandbox to reproduce your problem? – Shubham Khatri Apr 06 '21 at 18:14
  • I tried it with a mock API in a sandbox and it works. I think the problem is that I'm calling the Binance API (crypto trading platform) to fetch the results of lot of cryptos (+200). I use a library to help with the Binance API. The problem is, when I call my API to fetch those cryptos, the library calls one URL per crypto listed. So each time I do one call via my API, the library makes +200. I guess one call from the library is cancelled but the other 199 go trough. Didn't think of this before but makes sense. Any idea if there is a solution to this ? Thanks a lot for your help and patience – Tom687 Apr 06 '21 at 19:40
  • If you can provide more details about the library, I will check it out. – Shubham Khatri Apr 07 '21 at 13:14
2

June 2022 Update

CancelToken has been deprecated since Axios v0.22.0 and should not be used in new projects (reference: https://axios-http.com/docs/cancellation). Use AbortController instead, like so:

function App() {
  useEffect(() => {
    const controller = new AbortController();
    async function getPairs() {
      try {
        const res = await axios.get(
          "http://localhost:3000/binance/pairs?timeframe=" +
            timeframe +
            "&strat=" +
            strat,
          {
            signal: controller.signal,
          }
        );

        if (res.status === 200 || res.data.status === "success") {
          setPairs(res.data.pairs);
          setReloadConfig(false);
        }
      } catch (err) {
        if (axios.isCancel(err)) {
          console.log("cancelled");
        } else {
          throw err;
        }
      }
    }

    // reloadConfig: React useState hook set to true when clicking a button
    if (reloadConfig) {
      getPairs();
    }

    return () => {
      controller.abort();
    };
  }, [reloadConfig]);
}

export default App;

Happy coding! Ciao.

ranaalisaeed
  • 304
  • 3
  • 15
0

Maybe this is not a web problem, I can't give an accurate answer. Maybe you can consider using queue and broadcast(websocket) solution?

If you want to do something before refreshing the page, please refer to window.onbeforeunload event.The code that executes the cancel request in the event callback.

  • Unfortunately this doesn't work for me. I tried it with the edits I made to the original post (putting `CancelToken` and `cancel` variable declarations above the component function declaration) and also with those same variables juste above the `useEffect` hook. It gives the same result as the edit I made in the post : I get the `cancelled` message from the `catch` block (from `if (axios.isCancel(err)) console.log("cancelled")`) but still can see all the requests being processed on the backend terminal. Any idea ? Thanks – Tom687 Apr 06 '21 at 15:48
  • @Tom687 so sorry. I can only give advice on this issue. – CongAn-王从安 Apr 06 '21 at 16:05
  • No worries, thank you very much for your input – Tom687 Apr 06 '21 at 16:06
0

Think you should change implementation like this to ensure you're avoiding memory leaks and handling errors. Using a separate hook for the axios request will also decouple your component and request code.

The main issue you have is that you're not getting the token from source i.e. const { token: cancelToken, cancel } = source;

const useFetchPairs = () => {
  const [data, setData] = useState({
    pairs: null,
    isLoading: false,
    error: null,
  });
  const CancelToken = axios.CancelToken;
  const source = CancelToken.source();
  const { cancelToken: token, cancel } = source;
  const getPairs = async () => {
    setData({ pairs: null, isLoading: false, error: null });

    try {
      const res = await axios.get(
        "http://localhost:3000/binance/pairs?timeframe=" +
          timeframe +
          "&strat=" +
          strat,
        {
          cancelToken,
        }
      );

      if (res.status === 200 || res.data.status === "success") {
        setData({ pairs: res.data.pairs, isLoading: false, error: null });
      }
    } catch (err) {
      if (axios.isCancel(err)) {
        console.log("cancelled");
      } else {
        setData({ pairs: null, isLoading: false, error });
      }
    }
  };

  return [data, getPairs, cancel];
};

const MyComponent = () => {
  const [{ isLoading, pairs, error }, getPairs, cancel] = useFetchPairs();

  useEffect(() => {
    return () => {
      // cancelling when component unmounts i.e. navigation
      cancel();
    };
  }, []);

  const onClick = () => {
    if (isLoading) {
      // cancelling when button clicked and previous request in progress
      cancel();
    }
    getPairs();
  };
  return (
    <>
      {error && <p>{error}</p>}
      <div>{pairs}</div>
      <button onClick={onClick}>Click me</button>
    </>
  );
};
    
alorek
  • 1
  • I can't get it to work. In `useFetchPairs`, when declaring `cancelToken`, shouldn't it be : `cancelToken: token` ? I did it like this, otherwise `cancelToken` is `undefined`. Even when putting messages in `cancel()`, no message logs in and I can still see all the requests being processed in the backend terminal. Shouldn't one of the `isLoading` be `true` at some point ? They are all `false` in your example. I tried playing around with your code but can't get it to work. I got to the point where I get `cancelled` from the `catch` block (in `if (axios.isCancel(err))`). Any idea ? Thanks a lot – Tom687 Apr 06 '21 at 16:46
0

I can suggest one more solution with custom libs (Working Demo):

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

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

  const request = useAsyncCallback(
    function* () {
      this.timeout(props.timeout);

      try {
        setText("fetching...");
        const response = yield cpAxios(props.url);
        setText(`Success: ${JSON.stringify(response.data)}`);
        // do whatever you need with the response data here 
      } catch (err) {
        CanceledError.rethrow(err, E_REASON_UNMOUNTED); //passthrough
        setText(`Failed: ${err}`);
      }
    },
    { deps: [props.url], cancelPrevious: true }
  );

  return (
    <div className="component">
      <div>{text}</div>
      <button onClick={request}>Request</button>
      <button onClick={request.cancel}>Abort</button>
    </div>
  );
}
Dmitriy Mozgovoy
  • 1,419
  • 2
  • 8
  • 7