⚠️ This lesson is retired and might contain outdated information.

Preserve cursor position when filtering out characters from a React input

Gosha Arinich
InstructorGosha Arinich
Share this video with your friends

Social Share Links

Send Tweet
Published 5 years ago
Updated 2 years ago

If you've ever tried to filter out characters from the input in the onChange handler, you've most likely ran into the issue when, if you're inserting something in the middle of the input, the cursor jumps to the end of the input.

This happens because React has no way of knowing where you want the cursor to be!

In this lesson, we will create a filtering function that accepts new text and current cursor position, and which returns the filtered text and the new cursor position. We then use a custom useRunAfterUpdate hook after the set state call to update the cursor position after the component has re-rendered.

Instructor: [00:01] We have a name input that doesn't allow the user to type any numbers or special characters into the box. The way it's implemented is pretty simple. The onChange handler just removes the unwanted characters from event.target.value and assigns a state to that new filtered value.

[00:19] There is just one problem with that. If we put the cursor in the middle of the input and try to insert a number, we can see that the cursor jumps to the very end. That's happening because React doesn't know where to put the cursor.

[00:32] We can fix that. In handleNameChange, let's create a few variables that we'll need. We'll create const input = event.target. We'll create const text = input.value. We'll create const cursor = input.selectionStart.

[00:53] To calculate new name, let's call the filterOut function. Let's give it the text that the user has typed in and the current cursor position. Instead of just expecting new name from that function, let's also expect a new cursor position from it.

[01:12] It's time to define that function. Let's go ahead and create function filterOut. Accepts text and cursor. We'll create const newText = text and const newCursor = cursor. We'll just return those two values.

[01:34] Filtering out text seems pretty easy. We can just do stripText. How do we calculate the new cursor position? To achieve that, we're going to split the text into two parts, before and after the cursor.

[01:47] We'll create a beforeCursor variable. The value will be text sliced from index zero to cursor position. We'll create a const afterCursor, which will equal to text sliced from cursor to the very end of the text.

[02:08] Let's just remove the unwanted characters from both of those parts separately. We'll create a const filteredBeforeCursor, will equal to strip beforeCursor. We'll create a const filteredAfterCursor, which will equal to strip afterCursor.

[02:31] New text will be just a combination of filteredBeforeCursor and filteredAfterCursor. We'll put the cursor where the before part ends. We'll say newCursor = filteredBeforeCursor.length.

[02:48] Let's go back to the handleNameChange function. We are updating the state, but we are not updating the cursor position just yet. Let's go ahead and do that. We'll do input.selectionStart = newCursor. input.selectionEnd = newCursor.

[03:10] If we go to the input now, move the cursor back to the middle of the input, and try to press a number, we still see that the cursor is jumping to the end of the input. This is happening because we are updating cursor position right away, even before React had a chance to give the new value to the input.

[03:27] To fix that, I will paste a custom hook called useRunAfterUpdate. This custom hook will allow us to run an arbitrary function after a React component has rendered using the useLayoutEffect hook.

[03:41] I will use this hook in our component. I'll do const runAfterUpdate = useRunAfterUpdate. We'll go ahead and wrap our cursor updates in logic until a runAfterUpdate call. Give it a function that updates the cursor. Now go to the middle of the input and try to press a number. We see that the cursor is where it should be and that it hasn't moved to the end of the input.

~ 5 years ago

This was an interesting insight into the technique. Could you kindly provide more insight into how the custom hook is used and functions (cold be another video) I only understand it at a rudimentary level.

As an experiment I've recreated this as a class component and wanted to check if this implementation is correct/best practice.


Gosha Arinich
Gosha Arinichinstructor
~ 5 years ago

This was an interesting insight into the technique. Could you kindly provide more insight into how the custom hook is used and functions (cold be another video) I only understand it at a rudimentary level.

I'll probably work on a video about that sometime, but roughly, useRunAfterUpdate allows us to schedule an arbitrary function that will be run on component update. Your example is somewhat close, but there's a few issues: a) you don't move the cursor once, you're moving it on every update; b) your componentDidMount actually holds the code to move the cursor

A simple alternative to the useRunAfterUpdate hook in a class component would be just using the second argument to the this.setState function (it's an optional argument which accepts a callback that you want to be called after the state updates the component): this.setState({ value: cleanValue }, () => { input.selectionStart = cursor; input.selectionEnd = cursor })

~ 5 years ago

Hi Gosha, thanks for the feedback,

I've implemented a variation of the class component as per your recommendation as well as the example as you've detailed it in the lesson. You are correct, the cursor position is set on every subsequent render/update and that the functional component using hooks do not have this problem (see console logs). But adding a trace to your example on codesandbox

 runAfterUpdate(() => {
      console.log('set cursor')
      input.selectionStart = newCursor;
      input.selectionEnd = newCursor;

reveals that it updates on every input not only when the state is changed.

I also have unexpected behavior in my implementations. The cursor position is not maintained by the runAfterUpdate hook. I think this is because the useState update function (setName) performs a shallow comparison using Object.is and so will not trigger the update hook while the DOM element still receives input event and is updated outside reacts control (I hope that makes sense).


Markdown supported.
Become a member to join the discussionEnroll Today