React-select: Async Debounce for Select 2.0 not behaving as expected

Created on 26 Sep 2018  路  12Comments  路  Source: JedWatson/react-select

As the title says, lodash (_.debounce) implementation is not working for Select 2.0 async component.

It looks like the caching mechanism is caching the previous input and displaying that in the results.

Here is my forked code example:
Edit react-codesandboxer-example

Does anyone have a working debounce example that works for the 2.0 implementation of React Select Async?

Most helpful comment

Great reproduction!

It doesn't appear to me that this issue has to do with the cache.

The issue

It seems to me the issue is with an inaccurate expectation of how Lodash's debounce works.

Lodash specifies that

subsequent calls to the debounced function return the result of the last func invocation

Not that:

subsequent calls return promises which will resolve to the result of the next func invocation

This means each call which is within the wait period to our debounced loadOptions prop function is actually returning the last func invocation, and so the "real" promise we care about is never subscribed to.

An example

Here is a forked example making it less react-select specific (removes the cacheOptions and defaultOptions props, passes the option {leading: true} to the debounce function, and makes the wait period longer):

https://codesandbox.io/s/olmjz7mn9z

User types a and waits past the debounce wait period (1 second):

  1. Async#handleInputChange is called, with a new value of a.
  2. Within this, Async#loadOptions is called and subscribes to the promise returned from the passed in loadOptions fn (which is our _.debounced fn).
  3. Our _.debounced fn returns a promise (thanks to {leading: true} option) which resolves to the correct data (eg, the list of Alabama, Alaska, America Samoa).

