javascriptfetchclosuresdelayed-executionabortcontroller

Cancel/abort a delayed fetch request


I have a simple UI with a button to trigger a fetch request to an API.

<button type="button"id="fetch">Fetch products</button>

I want to implement some logic (using AbortController) so that any re-fetch cancels a previous ongoing request. To simulate network latency (and make sure my abort logic works fine), I have wrapped the fetch logic into a promise to delay the fetch operation. On every click event, I check whether a controller already exists to cancel its related request.

Note I do not want to disable the button or debounce user's click here.

Here is the base code:

const btnEl = document.querySelector('#fetch');
const API_BASE_URL =
  'https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store';

let controller;

const onClick = async () => {
  try {
    const products = await fetchProducts();
    console.log('Products:', products);
  } catch (error) {
    console.error('Failed to fetch products.', error?.message ?? error);
  }
};

btnEl.addEventListener('click', () => onClick());

❌ Here is the business logic I initially tried. I expected the promise's executor function to close around the controller value of the outer scope. It is obviously not the case... It looks like the new fetch request mutates the previous abort controller before it has a chance to fire. I'm not sure why this does not work 🤔...

function fetchProducts() {
  return fetchDataWithDelay(API_BASE_URL + '/products.json');
}

// Pretend data fetching is hitting the network.
function fetchDataWithDelay(url, delay = 3000) {
  if (!url) throw new Error('URL is required.');

  if (controller) {
    controller.abort('Fetch aborted by the user.');
    console.log('Fetch aborted.');
  }
  controller = new AbortController();

  return new Promise((resolve, reject) => {
    console.log(`Fetching data with ${delay}ms delay...`);
    setTimeout(async () => {
      try {
        const response = await fetch(url, { signal: controller.signal });
        response.ok
          ? resolve(await response.json())
          : reject(new Error(`Request failed with status ${response.status}`));
      } catch (error) {
        reject(error);
      }
    }, delay);
  });
}

Multiple clicks within the specified delay result in as many fetch requests. Here are the console logs I get after 2 click events within 3000ms:

Fetching data with 3000ms delay...
Fetch aborted.
Fetching data with 3000ms delay...
Products:
(12) [{...}, {...}, {...}, ..., {...}]
Products:
(12) [{...}, {...}, {...}, ..., {...}]

✅ I managed to make it work by extracting the abort controller related logic to the caller function:

function fetchProducts() {
 // Moved the controller logic here...
  if (controller) {
    controller.abort('Fetch aborted by the user.');
    console.log('Fetch aborted.');
  }
  controller = new AbortController();

 // ... and passed the controller as a function parameter to the delayed fetch function
  return fetchDataWithDelay(API_BASE_URL + '/products.json', controller);
}

function fetchDataWithDelay(url, abortController, delay = 3000) {
  if (!url) throw new Error('URL is required.');

  return new Promise((resolve, reject) => {
    console.log(`Fetching data with ${delay}ms delay...`);
    setTimeout(async () => {
      try {
        const response = await fetch(url, { signal: abortController.signal });
        response.ok
          ? resolve(await response.json())
          : reject(new Error(`Request failed with status ${response.status}`));
      } catch (error) {
        reject(error);
      }
    }, delay);
  });
}

Here I get the expected output after 2 click events within 3000ms:

Fetching data with 3000ms delay...
Fetch aborted.
Fetching data with 3000ms delay...
Failed to fetch products. Fetch aborted by the user.
Products:
(12) [{...}, {...}, {...}, ..., {...}]

But this time not entirely sure why this works (smells like closure...) 😅

Any insights will be greatly appreciated.

Thanks for your time 🙏


Solution

  • The problem is with your setTimeout(), in the working example you create a copy with the argument abortController so in the setTimeout's callback it's the proper intended valid controller.

    When you don't use the function, when you click you override the controller before it's passed to fetch() 🤷‍♂️ since the timeout hasn't happened yet.

    So if you remove setTimeout() the first code would work ok.