Bondolin
Bondolin

Reputation: 3121

React Event AJAX Call Race Condition

Basic Scenario

I have a React textbox controlled component whose onChange event eventually triggers an AJAX call to a server-side API. The results of this call may potentially change the value of the textbox. So, there is a call to setState in the AJAX call's callback.

Basic Problem

I am having trouble finding a way to smoothly, consistently update this value when changes are made to the input before the AJAX call completes. There are two approaches I have tried so far. The main difference is in how eventually the AJAX call happens.

Approach 1

My first attempt calls setState with the immediately entered data, which eventually triggers a re-render and componentDidUpdate. The latter then makes the AJAX call, on the condition that the state data in question is different.

handleChange(event) {
    const inputTextValue = event.target.value;

    setState({ inputText: inputTextValue }); // will trigger componentDidUpdate
}

componentDidUpdate(lastProps, lastState) {
    const inputTextValue = this.state.inputText;

    if (lastState.inputText !== inputTextValue) { // string comparison to prevent infinite loop
        $.ajax({
            url: serviceUrl,
            data: JSON.stringify({ inputText: inputTextValue })
            // set other AJAX options
        }).done((response) => {
            setState({ inputText: response.validatedInputTextValue }); // will also trigger componentDidUpdate
        });
    }
}

This approach has the advantage of quickly updating the state to reflect the user's immediate input. However, if two inputs are made quickly, a sequence such as the following occurs:

  1. Event handler 1 fires with value '1'

    • Handler 1 calls setState with value '1'
    • Component re-rendered from change in state
    • componentDidUpdate triggered from re-render
    • Value '1' is different from last value, so
    • AJAX call 1 made with value '1'
  2. While AJAX call 1 in progress, event 2 handler fires with value '12'

    • Handler 2 calls setState with value '12'
    • componentDidUpdate triggered from re-render
    • Value '12' is different from '1', so
    • AJAX call 2 made with value '12'
  3. While AJAX call 2 in progress, AJAX call 1 returns with value '1'

    • AJAX callback 1 calls setState with value '1'
    • componentDidUpdate triggered from re-render
    • Value '1' is different from '12', so
    • AJAX call 3 made with value '1'
  4. While AAJX call 3 in progress, AJAX call 2 returns with value '12'...

TL;DR an infinite loop occurs despite the last-state check in componentDidUpdate, since two overlapping AJAX calls give alternating values to setState.

Approach 2

To address this, my second approach simplifies the system and makes the AJAX call directly from the event handler:

handleChange(event) {
    $.ajax({
        url: serviceUrl,
        data: JSON.stringify({ inputText: inputTextValue })
        // set other AJAX options
    }).done((response) => {
        setState({ inputText: response.validatedInputTextValue });
    });
}

If I do this, however, the immediate update of the controlled component value is stalled until the AJAX call completes and calls setState. It is simple and stable, only setting state and rendering once; but stalling input while waiting on an AJAX call is bad UX. The first approach at least has some semblance of an (overly) immediate update.

Approach 3?

While I am waiting for an answer, I am going to implement the following Approach 3, which is basically an enhanced version of Approach 1:

Question

I am still relatively new to React. I imagine someone else has encountered this use case, but I am having trouble finding a solution. I would like a way to set the state and update the component's value immediately, a la Approach 1, and still have Approach 2's data stability. Approach 3 seems promising, but a little too complicated. Is there an elegant pattern that accomplishes this?

Upvotes: 0

Views: 345

Answers (2)

Bondolin
Bondolin

Reputation: 3121

I ended up reverting back to Approach 1, but debouncing the input to eliminate the overlap.

In particular, I used Lodash to debounce a method refactored from the code in componentDidUpdate that actually made the AJAX call:

constructor(props) {
    super(props);

    this.handleChange = this.handleChange.bind(this);
    this.validateInput = this.validateInput.bind(this);
    this.validateInputDebounced = _.debounce(this.validateInput, 100);
}

handleChange(event) {
    const inputTextValue = event.target.value;

    setState({ inputText: inputTextValue }); // will trigger componentDidUpdate
}

