React controlled input cursor jumps
Asked Answered
Q

14

47

I am using React and have formatted a controlled input field, which works fine when I write some numbers and click outside the input field. However, when I want to edit the input, the cursor jumps to the front of the value in the input field. This only occur in IE, and not in e.g. Chrome. I've seen that for some programmers the cursor jumps to the back of the value. So I think the reason that my cursor is jumping to the front is because the value is aligned to the right instead of to the left in the input field. Here is a senario:

My first input is 1000 Then I want to edit it to 10003, but the result is 31000

Is there a way to controll that the cursor should not jump?

Quadripartite answered 1/9, 2017 at 12:43 Comment(0)
M
37

Here's a drop-in replacement for the <input/> tag. It's a simple functional component that uses hooks to preserve and restore the cursor position:

import React, { useEffect, useRef, useState } from 'react';

const ControlledInput = (props) => {
   const { value, onChange, ...rest } = props;
   const [cursor, setCursor] = useState(null);
   const ref = useRef(null);

   useEffect(() => {
      const input = ref.current;
      if (input) input.setSelectionRange(cursor, cursor);
   }, [ref, cursor, value]);

   const handleChange = (e) => {
      setCursor(e.target.selectionStart);
      onChange && onChange(e);
   };

   return <input ref={ref} value={value} onChange={handleChange} {...rest} />;
};

export default ControlledInput;

...or with TypeScript if you prefer:

import React, { useEffect, useRef, useState } from 'react';

type InputProps = React.ComponentProps<'input'>;
const ControlledInput: React.FC<InputProps> = (props) => {
   const { value, onChange, ...rest } = props;
   const [cursor, setCursor] = useState<number | null>(null);
   const ref = useRef<HTMLInputElement>(null);

   useEffect(() => {
      ref.current?.setSelectionRange(cursor, cursor);
   }, [ref, cursor, value]);

   const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
      setCursor(e.target.selectionStart);
      onChange?.(e);
   };

   return <input ref={ref} value={value} onChange={handleChange} {...rest} />;
};

export default ControlledInput;
Malvoisie answered 25/8, 2021 at 18:39 Comment(6)
this is beautiful, you're a legend. i tried other answers here but not only did this actually work, and work WELL (no cursor jumping then moving back, very smooth UX) but its so easy to use! πŸ™ – Kasten
Note that input.setSelecetionRange() does not work with type="number" – if that's what you want, I found I had to type="text" inputMode="numeric". – Esther
thank you, this worked for me too. i was using textarea with typescript so i had to change the code a bit :) – Hackett
Thanks for the code. The only change I would recommend is changing the useEffect hook with useLayoutEffect hook, otherwise the user might see the cursor jumping from the end to the selectionStart position. I have tried explaining a different approach here – Elegant
holy moly... you are coding god... THANKS!! – Nanceynanchang
While this solution does solve the problem of the cursor position, it is still not working if you type accents with dead keys, like usually typing ^e produces ê, here it will produce ^e. I found no solution to solve this issue beside taking another framework (svelte handles this fine). More details here #77418796 – Dichasium
O
20

Taking a guess by your question, your code most likely looks similar to this:

<input
  autoFocus="autofocus"
  type="text"
  value={this.state.value}
  onChange={(e) => this.setState({value: e.target.value})}
/>

This may vary in behaviour if your event is handled with onBlur but essentially its the same issue. The behaviour here, which many have stated as a React "bug", is actually expected behaviour.

Your input control's value is not an initial value of the control when its loaded, but rather an underlying value bound to this.state. And when the state changes the control is re-rendered by React.

Essentially this means that the control is recreated by React and populated by the state's value. The problem is that it has no way of knowing what the cursor position was before it was recreated.

One way of solving this which I found to work is remembering the cursor position before it was re-rendered as follows:

<input
  autoFocus="autofocus"
  type="text"
  value={ this.state.value }
  onChange={(e) => {
      this.cursor = e.target.selectionStart;
      this.setState({value: e.target.value});
    }
  }
  onFocus={(e) => {
      e.target.selectionStart = this.cursor;
    }
  }