User then types l, then a, then s, then k, then a (all within the debounce wait period). We now have an input showing Alaska, but with the dropdown showing both Alabama and Alaska. Here's why:

  1. Async#handleInputChange is called, with a new value of al. Within this, Async#loadOptions is called and subscribes to the promise returned from the passed in loadOptions fn (which is our _.debounced fn), which is a promise which resolves to the correct data (both Alabama and Alaska).
  2. Async#handleInputChange is called, with a new value of ala. Within this, Async#loadOptions is called and subscribes to the promise returned from the passed in loadOptions fn (which is our _.debounced fn), which (because this event is within the wait period) returns the "result of the last func invocation", which is the promise from loadOptions from when al was typed in.
  3. Step two is repeated for alas, alask, and finally alaska each time with our debounced fn (because we're still within the debounce wait period) returning the "result of the last func invocation" which is the promise from loadOptions from when al was typed in. (This promise resolves to both Alabama and Alaska.)
  4. After having finished typing, the wait period finally expires from when alaska was typed, and our debounced function now actually calls our true loadOptions function, which returns a promise resolving to the correct list of just Alaska. However, nothing is subscribed to this promise and it has no effect. Remember, when our word, alaska, was finished being typed in, our debounced loadOptions function still being within the wait period, returned the "result of the last func invocation" (the promise from loadOptions for the al) and the Async#loadOptions code, having received a fine value), used it and moved on.

A solution

Using a promise-returning debounce method where subsequent calls return promises which will resolve to the result of the next func invocation.

Updated example using debounce-promise:
https://codesandbox.io/s/98vxxr18zw

All 12 comments

Great reproduction!

It doesn't appear to me that this issue has to do with the cache.

The issue

It seems to me the issue is with an inaccurate expectation of how Lodash's debounce works.

Lodash specifies that

subsequent calls to the debounced function return the result of the last func invocation

Not that:

subsequent calls return promises which will resolve to the result of the next func invocation

This means each call which is within the wait period to our debounced loadOptions prop function is actually returning the last func invocation, and so the "real" promise we care about is never subscribed to.

An example

Here is a forked example making it less react-select specific (removes the cacheOptions and defaultOptions props, passes the option {leading: true} to the debounce function, and makes the wait period longer):

https://codesandbox.io/s/olmjz7mn9z

User types a and waits past the debounce wait period (1 second):

  1. Async#handleInputChange is called, with a new value of a.
  2. Within this, Async#loadOptions is called and subscribes to the promise returned from the passed in loadOptions fn (which is our _.debounced fn).
  3. Our _.debounced fn returns a promise (thanks to {leading: true} option) which resolves to the correct data (eg, the list of Alabama, Alaska, America Samoa).

User then types l, then a, then s, then k, then a (all within the debounce wait period). We now have an input showing Alaska, but with the dropdown showing both Alabama and Alaska. Here's why:

  1. Async#handleInputChange is called, with a new value of al. Within this, Async#loadOptions is called and subscribes to the promise returned from the passed in loadOptions fn (which is our _.debounced fn), which is a promise which resolves to the correct data (both Alabama and Alaska).
  2. Async#handleInputChange is called, with a new value of ala. Within this, Async#loadOptions is called and subscribes to the promise returned from the passed in loadOptions fn (which is our _.debounced fn), which (because this event is within the wait period) returns the "result of the last func invocation", which is the promise from loadOptions from when al was typed in.
  3. Step two is repeated for alas, alask, and finally alaska each time with our debounced fn (because we're still within the debounce wait period) returning the "result of the last func invocation" which is the promise from loadOptions from when al was typed in. (This promise resolves to both Alabama and Alaska.)
  4. After having finished typing, the wait period finally expires from when alaska was typed, and our debounced function now actually calls our true loadOptions function, which returns a promise resolving to the correct list of just Alaska. However, nothing is subscribed to this promise and it has no effect. Remember, when our word, alaska, was finished being typed in, our debounced loadOptions function still being within the wait period, returned the "result of the last func invocation" (the promise from loadOptions for the al) and the Async#loadOptions code, having received a fine value), used it and moved on.

A solution

Using a promise-returning debounce method where subsequent calls return promises which will resolve to the result of the next func invocation.

Updated example using debounce-promise:
https://codesandbox.io/s/98vxxr18zw

@craigmichaelmartin Thanks for the clear explanation. Using { leading: true } does mean that the first character entered will always result in a call to loadOptions as shown in the timeline illustration here. So only the subsequent characters will be debounced

@craigmichaelmartin
Awesome job, this really works like a charm. I tried others solutions and this one it's perfect.
Well done! .

Another approach, that does seem to work with lodash's debounce, is to invoke the callback param React-Select pases to onLoad, and _don't_ return a Promise.

  constructor(props) {
    super(props);

    const wait = 1000; // milliseconds
    const loadOptions = (inputValue, callback) => {
      this.getAsyncOptions(inputValue)
        .then(results => callback(results))
      // Explicitly not returning a Promise.
      return;
    }
    this.debouncedLoadOptions = _.debounce(loadOptions, wait);
  }

The tricky part is that it's easy to accidentally return a promise when you're calling an async search function, if you use an arrow function or an async/await function.

@craigmichaelmartin Worked perfectly for my use-case. Thanks for the detailed post and sandbox!

    super(props);

    const wait = 1000; // milliseconds
    const loadOptions = (inputValue, callback) => {
      this.getAsyncOptions(inputValue)
        .then(results => callback(results))
      // Explicitly not returning a Promise.
      return;
    }
    this.debouncedLoadOptions = _.debounce(loadOptions, wait);
  }

Using this solution, if you load options and change your input without selecting anything, it will make the second call but the subsequent results will not be handled or show up on the options list.

Using this solution, if you load options and change your input without selecting anything, it will make the second call but the subsequent results will not be handled or show up on the options list.

Huh, I'm not experiencing that problem in my project. Maybe there's some subtlety in my particular use-case that makes it work.

Well, if the technique I described doesn't work for you, then I suggest going with the approach craigmichaelmartin described, using debounce-promise.

We decided to use a combination of lodash/debounce and Creatable instead of AsyncCreatableSelect. This allows us to handle all the async stuff on my own component state instead of letting the Select handle it internally with all its quirks.

Hello guys, I'm using react hooks and I'm using an async redux action to get the data when the user starts searching. It works fine and all, but the debouncing doesn't work. I'm guessing because I'm doing an async action that returns a promise.
How can I solve this? Does anyone have this issue?

Hello guys, I'm using react hooks and I'm using an async redux action to get the data when the user starts searching. It works fine and all, but the debouncing doesn't work. I'm guessing because I'm doing an async action that returns a promise.
How can I solve this? Does anyone have this issue?

Try debounce-promise NPM package.

Hello -

In an effort to sustain the react-select project going forward, we're closing old issues.

We understand this might be inconvenient but in the best interest of supporting the broader community we have to direct our efforts towards the current major version.

If you aren't using the latest version of react-select please consider upgrading to see if it resolves any issues you're having.

However, if you feel this issue is still relevant and you'd like us to review it - please leave a comment and we'll do our best to get back to you!

Was this page helpful?
0 / 5 - 0 ratings