There are many explanations out there about unwanted caret jumping in React inputs, but I couldn't find any that addressed the issue we found.
This might be an easy way to encounter the issue in complex apps, so I thought I'd add my grain of sand.
The inciting situation - async update on each keystroke
We have a controlled input that gets its value from a context that is updated asynchronously, and for this input in particular, it is updated per keystroke, not after blur.
This makes the input receive a possibly updated value every keystroke. If you have a caret in a middle position and the value changes unexpectedly, the input element won't make any assumption on the caret position, and will jump it to the end.
The problem we faced is that it also jumps when the value hasn't changed at all, except for the new character just typed.
Note that this flow might be necessary in some cases, but it is generally a bad idea. As a general rule, do not change the input value asynchronously while the user is typing.
Why does the caret jump
When you inject programmatically a different value in a DOM input, the input makes no assumption about caret position and moves it to the end.
In a controlled input, React is always capturing the input's events and then forcing a new value into the element. So, in order to avoid the caret jumping always, React will optimise(*) the synchronous updates, but it will not be able to do anything with asynchronous updates.
(*) This may be related to React internals, which I'm not the best person to ask about. I tried reproducing the caret jump with vanilla JS, to no success. If you can explain the specific reason, please be welcome to do so in the comments!
See this React issue: https://github.com/facebook/react/issues/5386
As Dan Abramov puts it:
If you use controlled inputs, you’re expected to update the value synchronously. If you need to, for example, debounce, you can do this after updating the value. For example one might maintain two values in the state: one for the input, and one for the “debounced” value.
From the point of view of the input element, the value was hell| world
with the caret at the |
, then the user pressed o
but the event was prevented from happening, and the next it knows is that it's receiving a new value that is different, hello world
, but it could as well be good bye
and it's not the input's job to compare it, so it puts the caret at the end.
How to solve it
Make always a synchronous update before sending the update up the asynchronous flow.
If we have this, assuming onChange
is asynchronous:
const Broken = ({ value, onChange }) => {
return <input value={value} onChange={(e) => onChange(e.target.value)} />;
};
We want to allow React to do the synchronous optimisation, to tell the input "Hey, this keypress was for you, do your thing and move the caret naturally".
Then, when the asynchronous update returns, if the value has not changed, the caret will not move. If the value has changed asynchronously (from another source of truth), then the caret will jump, and that is OK (**).
(**) If it is not OK for you, you will need to store the caret position yourself and implement your own custom logic to your liking. See https://stackoverflow.com/questions/46000544/react-controlled-input-cursor-jumps
How do we do that? We put a synchronous cache between the input and the async store. For example, with local state:
const Fixed = ({ value, onChange }) => {
const [val, setVal] = useState(value);
const updateVal = (val) => {
/* Make update synchronous, to avoid caret jumping when the value doesn't change asynchronously */
setVal(val);
/* Make the real update afterwards */
onChange(val);
};
return <input value={val} onChange={(e) => updateVal(e.target.value)} />;
};
And that's it. You can find the full example code here:
https://codesandbox.io/s/react-caret-jump-3huvm?file=/src/App.js
Top comments (8)
thank you for this good writeup and example
This helped me a lot to solve my problem, thanks!
This helped solved my problem. Thanks a lot!
Thanks for sharing!
I tried your codesandbox & sorry to say, but your approach is wrong. There is no need for such complex nesting of components. What you have achieved with that code can easily be done with a simple input component with custom onChange function (see my code at the end of this comment).
The problem in your code - the updateVal method of AsynchronousStateManagement does not really help if you want to modify the value in any way. For example try adding a few extra characters after the val like -
The input value still follows the saved state of it's own (inside the Fixed component), so any value update you make inside this updateVal function of AsynchronousStateManagement is just meaningless, it is lost.
And if your goal is to do some async task with the updated value, without affecting the original value or the input field, then just do that after updating the value. Why would you use 2 separate components with 2 separate state managements for that? The purpose of useState hook is to reload the dependent components after the state changes. In this case your useState in AsynchronousStateManagement is totally useless. What you are doing in that code sandbox can easily be achieved like this -
Thank you for reading this and taking the time to think other solutions.
The component AsynchronousStateManagement in the example represents the rest of the system. The code outside the Input component is a simplification of the real case, which had needs that do not relate to the issue at hand.
The solution you propose breaks the component contract. We needed a component that could be passed an (async) onChange call for the rest of the system to use as required, but your component does not accept that prop. Besides, doing API calls inside what should be an agnostic Input component couples the code, making you duplicate the Input component for each field, or dumping complex API interaction logic inside it.
In conclusion: If you don't need to receive an asynchronous onChange callback in your component, you don't have this specific issue and this article can't help you.
Now, question is how to do the same for
contentEditable
.... 🧐Hey there! I wrote extensively about the same subject answering your "Why does the caret jump" with a deep dive inside React internals. Hope you'll like it: giacomocerquone.com/keep-input-cur...