/>
Outbreed answered 4/2, 2018 at 12:57 Comment(5)
How to do it with hooks? – Harewood
@JuliodeLeon - use useState for the cursorPos. But this is hokey since there are some edge cases to handle around deleted and pasted inputs. – Firm
Specifically for hooks you can do the following (based on @abhinav-prabhakar suggestion): const inputRef = useRef(null); useEffect(() => { inputRef.current.focus(); }, [inputRef]); and then <input ref={inputRef} /> I just added it here so as not to convolute the answer and make it more confusing out of context of the question. – Outbreed
This is not entirely correct. Simply controlling the input with <input value={...} onChange={...} /> will not cause the cursor to jump. If your value is controlled by state, React will maintain the input's cursor position. The problem is when the input receives a change event and the value does not immediately change. In my case, it was because the input value was changed asynchronously through the chrome storage API (it's an extension). It looks instantaneous to the user, but it's async under the hood, and so React doesn't know that the value will be different. – Banc
@MichaelYaworski Good catch. @Dave Welling has a good solution here too, but sometimes a bit of a pain. In my case, even though my onChange func was async, I was able to move the setValue part to the top, before any async stuff, and React was able preserve the cursor. (I'm using a Jotai atom though, not useState, not sure if that would make a difference. And also this wouldn't work if you need the async stuff to determine the value.) – Ralphralston
C
11

I know the OP is 5 years old but some are still facing the same kind of issue and this page has an high visibility on Google search. Try by replacing :

<input value={...}

with

<input defaultValue={...}

This will solve most of the cases i've seen around there.

Cusec answered 21/2, 2023 at 17:40 Comment(0)
A
7

This is my solution:

import React, { Component } from "react";

class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      name: ""
    };

    //get reference for input
    this.nameRef = React.createRef();

    //Setup cursor position for input
    this.cursor;
  }

  componentDidUpdate() {
    this._setCursorPositions();
  }

  _setCursorPositions = () => {
    //reset the cursor position for input
    this.nameRef.current.selectionStart = this.cursor;
    this.nameRef.current.selectionEnd = this.cursor;
  };

  handleInputChange = (key, val) => {
    this.setState({
      [key]: val
    });
  };

  render() {
    return (
      <div className="content">
        <div className="form-group col-md-3">
          <label htmlFor="name">Name</label>
          <input
            ref={this.nameRef}
            type="text"
            autoComplete="off"
            className="form-control"
            id="name"
            value={this.state.name}
            onChange={event => {
              this.cursor = event.target.selectionStart;
              this.handleInputChange("name", event.currentTarget.value);
            }}
          />
        </div>
      </div>
    );
  }
}

export default App;

Aplomb answered 3/9, 2019 at 14:10 Comment(0)
M
4

This is an easy solution. Worked for me.

