28

I have an input field on my react component that shows the line price for an item (two decimal places with thousands separators). I want the value shown to be in money format when the component first renders and also to be kept in money format as user types in the field.

At the moment I have the following code in my component:

var React = require('react');
import accounting from 'accounting';
    
MoneyInput = React.createClass({
    propTypes: {
        name: React.PropTypes.string.isRequired,
        onChange: React.PropTypes.func.isRequired,
        value: React.PropTypes.number,
        error: React.PropTypes.string,
    },
    
    onChange(event) {
        // get rid of any money formatting
        event.target.value = accounting.unformat(event.target.value);
    
        // pass the value on
        this.props.onChange(event);
    },
    
    getValue() {
        return accounting.formatNumber(this.props.value, 2)
    },
    
    render() {
    
        return (
                <div className="field">
                    <input type="text"
                           name={this.props.name}
                           className="form-control"
                           value={this.getValue()}
                           onChange={this.onChange} />
                    <div className="input">{this.props.error}</div>
                </div>
        );
    }
});

module.exports = MoneyInput;

That code displays the data correctly formatted, but every time I enter a value the cursor/caret jumps to the end of the number.

I understand why that's happening (I think) and I've read several questions here related to not losing cursor position in JavaScript (here and here for example).

My question is what's the best way to deal with this in React?

I think that ideally I wouldn't want to store the cursor position in state (e.g. I would want these to be Presentation Components in Dan Abramov syntax) so is there another way?

tomRedox
  • 28,092
  • 24
  • 117
  • 154

5 Answers5

55

An easy solution for losing cursor/caret position in the React's <input /> field that's being formatted is to manage the position yourself:

    onChange(event) {

      const caret = event.target.selectionStart
      const element = event.target
      window.requestAnimationFrame(() => {
        element.selectionStart = caret
        element.selectionEnd = caret
      })

      // your code
    }

The reason your cursor position resets is because React does not know what kinds of changes you are performing (what if you are changing the text completely to something shorter or longer?) and you are now responsible for controlling the caret position.

Example: On one of my input textfields I auto-replace the three dots (...) with an ellipsis. The former is three-characters-long string, while the latter is just one. Although React would know what the end result would look like, it would not know where to put the cursor anymore as there no one definite logical answer.

tomRedox
  • 28,092
  • 24
  • 117
  • 154
dmitrizzle
  • 774
  • 6
  • 15
  • 4
    Wow that is such an simple solution. Good job it works like magic! – hytromo May 07 '18 at 22:19
  • 2
    This is a great tip! I found there is one edge case it doesn't handle--if the user types enough characters that trigger a change quickly enough, the cursor can still jump to the end. I suspect it's because React is calling the onChange handler more than once between animation frames, and the second call sees the end-of-text state left by the first, then schedules an animation frame with that value. i have to really bang on the keys to trigger this, so I don't plan to try to fix it. – Rusty Brown Nail Oct 08 '18 at 02:46
  • 1
    Super easy solution. Thanks a lot. – Alberto Perez Jun 15 '20 at 13:32
1
onKeyUp(ev) {
  const cardNumber = "8318 3712 31"
  const selectionStart = ev.target.selectionStart; // save the cursor position before cursor jump
  this.setState({ cardNumber, }, () => {
    ev.target.setSelectionRange(selectionStart, selectionStart); // set the cursor position on setState callback handler
  });
}
Let Me Tink About It
  • 15,156
  • 21
  • 98
  • 207
Khyati
  • 233
  • 1
  • 5
  • This gives me `TypeError: ev.target is null` for the part after `setState` – Giraldi Feb 14 '19 at 09:52
  • Got it to work by adding `const targetElement = ev.target` and changing the `setSelectionRange` part to `targetElement.setSelectionRange(selectionStart, selectionStart);` – Giraldi Feb 14 '19 at 10:54
0
    set value(val) { // Set by external method
        if(val != this.state.value) {
            this.setState({value: val, randKey: this.getKey()});
        }
    }

    getKey() {
        return 'input-key' + parseInt(Math.random() * 100000);
    }

    onBlur(e) {
        this.state.value = e.target.value; // Set by user
        if(this.props.blur) this.props.blur(e);
    }

    render() {
        return(
            <input className="te-input" type="text" defaultValue={this.state.value} onBlur={this.onBlur} key={this.state.randKey} />
        )
    }

If you need an Input that's both editable without cursor move and may be set from outside as well you should use default value and render the input with different key when the value is changed externally.

tomRedox
  • 28,092
  • 24
  • 117
  • 154
yairniz
  • 408
  • 5
  • 7
0

I think we can do this at a DOM level. What I did was provided id to the input field. There is a property selectionEnd in the input element.

What you can do is just get the input element in the normalize function and get its selectionEnd property

    const selectionEnd=inputElm &&inputElem.selectionEnd?inputElm.selectionEnd:0; 

And since the problem is only while we press the back button. We add a condition as follows

    if(result.length<previousValue.length){
       inputElm.setSelectionRange(selectionEnd, selectionEnd)
    }

But since this value will be set after we return from the function and the returned value will again be set pushing the cursor to the end, we return just add a settimeout.

    setTimeout(() => {   
        if(result.length<previousValue.length){
            inputElm.setSelectionRange(selectionEnd, selectionEnd)
        }
    }, 50);

I just faced this problem today and seems like a timeout of 50 is sufficient.

And if you want to handle the case of user adding the data in the middle. The following code seems to be working good.

    if(result.length<previousValue.length){
         inputElm.setSelectionRange(selectionEnd, selectionEnd)
    } else if(selectionEnd!==result.length){ // result being the computed value
         inputElm.setSelectionRange(selectionEnd, selectionEnd)
    }
tomRedox
  • 28,092
  • 24
  • 117
  • 154
Sagar Acharya
  • 1,763
  • 2
  • 19
  • 38
0

I have the same problem and for me it is because I am using Redux. This article really explained it well.

https://medium.com/@alutom/in-order-to-understand-what-is-really-happening-it-might-be-helpful-to-artificially-increase-the-e64ce17b70a6

The browser is what manages the input curser and when it detects a new text it automatically kicks the curser to the end, my guess is that the state updates the textfield in a way the triggers that browser behaviour.

Mohammed
  • 2,215
  • 1
  • 9
  • 8