0

I have multiple places where I need to make ajax requests to fetch the items corresponding to some ids. However, I only want to make a single request by accumulating these ids and debouncing the actual method that makes the ajax request...So far I've come up with this code, but it just feels ugly/non-reusable.

Is there any simpler/recommended method to achieve similar results without sharing resolve/promise variables like I did here?

Here's a fiddle

const fakeData = [{
    id: 1,
    name: 'foo'
  },
  {
    id: 2,
    name: 'bar'
  },
  {
    id: 3,
    name: 'baz'
  }
];

let idsToFetch = [];

let getItemsPromise, resolve, reject;

const fetchItems = _.debounce(() => {
  console.log('fetching items...');
  const currentResolve = resolve;
  const currentReject = reject;

  // simulating ajax request
  setTimeout(function() {
    const result = idsToFetch.map((id) => fakeData.find(item => item.id == id));
    currentResolve(result);
  }, 400);

  getItemsPromise = resolve = reject = null;
}, 500);

function getItems(ids) {
  idsToFetch = ids.filter((id) => !idsToFetch.includes(id)).concat(idsToFetch);
  if (!getItemsPromise) {
    getItemsPromise = new Promise((_resolve, _reject) => {
      resolve = _resolve;
      reject = _reject;
    });
  }

  fetchItems();

  return getItemsPromise
    .then((res) => {
      return res.filter((item) => ids.includes(item.id));
    })
}

setTimeout(() => {
  console.log('first request start');
  getItems([1]).then(res => console.log('first result:', res));
}, 100);
setTimeout(() => {
  console.log('second request start');
  getItems([1, 2]).then(res => console.log('second result:', res));
}, 200)
setTimeout(() => {
  console.log('third request start');
  getItems([1, 3]).then(res => console.log('third result:', res));
}, 300)
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.10/lodash.min.js"></script>
Amr Noman
  • 2,597
  • 13
  • 24

2 Answers2

1

I found it simpler to write a debounce() function with behaviour fully under my control rather than rely on a library method.

In particular, I went out of my way to engineer a different debounce behaviour from that in the question, by which (if I understand correctly) the first, and possibly only, request of a quick sequence must wait for the debounce delay to expire.

In the code below, instead of the original getItemsPromise a debouncePromise is used to signify a debounce period, during which fetching is suppressed and request data is allowed to accumulate. From the quiescent state (whenever debouncePromise === null), the next fetch() call will fetch data ASAP (next tick). Only second and subsequent calls are debounced, until the debounce period expires and the debounce() instance returns to its quiescent state. I think that is as valid a "debounce" paradigm as the original, and arguably better. (If not, then fetch() can be lightly modified to give the original behaviour).

Apart from that, differences are minor :

  • messy externalized resolve and reject are avoided
  • in an attempt to keep debounce() generic, a resultsFilter function is passed in addition to a fetcher and delay.

Further comments in code.

function debounce(fetcher, resultsFilter, delay) {
    let idsToFetch = [],
        debouncePromise = null;
    function reset() { // utility funtion - keeps code below clean and DRY
        let idsToFetch_ = idsToFetch;
        idsToFetch = [];
        return idsToFetch_;
    }
    function fetch(ids) {
        idsToFetch = idsToFetch.concat(ids.filter(id => !idsToFetch.includes(id))); // swapped around so as not to reverse the order.
        if (!debouncePromise) {
            // set up the debounce period, and what is to happen when it expires.
            debouncePromise = new Promise(resolve => {
                setTimeout(resolve, delay);
            }).then(() => {
                // on expiry of the debounce period ...
                debouncePromise = null; // ... return to quiescent state.
                return fetcher(reset()); // ... fetch (and deliver) data for all request data accumulated in the debounce period.
            });
            // *** First call of this debounce period - FETCH IMMEDIATELY ***
            return Promise.resolve(reset()).then(fetcher); // (1) ensure fetcher is called asynchronously (as above). (2) resultsFilter is not necessary here.
        } else {
            return debouncePromise.then(res => resultsFilter(ids, res)); // when debouncePromise exists, return it with chained filter to give only the results for these ids.
        }
    }
    return fetch;
}

Sample usage :

function fetchItems(ids) {
    const fakeData = [
        { 'id': 1, 'name': 'foo' },
        { 'id': 2, 'name': 'bar' },
        { 'id': 3, 'name': 'baz' },
        { 'id': 4, 'name': 'zaz' }
    ];
    if (ids.length > 0) {
        return new Promise(resolve => { // simulate ajax request
            setTimeout(resolve, 400);
        }).then(() => {
            return ids.map(id => fakeData.find(item => item.id == id));
        });
    } else {
        return Promise.resolve([]);
    }
}
function filterResults(ids, results) {
    return results.filter(item => ids.includes(item.id));
}

// ******************************************************
let getItems = debounce(fetchItems, filterResults, 500);
// ******************************************************

