12

React Async Select loadoption sometimes fail to loads the option. This is a very strange phenomenon after couple of set of queries react loadoptions don't load any value but i can see from log that results properly came from backend query. My codebase is totally up to date with react-select new release and using

"react-select": "^2.1.1"

Here is my front end code for react-async select component. I do use debounce in my getOptions function to reduce number of backend search query. This should not cause any problem i guess. I would like to add another point that i observe in this case, loadoptions serach indicator ( ... ) also not appear in this phenomenon.

import React from 'react';
import AsyncSelect from 'react-select/lib/Async';
import Typography from '@material-ui/core/Typography';
import i18n from 'react-intl-universal';

const _ = require('lodash');

class SearchableSelect extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      inputValue: '',
      searchApiUrl: props.searchApiUrl,
      limit: props.limit,
      selectedOption: this.props.defaultValue
    };
    this.getOptions = _.debounce(this.getOptions.bind(this), 500);
    //this.getOptions = this.getOptions.bind(this);
    this.handleChange = this.handleChange.bind(this);
    this.noOptionsMessage = this.noOptionsMessage.bind(this);
    this.handleInputChange = this.handleInputChange.bind(this);
  }

  handleChange(selectedOption) {
    this.setState({
      selectedOption: selectedOption
    });
    if (this.props.actionOnSelectedOption) {
      // this is for update action on selectedOption
      this.props.actionOnSelectedOption(selectedOption.value);
    }
  }

  handleInputChange(inputValue) {
    this.setState({ inputValue });
    return inputValue;
  }

  async getOptions(inputValue, callback) {
    console.log('in getOptions'); // never print
    if (!inputValue) {
      return callback([]);
    }
    const response = await fetch(
      `${this.state.searchApiUrl}?search=${inputValue}&limit=${
        this.state.limit
      }`
    );
    const json = await response.json();
    console.log('results', json.results); // never print
    return callback(json.results);
  }

  noOptionsMessage(props) {
    if (this.state.inputValue === '') {
      return (
        <Typography {...props.innerProps} align="center" variant="title">
          {i18n.get('app.commons.label.search')}
        </Typography>
      );
    }
    return (
      <Typography {...props.innerProps} align="center" variant="title">
        {i18n.get('app.commons.errors.emptySearchResult')}
      </Typography>
    );
  }
  getOptionValue = option => {
    return option.value || option.id;
  };

  getOptionLabel = option => {
    return option.label || option.name;
  };

  render() {
    const { defaultOptions, placeholder } = this.props;
    return (
      <AsyncSelect
        cacheOptions
        value={this.state.selectedOption}
        noOptionsMessage={this.noOptionsMessage}
        getOptionValue={this.getOptionValue}
        getOptionLabel={this.getOptionLabel}
        defaultOptions={defaultOptions}
        loadOptions={this.getOptions}
        placeholder={placeholder}
        onChange={this.handleChange}
      />
    );
  }
}

export default SearchableSelect;

Edit to response Steve's answer

Thank you for your answer Steve. Still no luck. I try to response according to your response points.

  1. If i don't use optionsValue, rather use getOptionValue and getOptionLevel then query result don't loaded properly. I mean there blank options loaded, no text value.
  2. yes you are right, is a synchronous method returning a string, i don't need to override this. And this working fine and noOptionsMessage shows properly. Thanks to point this out.
  3. actionOnSelectedOption is not a noop method, its may have some responsibility to perform. I try to use SearchableSelect as an independent component, if i need some back-end action to do this function will trigger that accordingly. For example, i use this in my project's user-profile, where user can update his school/college information from existing entries. When user select an option there is a profile update responsibility to perform.
  4. Yes you are right. I don't need to maintain inputValue in state, thanks.
  5. I do make sure defaultOptions is an array.
  6. I do test without using debounce, still no luck. i am using debounce to limit the backend call, otherwise there may a backend call for every key-stroke that surely i don't want.

async select work perfectly for 2/3 queries and after that it suddenly stop working. One distinguishable behaviour i observe that for those cases search indicators ( ... ) also not showing.

Thank you so much for you time.

Edit 2 to response Steve's answer

Thank you so much for your response again. I was wrong about getOptionValue and getOptionLabel. If loadOptions got response both these function called. So i removed my helper optionsValue function from my previous code snippet and update my code-snippet according to ( In this post also ). But still no luck. In some cases async-select didn't work. I try to take a screenshot one such case. I do name use in my local-db name "tamim johnson" but when i search him i didn't get any response but got proper response back from back-end. Here is the screenshot of this case tamim johnson

I not sure how clear this screenshot is. Tamim johnson also in 6th position in my ranklist.

Thank you sir for your time. I have no clue what i am doing wrong or missing something.