componentDidUpdate(lastProps, lastState) {
    const inputTextValue = this.state.inputText;

    if (lastState.inputText !== inputTextValue) { // string comparison to prevent infinite loop
        validateInputDebounced(inputTextValue);
    }
}

validateInput(newInputTextValue) {
    $.ajax({
        url: serviceUrl,
        data: JSON.stringify({ inputText: newInputTextValue })
        // set other AJAX options
    }).done((response) => {
        setState({ inputText: response.validatedInputTextValue }); // will also trigger componentDidUpdate
    });
}

This is in part based on the work done here: https://medium.com/@platonish/debouncing-functions-in-react-components-d42f5b00c9f5

Edit

Upon further examination, this method falls short as well. If the AJAX call is sufficiently longer than the debounce, the requests potentially resolve out of order again. I think I will keep the debounce logic to save on network traffic; but the accepted solution, cancelling a previous in-progress request, sufficiently addresses the issue.

Upvotes: 0

silicakes
silicakes

Reputation: 6902

The suggested solution (#1) has a big caveat: You have no guarantee that the first request will return before the second.

In order to avoid it, you can follow one of these approaches:

Lock the select input:

Your select component:

const Select = props => {
  const {disabled, options} = props;
  return (<select disabled={disabled}>
           { options.map(item => <option value={item}> {item} </option> }
         </select>)

}

Your logical component:

class LogicalComponent extends React.Component {

  constructor(props) {
    this.state = {
      selectDisabled: false;
      options: ['item1', 'item2', 'item3'],
      inputText: ''
    }
  }

handleChange(event) {
    const inputTextValue = event.target.value;

    setState({ inputText: inputTextValue }); // will trigger componentDidUpdate
}

componentDidUpdate(lastProps, lastState) {
    const inputTextValue = this.state.inputText;

    if (lastState.inputText !== inputTextValue) { // string comparison to prevent infinite loop

        // disabling the select until the request finishes
        this.setState({ selectDisabled: true });
        $.ajax({
            url: serviceUrl,
            data: JSON.stringify({ inputText: inputTextValue })
            // set other AJAX options
        }).done((response) => {

            //re-enabling it when done
            setState({ inputText: response.validatedInputTextValue, selectDisabled: false }); // will also trigger componentDidUpdate
         // don't forget to enable it when the request is failed
        }).fail(res => this.setState({selectDisabled: false}));
    }
  }

  render() {
     const { selectDisabled, options, inputText } = this.state;
     return <>
              <Select disabled={selectDisabled} options={options} />
              <input type="text" value={inputText}/>
            <>
  }
}

Cancel the request that's in progress

If you already have an AJAX request in progress, you can cancel it and fire a new one. This will guarantee that only the recent request is returned.


class LogicalComponent extends React.Component {

  constructor(props) {
    this.requestInProgress = null;
    this.state = {
      options: ['item1', 'item2', 'item3'],
      inputText: ''
    }
  }

handleChange(event) {
    const inputTextValue = event.target.value;

    setState({ inputText: inputTextValue }); // will trigger componentDidUpdate
}

componentDidUpdate(lastProps, lastState) {
    const inputTextValue = this.state.inputText;

    if (lastState.inputText !== inputTextValue) { // string comparison to prevent infinite loop

        // checking to see if there's a request in progress
        if(this.requestInProgress && this.requestInProgress.state() !== "rejected") {
          // aborting the request in progress
          this.requestInProgress.abort();
        }
        // setting the current requestInProgress
        this.requestInProgress = $.ajax({
            url: serviceUrl,
            data: JSON.stringify({ inputText: inputTextValue })
            // set other AJAX options
        }).done((response) => {

        setState({ inputText: response.validatedInputTextValue }); // will also trigger componentDidUpdate
         // don't forget to enable it when the request is failed
        })
    }
  }

  render() {
     const { selectDisabled, options, inputText } = this.state;
     return <>
              <Select disabled={selectDisabled} options={options} />
              <input type="text" value={inputText}/>
            <>
  }
}

Upvotes: 1

Related Questions