2

I have this to check if my server is alive, polling every 10 seconds for a max of 10 times to see if it's alive.

const wait = (ms: number) => new Promise((res) => setTimeout(res, ms));

export async function checkRemote(
  { maxAttempts = 10 }: { maxAttempts: number } = { maxAttempts: 10 }
) {
  let i = 1;
  while (true) {
    const res = await fetch(`/check`);
    if (res.status >= 400) {
      if (i === maxAttempts) {
        throw new Error("Check failed");
      } else {
        i++;
        await wait(10000);
        continue;
      }
    } else {
      const json = await res.json();
      return json;
    }
  }
}

How can I properly "cancel" this polling in the context of React.js?

The problem is, my checkRemote function is going to continue to execute after my React component unmounts... If it unmounts while the calls are still ongoing.

First of all, the call to checkRemote must be made in a useEffect hook, not as the result of a button click? Or can we make it occur on button click (that would be ideal IMO), but still make it cancelable if they unmount the current component?

useEffect(() => {
  const promise = checkRemote({ maxAttempts: 10 })
    .then(res => {
      setStatus('ready')
    }).catch(e => {
      setStatus('unvailable')
    })

  return () => {
    // on unmount, do something like this?
    promise.abort()
  }
})

Or perhaps for button click:

const [checkPromise, setCheckPromise] = useState<Promise>()

const handleClick = () => {
  const promise = checkRemote({ maxAttempts: 10 })
    .then((res) => {
      setStatus("ready");
    })
    .catch((e) => {
      setStatus("unvailable");
    });
  
  setCheckPromise(promise)
}

useEffect(() => {
  return () => checkPromise.abort()
}, [checkPromise])

return <button onClick={handleClick}>Click me</button>

How could I architect this to pass in the "promise abort handler" into my nested functions? Something like AbortController....

const wait = (ms: number, { controller }) => {
  return new Promise((res) => {
    let timer = setTimeout(res, ms)

    controller.on('abort', () => {
      clearTimeout(timer)
      // to respond or not respond then?
      res()
    })
  })
}

export async function checkRemote(
  { maxAttempts = 10, controller }: { maxAttempts: number } = {
    maxAttempts: 10,
  }
) {
  let i = 1;
  while (!controller.aborted) {
    const res = await fetch(`/check`, { signal: controller });
    if (res.status >= 400) {
      if (i === maxAttempts) {
        throw new Error("Check failed");
      } else {
        i++;
        // somehow stop the timer early if we abort
        await wait(10000, { controller });
        continue;
      }
    } else {
      const json = await res.json();
      return json;
    }
  }
}

If I go down this rabbit hole, what are your recommendations? How do I architect the system to abort every function "properly"? I would probably use a custom event emitter to do this I'm guessing, so I can have controller.on('abort') easily throughout.

What I don't want to happen is my 10 second * 10 attempt = ~2 minute checker continues to check when the component is unmounted, that would be painful and confusing. I would like for it to cancel everything on unmount basically.

Note: the general problem is how to setup async aborting, not to useContext or something for this specific use case.

2
  • You should only pass the AbortSignal, not the AbortController (and name your parameters accordingly), but otherwise yes that's the way to go Commented Feb 15, 2024 at 3:46
  • As for how to cancel the setTimeout in wait, see here Commented Feb 15, 2024 at 3:48

1 Answer 1

0

Besides using a simple AbortController and using the throwIfAbortedmethod and/or abort event, you can use any library that provides its own cancelable/abortable Promise.

The following is not a recommended way, as I created this library myself and it is in beta, so this is just a suggestion: (Live playground)

import { AxiosPromise } from 'axios-promise';

const wait = AxiosPromise.delay;

const cancelableFetch = (url, opt) =>
  new AxiosPromise((resolve, _, { signal }) => {
    resolve(fetch(url, {...opt, signal}));
  });

const checkRemote = AxiosPromise.promisify(function* (url, { maxAttempts = 10 } = {}) {
  let i = 1;
  for (;;) {
    try {
      console.log(`request [${i}]`);

      const res = yield cancelableFetch(url);

      const simulateFailure = i < 3;

      if (res.status < 400 && !simulateFailure) {
        return yield res.json();
      }
    } finally {
      if (i++ === maxAttempts) {
        throw new Error('Check failed');
      }
    }
    yield wait(5000);
  }
});

// ------------ test env ----------------
const runButton = document.querySelector('#run');
const cancelButton = document.querySelector('#cancel');
let promise;

cancelButton.onclick = () => {
  promise?.cancel();
};

runButton.onclick = () => {
  promise?.cancel(new Error('restart'));

  promise = checkRemote(`https://dummyjson.com/products/1?delay=1000`).then(
    (json) => console.log(`Done:`, json),
    (err) => console.log(`Fail: ${err}`)
  );
};
Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.