Modifying user input value in React controlled component - reactjs

I've been stuck on this error for a long time, so I would appreciate some help. Here is a minimally reproducible exmaple:
import "./App.css";
import React, { useState } from "react";
import $ from "jquery";
function App() {
const [value, setValue] = useState("");
const focusHandler = () => {
$("input").on("keypress", (e) => {
let copy = value;
if (e.key === ".") {
e.preventDefault();
copy += " ";
setValue(copy);
}
});
};
const blurHandler = (event) => {
$("input").off("keypress");
setValue(event.target.value);
};
const changeHandler = (event) => {
setValue(event.target.value);
};
return (
<div>
<input
value={value}
onFocus={focusHandler}
onBlur={blurHandler}
onChange={changeHandler}
/>
</div>
);
}
export default App;
On input focus, I'm adding an event listener to look for a . keypress and append a tab (4 spaces) to the input if it is pressed. But when I press . multiple times, the input gets stuck at the first tab, and doesn't move any further (e.g. input permanenetly shows 4 spaces). Using console.log shows me that the value state doesn't seem to be updating in focusHandler and reverts to the original value ("").
An important note is that switching to a class-based component with this.state makes it work. Any insight as to why this is happening?

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.
Controlled component
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>
Uncontrolled component
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>

Don't mix direct DOM manipulation, whether that's vanilla JavaScript or jQuery, with React. There is no need to add an event handler with jQuery here, because your methods are already event handlers. Just use them directly:
const focusHandler = (e) => {
// handle the event here!
}

My solution:
const changeHandler = (event) => {
const key = event.nativeEvent.data;
if (key === ".") {
event.preventDefault();
const initialValue = event.target.value.split(".")[0];
console.log(initialValue);
setValue(initialValue + " ");
} else {
setValue(event.target.value);
}
};

Related

Need to update React component on window resize

I am trying to access the window object inside of a React.js component as I want to create a state which holds the dynamic innerWidth value of the window object. I was able to make it work when the page gets refreshed but not when I resize the page with the dev tools dynamically.
Here is the code that works for me on refresh:
const About = () => {
const [bioType, setBioType] = useState("");
const truncateText = () =>
window.innerWidth > 1024 ? setBioType("desktop") : setBioType("mobile");
useEffect(() => {
truncateText();
});
return ({
bioType === 'desktop' ? ... : ....
})
}
However, when I resize the web page with Dev Tools, it doesn't work. Could someone give me a hint? Thanks.`
Changing the windows width doesn't cause React to react to the change, and re-render. You need to use an event handler to listen to the resize event, use a ResizeObserver or use MatchMedia, and listen to the changes.
Example with MatchMedia:
const { useState, useEffect } = React;
const MIN_WIDTH = 600;
const getBioType = matches => matches ? 'desktop' : 'mobile';
const About = () => {
const [bioType, setBioType] = useState(() => getBioType(window.innerWidth > MIN_WIDTH));
useEffect(() => {
const mql = window.matchMedia(`(min-width: ${MIN_WIDTH}px)`);
const handler = e => setBioType(getBioType(e.matches));
mql.addEventListener('change', handler);
return () => {
mql.removeEventListener('change', handler);
};
}, []);
return bioType;
}
ReactDOM
.createRoot(root)
.render(<About />);
<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="root"></div>

Why is the this input "value" property not refreshed?