<Input
ref={input=>input && (input.input.selectionStart=input.input.selectionEnd=this.cursor)}
value={this.state.inputtext} 
onChange={(e)=>{
this.cursor = e.target.selectionStart;
this.setState({inputtext: e.target.value})
/>

Explanation:

What we are doing here is we save the cursor position in onChange(), now when the tag re-renders due to a change in the state value, the ref code is executed, and inside the ref code we restore out cursor position.

Marpet answered 23/2, 2021 at 9:44 Comment(0)
D
4

As (I think) others have mentioned, React will keep track of this if you make your changes synchronously. Unfortunately, that's not always feasible. The other solutions suggest tracking the cursor position independently, but this will not work for input types like 'email' which will not allow you to use cursor properties/methods like selectionStart, setSelectionRange or whatever. Instead, I did something like this:

const Input = (props) => {
    const { onChange: _onChange, value } = props;

    const [localValue, setLocalValue] = useState(value);

    const onChange = useCallback(
        e => {
            setLocalValue(e.target.value);
            _onChange(e.target.value);
        },
        [_onChange]
    );
    useEffect(() => {
        setLocalValue(value);
    }, [value]);

    // Use JSX here if you prefer
    return react.createElement('input', {
        ...props,
        value: localValue,
        onChange
    });
};

This allows you to delegate the cursor positioning back to React, but make your async changes.

Dittmer answered 16/5, 2022 at 17:24 Comment(0)
H
2

If you're using textarea, then here's the hook based on Daniel Loiterton's code using TypeScript:

interface IControlledTextArea {
    value: string
    onChange: ChangeEventHandler<HTMLTextAreaElement> | undefined
    [x: string]: any
}

const ControlledTextArea = ({ value, onChange, ...rest }: IControlledTextArea) => {
    const [cursor, setCursor] = useState(0)
    const ref = useRef(null)

    useEffect(() => {
        const input: any = ref.current
        if (input) {
            input.setSelectionRange(cursor, cursor)
        }
    }, [ref, cursor, value])

    const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
        setCursor(e.target.selectionStart)
        onChange && onChange(e)
    }

    return <textarea ref={ref} value={value} onChange={handleChange} {...rest} />
}
Hackett answered 1/1, 2022 at 13:32 Comment(0)
K
2

If you faced an issue with the cursor jumping to the end after updating the input state and updating the cursor using refs -> I found a workaround for it by setting the cursor in Promise.resolve's microtask.

<input
  value={value}
  onChange={handleValueUpdate}
  ref={inputRef}
/>
const handleValueUpdate = (e: React.ChangeEvent<HTMLInputElement>) => {
  e.preventDefault();

  // ...
  // some value handling logic
  setValue(newValue)
  const cursorPosition = getCursorPositionLogic();

  /**
   * HACK: set the cursor on the next tick to make sure that the value is updated
   * useTimeout with 0ms provides issues when two keys are pressed same time
   */
  Promise.resolve().then(() => {
    inputRef.current?.setSelectionRange(cursorPosition, cursorPosition);
  });
}
Kelso answered 18/10, 2022 at 22:54 Comment(0)
T
0

My cursor jumped always to the end of the line. This solution seems to fix the problem (from github):

import * as React from "react";
import * as ReactDOM from "react-dom";

class App extends React.Component<{}, { text: string }> {
  private textarea: React.RefObject<HTMLTextAreaElement>;
  constructor(props) {
    super(props);
    this.state = { text: "" };
    this.textarea = React.createRef();
  }

  handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
    const cursor = e.target.selectionStart;
    this.setState({ text: e.target.value }, () => {
      if (this.textarea.current != null)
        this.textarea.current.selectionEnd = cursor;
    });
  }

  render() {
    return (
      <textarea
        ref={this.textarea}
        value={this.state.text}
        onChange={this.handleChange.bind(this)}
      />
    );
  }
}

ReactDOM.render(<App />, document.getElementById("root"));
Trachyte answered 21/2, 2019 at 16:25 Comment(0)
O
0

Here is my solution

const Input = () => {
    const [val, setVal] = useState('');
    const inputEl = useRef(null);

    const handleInputChange = e => {
      const { value, selectionEnd } = e.target;
      const rightCharsCount = value.length - selectionEnd;
      const formattedValue = parseInt(value.replace(/\D/g, ''), 10).toLocaleString();
      const newPosition = formattedValue.length - rightCharsCount;

      setVal(formattedValue);

      setTimeout(() => {
        inputEl.current.setSelectionRange(newPosition, newPosition);
      }, 0);
    };

    return <input ref={inputEl} value={val} onChange={handleInputChange} />;
};
Onfroi answered 19/7, 2019 at 15:10 Comment(2)
Been banging my head for hours and nothing has worked for me. @Narek Ghazaryan's solution works!!! – Dunston
only weird thing is that it worked in my test app I put together to just fix this issue but when I add the fix to the main app, my inputEl.current is always null. Only difference between the two codes is that one has a useEffect and other doesn't One that does not have useEffect is working hmmm – Dunston
H
0
// Here is a custom hook to overcome this problem:

import { useRef, useCallback, useLayoutEffect } from 'react'
/**
 * This hook overcomes this issue {@link https://github.com/reduxjs/react-redux/issues/525}
 * This is not an ideal solution. We need to figure out why the places where this hook is being used
 * the controlled InputText fields are losing their cursor position when being remounted to the DOM
 * @param {Function} callback - the onChangeCallback for the inputRef
 * @returns {Function} - the newCallback that fixes the cursor position from being reset
 */
const useControlledInputOnChangeCursorFix = callback => {
  const inputCursor = useRef(0)
  const inputRef = useRef(null)

  const newCallback = useCallback(
    e => {
      inputCursor.current = e.target.selectionStart
      if (e.target.type === 'text') {
        inputRef.current = e.target
      }
      callback(e)
    },
    [callback],
  )

  useLayoutEffect(() => {
    if (inputRef.current) {
      inputRef.current.setSelectionRange(inputCursor.current, inputCursor.current)
    }
  })

  return newCallback
}

export default useControlledInputOnChangeCursorFix