Edit 3 to response Steve's answer

This is preview tab response for user search named "tamim johnson".

preview tab

Shakil
  • 3,607
  • 2
  • 21
  • 32
  • The `noop` defaultProp is so that, in the event they did not include an `actionOnSelectedOption` prop, that you could just call it (no conditional). If getOptionValue and getOptionLabel didn't work for you the way I wrote it, then somethings not right in your options array. Can you post an example of the response? – Steve -Cutter- Blades Oct 25 '18 at 17:40
  • @Steve-Cutter-Blades hello sir, i edit my question for your response. Thank you so much for your time. – Shakil Oct 28 '18 at 07:33
  • You need to post an example of the response. You screen shot the response tab in DevTools, but the Preview tab gives you the ability to expand the response object much more clearly. Also, knowing exactly what parameters you send in your request (i.e. the `inputValue` in each of your calls, and which pass and which do not) – Steve -Cutter- Blades Oct 29 '18 at 12:11
  • @Steve-Cutter-Blades thank you sir, for your time again. I updated my screen shot with preview tab. – Shakil Oct 30 '18 at 06:12
  • I'm sorry but the screenshot is just too small for me to see any detail. If you could attach a screenshot of just the Preview tab, as well as give me the search params you're trying to use. – Steve -Cutter- Blades Oct 30 '18 at 11:07
  • @Steve-Cutter-Blades i am really sorry for that. I do an another edit for this. – Shakil Oct 30 '18 at 11:15
  • So I see the call being made, and the response being returned in the proper format. What I'm wondering now is if it has something to do with the way your async/await method itself is constructed. I haven't used async/await, but it seems to me that it implies the return as a promise, in which case you're mixing things by using the callback. Try to set your `getOptions` to just return the `json.result` (no callback). – Steve -Cutter- Blades Oct 30 '18 at 11:26
  • @Steve-Cutter-Blades sir still no luck, this work fine for first 2/3 response on average then not able to load results on time. Though result can be get from cacheOptions using backspace and typing again but this is not good for UX . – Shakil Oct 30 '18 at 12:59
  • Have you tried to remove `cacheOptions` entirely? (I've never used that option) – Steve -Cutter- Blades Oct 30 '18 at 14:00
  • @Steve-Cutter-Blades still no luck sir. everything works fine for first 2/3 response then problem started. – Shakil Oct 31 '18 at 06:17
  • Then I would think something else is blocking you here. The configuration in the answer below is correct. I would begin by a) debugging by placing breakpoints in your `getOptions` method to track progression in your component, and b) tracing the requests in your Network panel of DevTools, to ensure that the requests/response are as intended. (Think I'd still remove the `debounce` as well, at least to test) – Steve -Cutter- Blades Oct 31 '18 at 11:15

3 Answers3

23

I found out that people intend to look for this problem. So i am posting my update portion of code that fix the issue. Converting from async-await to normal callback function fix my issue. Special thanks to Steve and others.

import React from 'react';
import AsyncSelect from 'react-select/lib/Async';
import { loadingMessage, noOptionsMessage } from './utils';
import _ from 'lodash';

class SearchableSelect extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      selectedOption: this.props.defaultValue
    };
    this.getOptions = _.debounce(this.getOptions.bind(this), 500);
  }

  handleChange = selectedOption => {
    this.setState({
      selectedOption: selectedOption
    });
    if (this.props.actionOnSelectedOption) {
      this.props.actionOnSelectedOption(selectedOption.value);
    }
  };

  mapOptionsToValues = options => {
    return options.map(option => ({
      value: option.id,
      label: option.name
    }));
  };

  getOptions = (inputValue, callback) => {
    if (!inputValue) {
      return callback([]);
    }

    const { searchApiUrl } = this.props;
    const limit =
      this.props.limit || process.env['REACT_APP_DROPDOWN_ITEMS_LIMIT'] || 5;
    const queryAdder = searchApiUrl.indexOf('?') === -1 ? '?' : '&';
    const fetchURL = `${searchApiUrl}${queryAdder}search=${inputValue}&limit=${limit}`;

    fetch(fetchURL).then(response => {
      response.json().then(data => {
        const results = data.results;
        if (this.props.mapOptionsToValues)
          callback(this.props.mapOptionsToValues(results));
        else callback(this.mapOptionsToValues(results));
      });
    });
  };

  render() {
    const { defaultOptions, placeholder, inputId } = this.props;
    return (
      <AsyncSelect
        inputId={inputId}
        cacheOptions
        value={this.state.selectedOption}
        defaultOptions={defaultOptions}
        loadOptions={this.getOptions}
        placeholder={placeholder}
        onChange={this.handleChange}
        noOptionsMessage={noOptionsMessage}
        loadingMessage={loadingMessage}
      />
    );
  }
}