I am learning React while doing a new project with it.
I want to create an input component with a event that is only triggered if the user exits the input or hit enter. (here is the link to codesandbox)
const MyInput = ({ value, onValueChange }) => {
const [myCurrentTypingValue, setMyCurrentTypingValue] = useState(value);
useEffect(() => setMyCurrentTypingValue(value), [value]);
const commitChanges = () => {
const numericValue = Number(myCurrentTypingValue);
setMyCurrentTypingValue(numericValue);
if (value !== numericValue) onValueChange(numericValue);
};
const handleOnChange = (e) => setMyCurrentTypingValue(e.target.value);
const handleOnBlur = () => commitChanges();
const handleOnKeyPress = (e) => {
if (e.charCode === 13) commitChanges();
};
return (
<input
type="number"
value={myCurrentTypingValue}
onChange={handleOnChange}
onBlur={handleOnBlur}
onKeyPress={handleOnKeyPress}
/>
);
};
As you can see, when I type something, the input is correctly displayed when I press enter or exit, but when I type a number that starts with 0 (e.g. 000023), although the numerical value is correctly converted, it is still displayed with all zeros in front of it.
Because I change the state of my component, I expect my input box value property to be refreshed, and it is not:
Why is this happening? I checked with the debugger tool, my state is correct?
How can I have the input box reflect the new state with the good numeric formatted value?
When it's time to "commit" your changes, you should stringify the parsed number value and update the input value (which is always a string). Here's a self-contained example:
TS Playground
body { font-family: sans-serif; }
input[type="number"], pre { font-size: 1rem; padding: 0.5rem; }
<div id="root"></div><script src="https://unpkg.com/react#18.1.0/umd/react.development.js"></script><script src="https://unpkg.com/react-dom#18.1.0/umd/react-dom.development.js"></script><script src="https://unpkg.com/#babel/standalone#7.17.10/babel.min.js"></script>
<script type="text/babel" data-type="module" data-presets="env,react">
// import * as ReactDOM from 'react-dom/client';
// import {useEffect, useState} from 'react';
// This Stack Overflow snippet demo uses UMD modules instead of the above import statments
const {useEffect, useState} = React;
// Parse a string as a number, but if the string is not a valid number then return 0
function parseNumber (str) {
const n = Number(str);
return Number.isNaN(n) ? 0 : n;
}
function NumberInput ({value, setValue}) {
const [rawValue, setRawValue] = useState(String(value));
useEffect(() => setRawValue(String(value)), [value]);
const commit = () => setValue(parseNumber(rawValue));
const handleChange = (ev) => setRawValue(ev.target.value);
const handleKeyUp = (ev) => {
if (ev.key === 'Enter') commit();
};
return (
<input
type="number"
onBlur={commit}
onChange={handleChange}
onKeyUp={handleKeyUp}
value={rawValue}
/>
);
}
function App () {
const [value, setValue] = useState(0);
return (
<div>
<h1>Number input management</h1>
<NumberInput {...{value, setValue}} />
<pre><code>{JSON.stringify({value}, null, 2)}</code></pre>
</div>
);
}
const reactRoot = ReactDOM.createRoot(document.getElementById('root'));
reactRoot.render(<App />);
</script>

How to register only once in react useEffect and get useState changes

