In ReactJS, why does `setState` behave differently when called synchronously?
JavascriptReactjsJavascript Problem Overview
I am trying to understand the underlying cause for some somewhat "magical" behavior I am seeing that I cannot fully explain, and which is not apparent from reading the ReactJS source code.
When calling the setState
method synchronously in response to an onChange
event on an input, everything works as expected. The "new" value of the input is already present, and so the DOM is not actually updated. This is highly desirable because it means the cursor will not jump to the end of the input box.
However, when running a component with exactly the same structure but that calls setState
asynchronously, the "new" value of the input does not appear to be present, causing ReactJS to actually touch the DOM, which causes the cursor to jump to the end of the input.
Apparently, something is intervening to "reset" the input back to its prior value
in the asynchronous case, which it is not doing in the synchronous case. What is this mechanic?
Synchronous Example
var synchronouslyUpdatingComponent =
React.createFactory(React.createClass({
getInitialState: function () {
return {value: "Hello"};
},
changeHandler: function (e) {
this.setState({value: e.target.value});
},
render: function () {
var valueToSet = this.state.value;
console.log("Rendering...");
console.log("Setting value:" + valueToSet);
if(this.isMounted()) {
console.log("Current value:" + this.getDOMNode().value);
}
return React.DOM.input({value: valueToSet,
onChange: this.changeHandler});
}
}));
Note that the code will log in the render
method, printing out the current value
of the actual DOM node.
When typing an "X" between the two Ls of "Hello", we see the following console output, and the cursor stays where expected:
Rendering...
Setting value:HelXlo
Current value:HelXlo
Asynchronous Example
var asynchronouslyUpdatingComponent =
React.createFactory(React.createClass({
getInitialState: function () {
return {value: "Hello"};
},
changeHandler: function (e) {
var component = this;
var value = e.target.value;
window.setTimeout(function() {
component.setState({value: value});
});
},
render: function () {
var valueToSet = this.state.value;
console.log("Rendering...");
console.log("Setting value:" + valueToSet);
if(this.isMounted()) {
console.log("Current value:" + this.getDOMNode().value);
}
return React.DOM.input({value: valueToSet,
onChange: this.changeHandler});
}
}));
This is precisely the same as the above, except that the call to setState
is in a setTimeout
callback.
In this case, typing an X between the two Ls yields the following console output, and the cursor jumps to the end of the input:
Rendering...
Setting value:HelXlo
Current value:Hello
Why is this?
I understand React's concept of a Controlled Component, and so it makes sense that user changes to the value
are ignored. But it looks like the value
is in fact changed, and then explicitly reset.
Apparently, calling setState
synchronously ensures that it takes effect before the reset, while calling setState
at any other time happens after the reset, forcing a re-render.
Is this in fact what's happening?
JS Bin Example
Javascript Solutions
Solution 1 - Javascript
Here's what's happening.
Synchronous
- you press X
- input.value is 'HelXlo'
- you call
setState({value: 'HelXlo'})
- the virtual dom says the input value should be 'HelXlo'
- input.value is 'HelXlo'
- no action taken
Asynchronous
- you press X
- input.value is 'HelXlo'
- you do nothing
- the virtual DOM says the input value should be 'Hello'
- react makes input.value 'Hello'.
Later on...
- you
setState({value: 'HelXlo'})
- the virtual DOM says the input value should be 'HelXlo'
- react makes input.value 'HelXlo'
- the browser jumps the cursor to the end (it's a side effect of setting .value)
Magic?
Yes, there's a bit of magic here. React calls render synchronously after your event handler. This is necessary to avoid flickers.
Solution 2 - Javascript
Using defaultValue rather than value resolved the issue for me. I'm unsure if this is the best solution though, for example:
From:
return React.DOM.input({value: valueToSet,
onChange: this.changeHandler});
To:
return React.DOM.input({defaultValue: valueToSet,
onChange: this.changeHandler});
JS Bin Example
Solution 3 - Javascript
As mentioned, this will be an issue when using controlled components because React is updating the value of the input, rather than vice versa (React intercepts the change request and updates its state to match).
FakeRainBrigand's answer is great, but I have noticed that It's not entirely whether an update is synchronous or asynchronous that causes the input to behave this way. If you are doing something synchronously like applying a mask to modify the returned value it can also result in the cursor jumping to the end of the line. Unfortunately(?) this is just how React works with respect to controlled inputs. But it can be manually worked around.
There is a great explanation and discussion of this on the react github issues, which includes a link to a JSBin solution by Sophie Alpert [that manually ensures the cursor remains where it ought to be]
This is achieved using an <Input>
component like this:
var Input = React.createClass({
render: function() {
return <input ref="root" {...this.props} value={undefined} />;
},
componentDidUpdate: function(prevProps) {
var node = React.findDOMNode(this);
var oldLength = node.value.length;
var oldIdx = node.selectionStart;
node.value = this.props.value;
var newIdx = Math.max(0, node.value.length - oldLength + oldIdx);
node.selectionStart = node.selectionEnd = newIdx;
},
});
Solution 4 - Javascript
This is not exactly an answer, but one possible approach to mitigating the issue. It defines a wrapper for React inputs that manages value updates synchronously via a local state shim; and versions the outgoing values so that only the latest returned from asynchronous processing is ever applied.
It's based on some work by Stephen Sugden (https://github.com/grncdr) which I updated for modern React and improved by versioning the values, which eliminates the race condition.
It's not beautiful :)
http://jsfiddle.net/yrmmbjm1/1/
var AsyncInput = asyncInput('input');
Here is how components need to use it:
var AI = asyncInput('input');
var Test = React.createClass({
// the controlling component must track
// the version
change: function(e, i) {
var v = e.target.value;
setTimeout(function() {
this.setState({v: v, i: i});
}.bind(this), Math.floor(Math.random() * 100 + 50));
},
getInitialState: function() { return {v: ''}; },
render: function() {
{/* and pass it down to the controlled input, yuck */}
return <AI value={this.state.v} i={this.state.i} onChange={this.change} />
}
});
React.render(<Test />, document.body);
Another version that attempts to make the impact on the controlling component's code less obnoxious is here:
http://jsfiddle.net/yrmmbjm1/4/
That ends up looking like:
var AI = asyncInput('input');
var Test = React.createClass({
// the controlling component must send versionedValues
// back down to the input
change: function(e) {
var v = e.target.value;
var f = e.valueFactory;
setTimeout(function() {
this.setState({v: f(v)});
}.bind(this), Math.floor(Math.random() * 100 + 50));
},
getInitialState: function() { return {v: ''}; },
render: function() {
{/* and pass it down to the controlled input, yuck */}
return <AI value={this.state.v} onChange={this.change} />
}
});
React.render(<Test />, document.body);
¯\(ツ)/¯
Solution 5 - Javascript
I've had same issue when using Reflux. State was stored outside of a React Component which caused similar effect as wrapping setState
inside of a setTimeout
.
@dule suggested, we should make our state change synchronous and asynchronous at the same time. So I've prepared an HOC that ensures that value change is synchronous - so it's cool to wrap input that suffers from asynchronous state change.
A note: this HOC will work for only for components that have similar to <input/>
API, but I guess it's straightforward to make it more generic if there would be such need.
import React from 'react';
import debounce from 'debounce';
/**
* The HOC solves a problem with cursor being moved to the end of input while typing.
* This happens in case of controlled component, when setState part is executed asynchronously.
* @param {string|React.Component} Component
* @returns {SynchronousValueChanger}
*/
const synchronousValueChangerHOC = function(Component) {
class SynchronousValueChanger extends React.Component {
static propTypes = {
onChange: React.PropTypes.func,
value: React.PropTypes.string
};
constructor(props) {
super(props);
this.state = {
value: props.value
};
}
propagateOnChange = debounce(e => {
this.props.onChange(e);
}, onChangePropagationDelay);
onChange = (e) => {
this.setState({value: e.target.value});
e.persist();
this.propagateOnChange(e);
};
componentWillReceiveProps(nextProps) {
if (nextProps.value !== this.state.value) {
this.setState({value: nextProps.value});
}
}
render() {
return <Component {...this.props} value={this.state.value} onChange={this.onChange}/>;
}
}
return SynchronousValueChanger;
};
export default synchronousValueChangerHOC;
const onChangePropagationDelay = 250;
And then it can be used in such a way:
const InputWithSynchronousValueChange = synchronousValueChangerHOC('input');
By making it HOC we can have it working for inputs, textarea and probably for others as well. Maybe the name is not the best one, so if anyone of you have a suggestion how to improve, let me know :)
There is a hack with debounce, because sometimes, when typing was done really quickly the bug reappeared.
Solution 6 - Javascript
We have a similar issue and in our case we have to use asyncronous state updates.
So we use defaultValue, and add a key
param to the input associated with the model that the input is reflecting. This insures that for any model the input will remain syncronized to the model, but if the actual model changes will force a new input to be generated.