export default SearchableSelect;
Shakil
  • 3,607
  • 2
  • 21
  • 32
10

Some notes can be found below the code. You're looking for something like this:

import React, {Component} from 'react';
import PropTypes from 'prop-types';
import AsyncSelect from 'react-select/lib/Async';
import debounce from 'lodash.debounce';
import noop from 'lodash.noop';
import i18n from 'myinternationalization';

const propTypes = {
  searchApiUrl: PropTypes.string.isRequired,
  limit: PropTypes.number,
  defaultValue: PropTypes.object,
  actionOnSelectedOption: PropTypes.func
};

const defaultProps = {
  limit: 25,
  defaultValue: null,
  actionOnSelectedOption: noop
};

export default class SearchableSelect extends Component {
  static propTypes = propTypes;
  static defaultProps = defaultProps;
  constructor(props) {
    super(props);
    this.state = {
      inputValue: '',
      searchApiUrl: props.searchApiUrl,
      limit: props.limit,
      selectedOption: this.props.defaultValue,
      actionOnSelectedOption: props.actionOnSelectedOption
    };
    this.getOptions = debounce(this.getOptions.bind(this), 500);
    this.handleChange = this.handleChange.bind(this);
    this.noOptionsMessage = this.noOptionsMessage.bind(this);
    this.handleInputChange = this.handleInputChange.bind(this);
  }

  getOptionValue = (option) => option.id;

  getOptionLabel = (option) => option.name;

  handleChange(selectedOption) {
    this.setState({
      selectedOption: selectedOption
    });
    // this is for update action on selectedOption
    this.state.actionOnSelectedOption(selectedOption.value);
  }

  async getOptions(inputValue) {
    if (!inputValue) {
      return [];
    }
    const response = await fetch(
      `${this.state.searchApiUrl}?search=${inputValue}&limit=${
      this.state.limit
      }`
    );
    const json = await response.json();
    return json.results;
  }

  handleInputChange(inputValue) {
    this.setState({ inputValue });
    return inputValue;
  }

  noOptionsMessage(inputValue) {
    if (this.props.options.length) return null;
    if (!inputValue) {
      return i18n.get('app.commons.label.search');
    }

    return i18n.get('app.commons.errors.emptySearchResult');
  }

  render() {
    const { defaultOptions, placeholder } = this.props;
    const { selectedOption } = this.state;
    return (
      <AsyncSelect
        cacheOptions
        value={selectedOption}
        noOptionsMessage={this.noOptionsMessage}
        getOptionValue={this.getOptionValue}
        getOptionLabel={this.getOptionLabel}
        defaultOptions={defaultOptions}
        loadOptions={this.getOptions}
        placeholder={placeholder}
        onChange={this.handleChange}
      />
    );
  }
}
  1. You don't need the method to map your result set. There are props that handle that for you.
  2. If your i18n.get() is a synchronous method returning a string, you don't have to override the entire component (even for styling changes)
  3. If you default your actionOnSelectedOption to a noop method, then you no longer require a conditional to call it.
  4. React-Select tracks inputValue internally. Unless you have some need externally (your wrapper) there isn't a need to try to manage it's state.
  5. defaultOptions is either
    • an array of default options (will not call the loadOptions until you filter)
    • true (will autoload from your loadOptions method)
  6. Async/Await functions return a promise, using the promise response rather than the callback type.

I'm wondering if, by wrapping your getOptions() method in debounce, that you're breaking this scope with your component. Can't say for sure, as I've never used debounce before. You might pull that wrapper and try your code to test.

Steve -Cutter- Blades
  • 3,323
  • 1
  • 20
  • 33
8

The issue is that Lodash's debounce function is not suitable for this. 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 the debounced loadOptions prop function is actually returning the last func invocation, and so the "real" promise we care about is never subscribed to.

Instead use a promise-returning debounce function

For example:

import debounce from "debounce-promise";

//...
this.getOptions = debounce(this.getOptions.bind(this), 500);

See full explanation https://github.com/JedWatson/react-select/issues/3075#issuecomment-450194917

craigmichaelmartin
  • 4,403
  • 1
  • 16
  • 20
  • 2
    This must have been an accepted answer for it's simple, elegant and truly address why debounce from lodash is not a good fit with react-select's async. – Pruthvi Kumar Dec 15 '19 at 00:30
  • 1
    This was the solution i was looking for. The example on github specified that 'leading:true' should be added as an option value as well. In my situation this wasn't needed. After removing it, it works perfectly. I had a search field that fired API calls after each keystroke, this promise debounce now only fires when the user finished typing. Thanks! :-) – Nygashi Dec 15 '20 at 08:53