I have a react hook component which needs to register to an outside event as follows:
const DefaultRateSection: React.FC<{
registerOnSaveEvent: (fn: () => void) => void;
}> = ({registerOnSaveEvent}) => {
const [serviceName, setServiceName] = useState('the name');
const serviceNameInput = useRef<input>(null);
useEffect(
() => {
return registerOnSaveEvent(() => {
if (!serviceName) {
serviceNameInput.current?.focus();
}
});
},
[registerOnSaveEvent]
);
return (
<input
ref={serviceNameInput}
value={serviceName}
onChange={(event) => {
const newValue = event.target.value;
setServiceName(newValue)
}}
/>
);
};
The registerOnSaveEvent is an API that i cannot change and i do not have an unsubscribe method, therefore i need to register to it only once. However, when it fires (from outside the component) i'm receiving the initial value of serviceName and not the updated one. I know it happens because i'm not calling useEffect after the change, but I need to avoid multiple registrations.
How can i achieve this?
TL-TR: The short answer is to use another ref so that the arrow function can access the latest rendered value of service name.
const serviceNameRef = useRef();
serviceNameRef.current = serviceName;
// use serviceNameRef.current in the arrow function
This code will NOT work
// Get a hook function - only needed for this Stack Snippet
const {useState, useRef, useEffect} = React;
const DefaultRateSection = ({ registerOnSaveEvent }) => {
const [serviceName, setServiceName] = useState("the name");
const serviceNameInput = useRef();
useEffect(() => {
return registerOnSaveEvent(() => {
console.log(serviceName)
});
}, [registerOnSaveEvent]);
return (
<input
ref={serviceNameInput}
value={serviceName}
onChange={(event) => {
const newValue = event.target.value;
setServiceName(newValue);
}}
/>
);
};
const App = () => {
const callbackRef = useRef();
function registerOnSaveEvent(callback) {
callbackRef.current=callback
}
function execCallback() {
callbackRef.current();
}
return <div>
<h2>This will not work</h2>
<p>Try to change the input field and click 'RegisterOnSaveEvent'.</p>
<p>The callback will not see the new value of the input</p>
<DefaultRateSection registerOnSaveEvent={registerOnSaveEvent}/>
<button onClick={execCallback}>RegisterOnSaveEvent</button>
</div>;
};
ReactDOM.render(
<App/>,
document.getElementById("react")
);
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js"></script>
<div id="react"></div>
This code will work
// Get a hook function - only needed for this Stack Snippet
const {useState, useRef, useEffect} = React;
const DefaultRateSection = ({ registerOnSaveEvent }) => {
const [serviceName, setServiceName] = useState("the name");
const serviceNameInput = useRef();
// use a ref for the service name and
// update it with the serviceName state on every render
const serviceNameRef = useRef();
serviceNameRef.current = serviceName;
useEffect(() => {
return registerOnSaveEvent(() => {
console.log(serviceNameRef.current)
});
}, [registerOnSaveEvent]);
return (
<input
ref={serviceNameInput}
value={serviceName}
onChange={(event) => {
const newValue = event.target.value;
setServiceName(newValue);
}}
/>
);
};
const App = () => {
const callbackRef = useRef();
function registerOnSaveEvent(callback) {
callbackRef.current=callback
}
function execCallback() {
callbackRef.current();
}
return <div>
<h2>This will work</h2>
<p>Try to change the input field and click 'RegisterOnSaveEvent'.</p>
<p>The callback will not see the new value of the input</p>
<DefaultRateSection registerOnSaveEvent={registerOnSaveEvent}/>
<button onClick={execCallback}>RegisterOnSaveEvent</button>
</div>;
};
ReactDOM.render(
<App/>,
document.getElementById("react")
);
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js"></script>
<div id="react"></div>
Explanation
When the useEffect is executed it creates an arrow function. This arrow function references the const serviceName which is the initial value. This is the value that the arrow function sees. When you enter something in the input field you call the setServiceName which changes the state and triggers a rerender. The rendering itself is nothing but a function call. So when the component is rerendered the useState returns the state and you assign it to a brand new local const named serviceName. This is NOT the same as the one that the arrow function references. Thus the arrow function will always see the value of serviceName when it was created.
To solve this problem I use another ref for the serviceName called serviceNameRef and update that ref with the serviceName state on every rendering. Since useRef returns the same instance of serviceRefName on each call, it is the same instance as the one the arrow function uses. That's how it works.

Custom hook not using useState() properly?

