RMT
RMT

Reputation: 1162

React controlled input cursor jumps

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?

Upvotes: 50

Views: 80754

Answers (15)

lovebird
lovebird

Reputation: 19

This solution can address the issue where the input by number type cannot set range, while ensuring that the value uses the value from the props.

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

type InputProps = React.ComponentProps<'input'>;
function Input(props: InputProps) {
  const { value, ...rest } = props;
  const [selfValue, setSelfValue] = useState(props.value);
  const ref = useRef<HTMLInputElement>(null);

  useEffect(() => {
    const input = ref.current;
    if (!input) {
      return;
    }

    if (value !== selfValue) {
      setSelfValue(value);
      input.value = value as string;
    }
  }, [selfValue, value]);

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

Upvotes: 0

Daniel Loiterton
Daniel Loiterton

Reputation: 5318

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;

Upvotes: 42

Tom Ah
Tom Ah

Reputation: 715

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.

Upvotes: 16

Yurii Brusentsov
Yurii Brusentsov

Reputation: 577

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

Upvotes: 2

Adel Helal
Adel Helal

Reputation: 680

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

Upvotes: 20

johncs
johncs

Reputation: 332

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

Upvotes: 0

Dave Welling
Dave Welling

Reputation: 1747

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.

Upvotes: 4

deadcoder0904
deadcoder0904

Reputation: 8693

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

Upvotes: 2

WolverinDEV
WolverinDEV

Reputation: 1524

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

Upvotes: 0

Joe Gasewicz
Joe Gasewicz

Reputation: 1495

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.

Upvotes: -1

Nathan Foster
Nathan Foster

Reputation: 1

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

Upvotes: 0

Navaneeth Kz
Navaneeth Kz

Reputation: 93

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.

Upvotes: 4

user1997592
user1997592

Reputation:

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;

Upvotes: 7

Narek Ghazaryan
Narek Ghazaryan

Reputation: 3143

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

Upvotes: 0

Kaj Risberg
Kaj Risberg

Reputation: 645

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"));

Upvotes: 0

Related Questions