Keep that cursor still!
We will analyze all the root causes for cursor jumps when handling inputs. Since the responsibility of this problem is mixed between the browsers and React, I've showcased examples in vanilla JS, React, and mentioned other frameworks too. Warning, there will be a deep dive into React's internals.
Long time no see fellows 🤠
Today I’m here to tell you a new, weird, story from the webdev world. Hang tight on this one and brace yourself for a deep dive. No other job planned to be simple became this haunting in the end.
Just so you know, I started writing this post almost two years ago. Then work led me somewhere else, and day after day, the reasonings as well as the need to experiment and gather all the information in one place as ordered thoughts, disappeared and so did the desire of writing it.
And yet, here we go again, writing my piece to share with you the knowledge I acquired while trying to customize input fields, across multiple environments, making them multi-purpose and feature-rich.
The context
From the very beginning of my work at Musixmatch, products’ tasks aside, I went full engine on implementing a variety of reusable components and utilities to simplify and streamline certain UX patterns among the developers. Right now, I’m specialized (and strongly dedicated) in component libraries and design systems implementations in React Native with the added complexity of deploying not only on native platforms but on the web too. We’ll refer to such code as universal code (the terminology is borrowed from Expo).
Among various tasks, there was one about capitalizing the first letter of the strings within the TextInputs. If that doesn’t sound like a difficult task to you, let me try to change your mind.
Keeping the axe (un)sharp
When asked to make this change, I thought for a moment about transforming the way the value was displayed inside the input field. I realized just a few seconds after that the idea was dangerously silly. This reasoning came from the ability of simply setting text-transform: capitalize;
in CSS to trick the browser, and therefore the user, into showing a capitalized string while storing a non-capitalized value… a nightmare, it’s like shooting yourself in the foot for future bugfixing. On top of that, text-transform
doesn’t work in native environments with React Native’s TextInputs.
You see, “keeping the axe sharp” means to work smart rather than working hard, and yet this was anything but smart.
Let’s see some code
The task described served as an introduction, but we’ll focus on value transformations performed inside inputs’ onChange handlers.
So, to indulge my laziness, we’ll simply call the String.prototype.toUpperCase()
function instead of going after a custom capitalization implementation.
This is the first naive attempt:
import { useState, ChangeEvent } from "react";
export default function App() {
const [val, setVal] = useState("hello");
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
setVal(e.target.value.toUpperCase());
};
return (<input value={val} onChange={onChange} />);
}
Since it can’t be one of those “discover the problem” questions by only looking at the code, play with the following CodeSandbox.
Transforming a value in an onChange handler causes some quirky problems
As suggested on the page, add something in between the string to see the input’s cursor resetting at the end of the value.
I invite you to play with it for ten minutes to understand what’s happening before following along.
Oh god, no.
This is definitely a sneaky and subtle bug. You might miss testing this particular scenario and when the code gets merged, finding the cause isn’t as straightforward as checking the above CodeSandbox.
I was seeking a fast resolution so I started doing funky string arithmetics to try to get the diff in terms of chars length between the new and old values to find the right spot for the cursor. All this, while also investigating the problem… I wish I had never searched for it: countless explanations, solutions, and similar, but different, problems were reported about this legendary “jumping cursor” on GitHub, StackOverflow, dev.to, and whatnot. It was even a recurring argument at the beginning of React’s story in 2015 that only increased the confusion.
Not a single answer was satisfying to me, so I realized it was time to write my own piece to shed some light and bring order.
Who is responsible?
I verified the issue was happening on Preact too (a simpler and lighter version of React) and I opened its much simpler source code. To my disappointment, the onChange handler (called onInput) wasn’t called at all there (that’s because it uses DOM events and doesn’t implement SyntheticEvents). This means that the responsibility falls on the browser and that the issue can be easily reproduced in vanilla JS too:
Also in vanilla JavaScript, the problem can be reproduced
What causes cursors to jump?
Among the infinite number of issues online and after a huge amount of time spent researching, I identified two problematic patterns developers implement that result in the cursor getting lost (at the time of writing, in 2023, with React):
- Value transformation within the onChange handler
- Setting the value after JavaScript (together with React) closed the timeframe for the edit to be made or setting nothing at all
But both of them (and many others that I may not have noticed) always lead to one root cause, which is this HTML spec:
The specs tell the browser exactly where the cursor (aka selection) should be positioned when a so-called, “relevant value” changes.
The above algorithm, if you may, it’s dramatically simple: every time a new value is received in the input, you just place the cursor to the right of this new value.
Value transformation behavior rationale
If we focus on the first pattern, after reading the specs, it’s logical to think that the browser simply doesn’t recognize the transformed value as the value that should be there and treats the entire string as a new one, and so the caret is righteously placed at the end of it.
What is “the value that should be there”?
When you write something inside an HTML form element, the first to receive the value is the “native input” and only after that, some handler in the surface API is called. So, whatever the framework you use (even if you’re passing a prop called value), the input sees the lowercase value first, some handler gets called, and when the transformed value is set back to the element, be it in vanilla JS, React, Vue, or any other framework, it is different than the “native” one, it will be considered entirely brand new, hence the cursor gets shifted at the end of what the HTML specs call “relevant value”, which is the entire string now, and not just the single letter you wrote.
Want a proof of all this? Check your DOM events in the previous CodeSandbox. You will notice e.target.selectionStart
as well as e.target.selectionEnd
to have the correct positions that the cursor should have had and that instead got lost for the above reasons (just make sure to log the event before setting the value, otherwise you’ll get the position after React forced the update on the element, see bonus info at the end of the post).
You can also counter-verify this by setting a breakpoint on the “input” event noticing that before executing any React code, the letter you’ve added is already there.
Async value setting behavior rationale
In the second pattern, there is a bit of React magic going on. Check the example:
Setting an input’s value asynchronously
After each incoming onChange event, React forces the value prop to the native HTML element that has already acquired a keystroke from the user’s keyboard. During this timeframe of high-priority code block execution (a microtask in JavaScript’s terms, more on this down below), if you didn’t give your setState call to React, the value that will be reconciled is the old one. In this way the native HTML input’s value and the one React will force on it are different. This, as we know, will make the cursor go to the end.
In the future, the new value will be set in the state and this time, the re-render triggers a synchronization of the new value with the input. This value, from the browsers’ perspective, comes out of nowhere since no event originated it and no comparison can be made. The cursor is placed at the end for a second time (you can observe this if you look carefully at its blinking pattern).
At this point, try to experiment and write within an Input that has no onChange handler and see what happens (I discovered only now that the new React docs mention this, kudos to them):
Even without an onChange handler (and no re-rendering) if you keep pushing buttons on your keyboard, the cursor will stay always active without blinking
Just some thoughts.
We always try to explain things when something changes in React through the concept of renderings. Most times, that’s enough, but this time it wasn’t and even if this might sound obvious, rendering components is not the only thing React does for us, it’s just the most common one.
Furthermore, this value synchronization on controlled components is entirely independent and falls outside the rendering cycle and we might also speculate that this is why they came up with a specific name for this particular type of component.
Let’s dig into React’s source code
To present a reliable source for the presented info, let’s dive a little bit into React’s codebase. We’ll focus on fibers management during re-renderings and reconciliation of input elements in the DOM.
Event listener assignment - addTrappedEventListener
Let’s start by saying that React doesn’t attach an event handler for every single element. It has a series of global listeners (it’s faster this way).
It all starts with the listenToAllSupportedEvents
:
They keep track of the supported events with a Set
here. Then the call stack is the following: listenToNativeEvent
calls the addTrappedEventListener
that calls an event listener factory called createEventListenerWrapperWithPriority
. Besides that, it also sets this listener on the root node of our React app through these functions.
onChange execution - dispatchEvent
Attached to the input
HTML event, as well as others, there is the dispatchEvent
function. This triggers a wide range of functions up until the onChange handler we define. The following call stack is verifiable by setting a debugger at the beginning of the handler:
We could dive in as much as we want, but nothing important to mention here in relation to what we talked about.
The dispatchEvent
function can be found here on GitHub, in the ReactDOMEventListener file.
Fibers and DOM committing - commitMutationEffectsOnFiber
and commitUpdate
Applying a breakpoint on attribute modification on the input element, we’re left with the following call stack that represents a timeline that begins after the execution of the onChange handler and ends when the value edit is applied to the DOM.
We can roughly split the work into Fiber work and real DOM work: what is one and what is the other should be clear enough from the methods’ names.
I’ve delved this much just to find the exact spot where the DOM would get mutated, which is here, from the commitUpdate
function through the updateWrapper
(be aware, I’m going to cite a function called updateInput
because the updateWrapper
has been very recently split in this commit):
Unraveling the solution
I don’t want to showcase a solution for the second problem. There are many strategies that can be put in place, each one specific to each case and some solutions will be easier than others. You might want to debounce the input for example, or you can just make an HTTP call within a useEffect instead of doing it in the change handler, etc., etc.
But now that we know a little bit better how change handlers are managed by React, we can understand properly the process that goes into building a working solution that keeps the cursor in the right position while transforming the value.
We’ll use the setSelectionRange
method to programmatically move the cursor.
If we were to do something like this:
const inputRef = useRef();
const [val, setVal] = useState("HELLO");
const onChange = (e) => {
const start = e.target.selectionStart;
const end = e.target.selectionEnd;
setVal(e.currentTarget.value.toUpperCase());
inputRef.current.setSelectionRange(start, end);
};
return <input ref={inputRef} onChange={onChange} />;
It wouldn’t work. That’s because the code we write in the onChange handler gets executed prior to the React’s commit phase which is where it will enforce the value leaving the space for the browser to move the cursor after we moved it ourselves.
How do we escape from this?
We’re looking for a spot to execute our code right after React commits the changes to the DOM (if you think about it, this is the spot we just observed in our second faulty pattern).
To manage the order of execution, we could queue a new microtask containing the setSelectionRange call, with: queueMicrotask
, Promise.resolve
, or postMessage
, or we could queue a (macro)task with setTimeout(..., 0)
.
With these, we’re asking JavaScript to execute that logic after the call stack filled with React’s tasks gets emptied.
But I don’t think this fix can be considered solid in the wild. Something could get in between the various orders of execution leaving us with a misplaced cursor once again. In fact, I’ve noticed that if you type really fast, from time to time, the cursor shifts to the end, indicating that my fears are indeed valid.
But most importantly, it’s definitely not the React way. If we want to execute logic when something changes (like the input’s value) we have to use a useEffect
. Even better a useLayoutEffect
in this case so that before repainting the screen, the cursor is where it’s supposed to be already:
The final solution
There is only one thing I’m not very happy about, which is the need to define the “value” dependency only to retrigger the logic at each change.
The flushSync method
If you go back to the stack trace screenshot I’ve pasted above, you will actually find a method called flushSync
. You can also find it documented in the new docs. Check this example:
All it does is urge React to flush ASAP (e.g. apply all the modifications to the DOM) all the state changes and other activities we’re asking it to perform, so to maintain a synchronous order of execution between state changes, for example, and the code you wrote that assumes those changes are already reflected in the DOM.
I’d like to warn you though that since it comes from react-dom, it won’t be directly available in React Native unless they add something like this there too (if it’s actually needed) and most importantly the react documentation states that:
Using
flushSync
is uncommon and can hurt the performance of your app.
So, your apps, your choices 😀
TLDR
- When an HTML form element gets assigned a new value (whatever the framework you’re using), if it’s different than the one the browser recorded from the user, it will move the cursor to the end.
- We’ve identified two problematic patterns in the wild that cause this in React: setting a transformed value (such as
e.target.value.toUpperCase()
) or setting a value after awaiting a promise. We focused on the first one. - Before you transform the value, the native input element already acquired its original version and fires an event containing the correct cursor position that you can store somewhere and reapply to the input.
- Even knowing this, due to the way React is designed, it’s not enough to just call setSelectionRange inside the onChange handler. But you need to use an external
useEffect
(or betteruseLayoutEffect
) or queue tasks to be able to apply the correct cursor position after the state has been set and after React synced the transformed value with the DOM element.
Bonus
Here you can find some sparse info I couldn’t fit properly in the post:
- There is a completely different cursor jump which is more of a reset happening where the caret positions itself at the beginning of the input due to the
key
prop in React. I see how this post’s title can be misleading but that, to me at least, was a well-known and understood React’s behavior and I didn’t mention it. The documentation also speaks about it very briefly at the end of this paragraph. - Event objects appear to be mutated during the course of browser’s internal work. If you print the cursor position before and after assigning a new value to the input you get two different values.
- With the help of some fellows, I reproduced the problem in VueJS and then I found out on a StackOverflow post that they provide a primitive
Vue.nextTick()
that does conceptually the same thing thatsetTimeout 0
does.
What gets me curious is the process of deciding whether to provide such a primitive as a surface API or not. But surely looks like a more structured system than React even though hooks apparently always provide a solid enough solution. - Svelte is playing the game on a totally different level explaining to the devs about its
tick
function here demonstrating exactly what I’ve talked about (sadly I’ve discovered it too late, what a great job). - Preact doesn’t really have the concept of controlled components. If you try to reproduce the “async issue” I’ve illustrated, the library doesn’t enforce the value prop to the DOM element.
- These patterns and misconceptions are heavily spread, just take a look:
https://github.com/CaioQuirinoMedeiros/react-native-mask-input/issues/23
https://github.com/facebook/react/issues/6483
https://stackoverflow.com/questions/28922275/in-reactjs-why-does-setstate-behave-differently-when-called-synchronously/28922465#28922465
https://github.com/facebook/react/issues/955
https://github.com/facebook/react/issues/5386 - To me, the culprit is in the DOM. In other environments, such as React Native running in iOS and Android, this thing doesn’t happen. You can actually edit the received value however you want and the cursor will be positioned just fine (it’ll be a good exploration to discover how TextInput gets implemented there, but it seems like they apply diffing algorithms).
The end of it
Thank you so much for reaching the end.
I’ve learned quite a good number of stuff and I had fun (even though all this research kinda obsessed me). Most importantly I gotta thank some friends who helped me revise this post: Gianluca, SalGnt.
I assure you the next one will be lighter for both of us!
Ciao 👋🇮🇹