Reputation: 3121
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:
Event handler 1 fires with value '1'
setState
with value '1'
componentDidUpdate
triggered from re-render'1'
is different from last value, so'1'
While AJAX call 1 in progress, event 2 handler fires with value '12'
setState
with value '12'
componentDidUpdate
triggered from re-render'12'
is different from '1'
, so'12'
While AJAX call 2 in progress, AJAX call 1 returns with value '1'
setState
with value '1'
componentDidUpdate
triggered from re-render'1'
is different from '12'
, so'1'
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:
setState
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
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
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
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:
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}/>
<>
}
}
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