setTimeout(() => {
    console.log('first request start');
    getItems([1]).then(res => console.log('first result:', res));
}, 100);
setTimeout(() => {
    console.log('second request start');
    getItems([1, 2]).then(res => console.log('second result:', res));
}, 200);
setTimeout(() => {
    console.log('third request start');
    getItems([1, 3]).then(res => console.log('third result:', res));
}, 300);
setTimeout(() => {
    console.log('fourth request start');
    getItems([1, 4]).then(res => console.log('fourth result:', res));
}, 2000);

Tested to the extent of this fiddle

Roamer-1888
  • 19,138
  • 5
  • 33
  • 44
  • Nice, thank you for taking the time to address this. If I'm not mistaken, you've implemented throttling not debouncing, is this correct? – Amr Noman May 10 '18 at 15:38
  • I think my paradigm is essentially the same as yours except for that initial request in every "debounce period", which is allowed through immediately. I don't know, does that make it throttling? Both versions accumulate request data during a dead period then make a single request with the accumulated data pool. For me, if your original version debounces then so does mine. Maybe someone else has a view. – Roamer-1888 May 10 '18 at 16:05
  • It's not about the initial request, check the difference between the results of your code [here](https://jsfiddle.net/amrnn/v2h4eurw/1/) and my code [here](https://jsfiddle.net/amrnn/gvfew4u0/2/)...yours will fetch each 200ms between requests, while mine waits until there are no requests for 200ms and then fetch...yours implements throttling not debouncing (which I think is more appropriate for this use case anyway). – Amr Noman May 10 '18 at 16:24
  • Our two versions are certainly different. It's largely academic but I'm not convinced that mine doesn't conform to the definition of debouncing given in the lodash documentation. And I can't quite work out why, with a debounce constant of `200`, your version should wait until all four `getItems()` calls are made before issuing the first and only request. I'm cross-eyed from looking at the code. Hopefully it will come clear tomorrow. – Roamer-1888 May 10 '18 at 19:14
  • Actually if you use `_.throttle` in my code instead of `_.debounce` you'll get similar behavior to your code [check this](https://jsfiddle.net/amrnn/gvfew4u0/4/). If we have a delay of 200ms then `debounce` will keep waiting as long as the time between each request is less than 200ms. However, `throttle` will fetch **every** 200ms as long as there are new request...the difference between them is a bit confusing. – Amr Noman May 10 '18 at 20:28
  • Ah OK, with that explanation, it's all making more sense now. Thank you. – Roamer-1888 May 10 '18 at 22:28
  • 1
    @AmrNoman, from your comments and having read [this](https://css-tricks.com/debouncing-throttling-explained-examples/), I am persueded that my paradigm is much closer to "throttle" than to "debounce". Amazing what one can learn by trying to answer a question! – Roamer-1888 May 11 '18 at 15:53
0

I was able to somehow encapsulate the logic by creating a function generator that holds the previous two functions like this:

   const fakeData = [{

      id: 1,
      name: 'foo'
    },
    {
      id: 2,
      name: 'bar'
    },
    {
      id: 3,
      name: 'baz'
    }
  ];

  function makeGetter(fetchFunc, debounceTime = 400) {
    let idsToFetch = [];

    let getItemsPromise, resolve, reject;

    const fetchItems = _.debounce(() => {
      console.log('fetching items...');
      const currentResolve = resolve;
      const currentReject = reject;
      const currentIdsToFetch = idsToFetch;

      Promise.resolve(fetchFunc(currentIdsToFetch))
        .then(res => currentResolve(res))
        .catch(err => currentReject(err));

      getItemsPromise = resolve = reject = null;
      idsToFetch = [];
    }, debounceTime);

    const getItems = (ids) => {
      idsToFetch = ids.filter((id) => !idsToFetch.includes(id)).concat(idsToFetch);
      if (!getItemsPromise) {
        getItemsPromise = new Promise((_resolve, _reject) => {
          resolve = _resolve;
          reject = _reject;
        });
      }
      const currentPromise = getItemsPromise;
      fetchItems();

      return currentPromise
        .then((res) => {
          return res.filter((item) => ids.includes(item.id));
        })
    }

    return getItems;
  }

  const getItems = makeGetter((ids) => {
    // simulating ajax request
    return new Promise((resolve, reject) => {
      setTimeout(function() {
        const result = ids.map((id) => fakeData.find(item => item.id == id));
        resolve(result);
      }, 400);
    })
  });


  setTimeout(() => {
    console.log('first request start');
    getItems([1]).then(res => console.log('first result:', res));
  }, 100);
  setTimeout(() => {
    console.log('second request start');
    getItems([1, 2]).then(res => console.log('second result:', res));
  }, 200)
  setTimeout(() => {
    console.log('third request start');
    getItems([1, 3]).then(res => console.log('third result:', res));
  }, 300)
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.10/lodash.min.js"></script>
Amr Noman
  • 2,597
  • 13
  • 24