I'm trying to make one of those custom useKeyPress hooks, and I want my keydown handler to fire on every individual button press, i.e. holding down a key won't trigger it a million times. I'm trying to do this by having a flag that indicates whether to call the handler, callHandler - it should only be set to true when the target key is released/the component just mounted. Below is a simplified version of what I'm trying to do:
const useKeyPress = (target)=>{
const [callHandler, setCallHandler] = useState(true);
const keyDown = (e) =>{
if(e.key === target && callHandler){
setCallHandler(false); //This line never seems to work properly???
console.log(callHandler, setCallHandler);
//call the keyDown handler
}
}
const keyUp = (e)=>{
if(e.key === target)
setCallHandler(true);
}
useEffect(()=>{
document.addEventListener('keydown', keyDown);
document.addEventListener('keyup' keyUp);
return ()=>{
document.removeEventListener('keydown', keyDown);
document.removeEventListener('keyup', keyUp);
}
},[]);
}
useKeyPress('a');
My issue is that callHandler is never set to false. That console log immediately after setCallHandler just spams the debug screen with (true, ...) when I hold down the 'a' key, meaning that despite the code block running callHandler is somehow always false. Any ideas as to why this isn't working?
Im not sure if i get it right , anyway i made this code hope helps u a bit.
EDIT
Change it to custom hook example, maybe useful for someone in future.
const useKeyEvent = (target) =>{
const [keyTarget,setKeyTarget] = React.useState(target);
const [playedKey,setPlayedKey] = React.useState(null);
const [keyDone,setKeyDone] = React.useState(false);
const keyUp = (e) => {
setPlayedKey(null);
setKeyDone(false)
};
const keyDown = (e) => {
const key = e.key;
if (key !== playedKey) {
if(key === keyTarget){
setKeyDone(true);
}
setPlayedKey(key);
}
};
React.useEffect(() => {
document.addEventListener("keydown", keyDown);
document.addEventListener("keyup", keyUp);
return () => {
document.removeEventListener("keydown", keyDown);
document.removeEventListener("keyup", keyUp);
};
}, [keyUp, keyDown,keyDone]);
return [playedKey,keyTarget,setKeyTarget,keyDone];
}
const App = () => {
const [playedKey,keyTarget,setKeyTarget,keyDone] = useKeyEvent("a");
return (
<div>
<h3 style={{color:keyDone?"green":"black"}}>Press {keyTarget} key ! Key : {playedKey}</h3>
<input type="text" onChange={(e)=>setKeyTarget(e.currentTarget.value)} maxLength="1" value={keyTarget}/>
</div>
)
}
ReactDOM.render(<App/>, document.getElementById("react"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<div id="react"></div>

React Input onChange before onKeyDown

const {useMemo, useState, createRef} = React;
const InputPIN = (props) => {
const inputRefs = useMemo(() => Array(4).fill(0).map(i=> createRef()), []);
const [values, setValues] = useState(Array(4).fill(''));
function handleChange(e, index){
const typed = e.target.value.toString();
setValues((prev)=>
prev.map((i,jndex) => {
if(index === jndex) return typed[typed.length-1];
else return i;
})
);
if (typed !=='' && inputRefs[index + 1]) inputRefs[index + 1].current.focus();
}
function handleBackspace(e, index) {
if(e.keyCode === 8){
if(inputRefs[index-1]){
inputRefs[index - 1].current.focus();
}
}
}
return (
<label className="InputPIN">
{
new Array(4).fill(0).map((i,index)=>(
<input style={{width:50}} key={index} value={values[index]} onKeyDown={(e)=>handleBackspace(e,index)} type="text" ref={inputRefs[index]} onChange={(e)=>handleChange(e,index)} /> ) )
}
</label>
)
}
const App = () => {
return (
<InputPIN />
)
}
ReactDOM.render(<App />, document.getElementById('root'));
<script crossorigin src="https://unpkg.com/react#16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom#16/umd/react-dom.development.js"></script>
<div id="root" />
I am making an InputPIN component.
When I typed a text, input focusing would be moved to the next input.
and When I typed the backspace, input focusing would be moved to the previous input after removing a present text. But It doesn't work.
on my code, I am using onKeyDown() and onChange().
and I guess onKeyDown() has a higher priority than onChange().
so, I've tried to change onKeyDown() to onKeyUp().
but it has a side effect that I don't want.
EDIT: The whole answer has been updated after the discussion in comments.
I think you can just use a custom handler function, and rewrite a little the two handlers methods:
const manualUpdateInputs = (index) => {
const newValues = [...values];
newValues[index] = '';
inputRefs[index].current.focus();
}
const handleChange = (e, index) => {
const newValues = [...values];
const typed = e.target.value.toString();
newValues[index] = typed;
if (typed !=='' && inputRefs[index + 1]) {
inputRefs[index + 1].current.focus();
}
setValues(newValues);
}
const handleBackspace = (e, index) => {
if(e.keyCode === 8){
if(inputRefs[index-1] && e.target.value === '') {
manualUpdateInputs(index - 1);
}
}
}
I've changed the way you update values but you can keep your way, it doesn't change. What matters is the use of manualUpdateInputs.
The "trick" was that handleBackspace is triggered anytime you click a key in the keyboard, but handleChange was not triggered if you clicked a backspace with an empty <input>.
Tell me if the behavior obtained with this implementation is what you wanted (it makes sense to me, but maybe it's not what you wanted).

Resources