React controlled input cursor jumps

71.2k views Asked by At

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?

14

There are 14 answers

0
Dave Welling On

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.

6
Daniel Loiterton On

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;
0
Navaneeth Kz On

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.

0
AudioBubble On

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;

0
WolverinDEV On

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}
        />
    )
});
0
Nathan Foster On
// 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>
  )
}
0
Joe Gasewicz On

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.

0
Tom Ah On

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.

2
Narek Ghazaryan On

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} />;
};
0
Kaj Risberg On

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"));
0
deadcoder0904 On

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} />
}
0
Yurii Brusentsov On

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);
  });
}
0
johncs On

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} />;
}
5
Adel Helal On

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;
    }
  }
/>