As mentioned in the comments, jQuery is the wrong tool for the job. Bringing in jQuery is the same as calling DOM methods directly: it's circumventing React and adding additional listeners on top of the ones React already gives you. You can expect misbehavior if you're setting state from multiple handlers unrelated to React. Once you're in React, use the tools it gives you (refs, effects, handlers) to solve the problem.
Worst case scenario is when an approach appears to work, then fails in production, on other people's machines/browsers, when refactoring from classes to hooks or vice-versa, in different versions of React, or for 1 out of 1000 users. Staying well within the API React gives you better guarantees that your app will behave correctly.
For manipulating the input value, you can use both onKeyDown
and onChange
listeners. onKeyDown
fires first. Calling event.preventDefault()
inside of onKeyDown
will block the change event and ensure only one call to setState
for the controlled input value occurs per keystroke.
The problem with this the input cursor moves to the end after the component updates (see relevant GitHub issue). One way to deal with this is to manually adjust the cursor position when you've made an invasive change to the string by adding state to keep track of the cursor and using a ref and useEffect
to set selectionStart
and selectionEnd
properties on the input element.
This causes a brief blinking effect due to asynchrony after the render, so this isn't a great solution. If you're always appending to the end of the value, you assume the user won't move the cursor as other answers do, or you want the cursor to finish at the end, then this is a non-issue, but this assumption doesn't hold in the general case.
One solution is to use useLayoutEffect
which runs synchronously before the repaint, eliminating the blink.
With useEffect
:
const {useEffect, useRef, useState} = React;
const App = () => {
const [value, setValue] = useState("");
const [cursor, setCursor] = useState(-1);
const inputRef = useRef();
const pad = ". ";
const onKeyDown = event => {
if (event.code === "Period") {
event.preventDefault();
const {selectionStart: start} = event.target;
const {selectionEnd: end} = event.target;
const v = value.slice(0, start) + pad + value.slice(end);
setValue(v);
setCursor(start + pad.length);
}
};
const onChange = event => {
setValue(event.target.value);
setCursor(-1);
};
useEffect(() => {
if (cursor >= 0) {
inputRef.current.selectionStart = cursor;
inputRef.current.selectionEnd = cursor;
}
}, [cursor]);
return (
<div>
<p>press `.` to add 4 spaces:</p>
<input
ref={inputRef}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
/>
</div>
);
};
ReactDOM.createRoot(document.querySelector("#app"))
.render(<App />);
input {
width: 100%;
}
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<div id="app"></div>
With useLayoutEffect
:
const {useLayoutEffect, useRef, useState} = React;
const App = () => {
const [value, setValue] = useState("");
const [cursor, setCursor] = useState(-1);
const inputRef = useRef();
const pad = ". ";
const onKeyDown = event => {
if (event.code === "Period") {
event.preventDefault();
const {selectionStart: start} = event.target;
const {selectionEnd: end} = event.target;
const v = value.slice(0, start) + pad + value.slice(end);
setValue(v);
setCursor(start + pad.length);
}
};
const onChange = event => {
setValue(event.target.value);
setCursor(-1);
};
useLayoutEffect(() => {
if (cursor >= 0) {
inputRef.current.selectionStart = cursor;
inputRef.current.selectionEnd = cursor;
}
}, [cursor]);
return (
<div>
<p>press `.` to add 4 spaces:</p>
<input
ref={inputRef}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
/>
</div>
);
};
ReactDOM.createRoot(document.querySelector("#app"))
.render(<App />);
input {
width: 100%;
}
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<div id="app"></div>
Here's another attempt using an uncontrolled component. This doesn't have the blinking problem because the DOM element's .value
property is synchronously set at the same time as the .selectionStart
property and is rendered in the same repaint.
const App = () => {
const pad = ". ";
const onKeyDown = event => {
if (event.code === "Period") {
event.preventDefault();
const {target} = event;
const {
value, selectionStart: start, selectionEnd: end,
} = target;
target.value = value.slice(0, start) +
pad + value.slice(end);
target.selectionStart = start + pad.length;
target.selectionEnd = start + pad.length;
}
};
return (
<div>
<p>press `.` to add 4 spaces:</p>
<input
onKeyDown={onKeyDown}
/>
</div>
);
};
ReactDOM.createRoot(document.querySelector("#app"))
.render(<App />);
input {
width: 100%;
}
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<div id="app"></div>