// Usage:

import React, { useReducer, useCallback } from 'react'
import useControlledInputOnChangeCursorFix from '../path/to/hookFolder/useControlledInputOnChangeCursorFix'

// Mimics this.setState for a class Component
const setStateReducer = (state, action) => ({ ...state, ...action })

const initialState = { street: '', address: '' }

const SomeComponent = props => {
  const [state, setState] = useReducer(setStateReducer, initialState)

  const handleOnChange = useControlledInputOnChangeCursorFix(
    useCallback(({ target: { name, value } }) => {
      setState({ [name]: value })
    }, []),
  )

  const { street, address } = state

  return (
    <form>
      <input name='street' value={street} onChange={handleOnChange} />
      <input name='address' value={address} onChange={handleOnChange} />
    </form>
  )
}
Handgrip answered 23/3, 2021 at 0:27 Comment(0)
E
0

For anybody having this issue in react-native-web here is solution written in TypeScript

const CursorFixTextInput = React.forwardRef((props: TextInputProps, refInput: ForwardedRef<TextInput>) => {
    if(typeof refInput === "function") {
        console.warn("CursorFixTextInput needs a MutableRefObject as reference to work!");
        return <TextInput key={"invalid-ref"} {...props} />;
    }

    if(!("HTMLInputElement" in self)) {
        return <TextInput key={"no-web"} {...props} />;
    }

    const { value, onChange, ...restProps } = props;
    const defaultRefObject = useRef<TextInput>(null);
    const refObject: RefObject<TextInput> = refInput || defaultRefObject;
    const [ selection, setSelection ] = useState<SelectionState>(kInitialSelectionState);

    useEffect(() => {
        if(refObject.current instanceof HTMLInputElement) {
            refObject.current.setSelectionRange(selection.start, selection.end);
        }
    }, [ refObject, selection, value ]);

    return (
        <TextInput
            ref={refObject}
            value={value}
            onChange={event => {
                const eventTarget = event.target as any;
                if(eventTarget instanceof HTMLInputElement) {
                    setSelection({
                        start: eventTarget.selectionStart,
                        end: eventTarget.selectionEnd
                    });
                }

                if(onChange) {
                    onChange(event);
                }
            }}
            {...restProps}
        />
    )
});
Endless answered 2/12, 2021 at 1:38 Comment(0)
A
0

The simplest and safest way of doing this is probably to save the cursor position before React renders the input and then setting it again after React finishes rendering.

import React, {ReactElement, useEffect, useRef} from "react";

/**
 * Text input that preserves cursor position during rendering.
 *
 * This will not preserve a selection.
 */
function TextInputWithStableCursor(
    props: React.InputHTMLAttributes<HTMLInputElement> & {type?: "text"}
): ReactElement {
    const inputRef = useRef<HTMLInputElement>(null);

    // Save the cursor position before rendering
    const cursorPosition = inputRef.current?.selectionStart;

    // Set it to the same value after rendering
    useEffect(function () {
        if (
            typeof cursorPosition === "number" &&
            document.activeElement === inputRef.current
        ) {
            inputRef.current?.setSelectionRange(cursorPosition, cursorPosition);
        }
    });

    return <input ref={inputRef} {...props} />;
}
Almost answered 23/9, 2022 at 6:3 Comment(0)
B
-1

I tried all of the above solutions and none of them worked for me. Instead I updated both the e.currentTarget.selectionStart & e.currentTarget.selectionEnd on the onKeyUp React synthetic event type. For example:

const [cursorState, updateCursorState] = useState({});
const [formState, updateFormState] = useState({ "email": "" });

const handleOnChange = (e) => {
    // Update your state & cursor state in your onChange handler
    updateCursorState(e.target.selectionStart);
    updateFormState(e.target.value);
}

<input
    name="email"
    value={formState.email}
    onChange={(e) => handleOnChange(e)}
    onKeyUp={(e) => {
        // You only need to update your select position in the onKeyUp handler:
        e.currentTarget.selectionStart = cursorState.cursorPosition;
        e.currentTarget.selectionEnd = cursorState.cursorPosition;
    }}
/>

Also, be aware that selectionStart & selectionEnd getters are not available on input fields of type email.

Bertolde answered 6/4, 2021 at 17:42 Comment(0)

© 2022 - 2024 β€” McMap. All rights reserved.