In React, I have a number of buttons (imagine a PIN layout with numbers) that update the state on click. I also added an event listener to the document so pressing keys on the keyboard updates the pin too. However, there's a strange problem. When I add a number by clicking a button, the state is working correctly and everything is fine, but when I press a key on a physical keyboard, the state updates, but logs as <empty string>!
Here is the code:
export default function Keypad() {
const [pin, setPin] = useState("");
function addNumber(num) {
console.log(pin); // returns the correct pin with handleKeyClick, returns <empty string> with handleKeyDown
if (pin.length < 6) { // only works if the pin is not <empty string>
setPin((pin) => [...pin, num.toString()]); // works correctly with both handleKeyClick and handleKeyDown even if pin logged <empty string>!
}
}
function handleKeyClick(num) {
addNumber(num);
}
function handleKeyDown(e) {
if (!isNaN(e.key)) {
addNumber(e.key);
}
}
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, []);
return (
<div>
{/* just one button for example */}
<button onClick={() => handleKeyClick(9)}>9</button>
</div>
)
}
I guess this is because document can't access the pin state, but if it was the case, the setPin shouldn't work either. Am I right?
Your component does not keep a reference when listening to DOM events, this answer has some neat code for listening to window events using a fairly simple hook. When applied to your code, it works as expected:
const {useState, useEffect, useRef} = React;
// Hook
function useEventListener(eventName, handler, element = window){
// Create a ref that stores handler
const savedHandler = useRef();
// Update ref.current value if handler changes.
// This allows our effect below to always get latest handler ...
// ... without us needing to pass it in effect deps array ...
// ... and potentially cause effect to re-run every render.
useEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(
() => {
// Make sure element supports addEventListener
// On
const isSupported = element && element.addEventListener;
if (!isSupported) return;
// Create event listener that calls handler function stored in ref
const eventListener = event => savedHandler.current(event);
// Add event listener
element.addEventListener(eventName, eventListener);
// Remove event listener on cleanup
return () => {
element.removeEventListener(eventName, eventListener);
};
},
[eventName, element] // Re-run if eventName or element changes
);
};
const Keypad = (props) => {
const [pin, setPin] = useState([]);
function addNumber(num) {
console.log(pin); // returns the correct pin with handleKeyClick, returns <empty string> with handleKeyDown
if (pin.length < 6) { // only works if the pin is not <empty string>
setPin((pin) => [...pin, num.toString()]); // works correctly with both handleKeyClick and handleKeyDown even if pin logged <empty string>!
}
}
function handleKeyClick(num) {
addNumber(num);
}
function handleKeyDown(e) {
if (!isNaN(e.key)) {
addNumber(e.key);
}
}
useEventListener("keydown", handleKeyDown)
return (
<div>
{/* just one button for example */}
<button onClick={() => handleKeyClick(9)}>9</button>
</div>
)
return "Hello World"
}
ReactDOM.render(<Keypad />, document.getElementById("root"))
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.development.js"></script>
Related
Across my app, there are some UX logic that needs to be shared. They are triggered by events, so I wrote 2 custom hooks. Let's call one useRefWithCalc. The other hook is a more standard useEventListener, similar to this one.
useRefWithCalc calls the native useRef, has some internal handlers for UX, then calls useEventListener to attach those handlers. useRefWithCalc returns the ref created within, so another component can use this hook, and get the returned ref to attach to elements.
This has worked for me when the ref isn't attached to conditionally rendered elements.
The component looks something like this. Please take note on the 2 test logs.
const useEventListener = (event, listener, ref) => {\
...
useEffect(() => {
...
console.log("1. ref is: ", ref.current); // test logging 1.
ref.current.addEventListener(event, listener);
return () => {
ref.current.removeEventListener(event, listener);
}
}, [event, listener, ref]);
}
const useRefWithCalc = (value) => {
const ref = useRef(null);
...
const calc = () => {
// some calculations
}
...
useEventListener(event, calc, ref)
return [ref, result]
}
// works perfectly
const WorkingElement = (props) => {
const [ref, result] = useRefWithCalc(props.value);
...
return <B ref={ref} />
}
// doesn't work consistently
const ConditionalElement = (props) => {
const [state, setState] = useState(false);
const [ref, result] = useRefWithCalc(props.value)
useEffect(()=>{
if (ref && ref.current) {
ref.current.focus();
console.log("2. ref is: ", ref.current); // test logging 2
}
}, [ref])
...
return state ? <A> : <B ref={ref} />
}
The <WorkingElement /> works just as expected. The ref gets attached, and handles events with no problem.
However, in the <ConditionalElement />, when B is mounted, sometimes times test logging 1 won't fire. Test logging 2 always fires, and the ref gets the focus correctly. But this update is not passed into useEventListener
Once <B /> gets 1 subsequent update (e.g. when user inputs something), both logs will fire correctly, and the event listner gets attached correctly, and it work just as <WorkingElement />
Sorry for not posting the exact code. I feel like my approach is convoluted and might be wrong.
In React when a ref changes, it doesn't trigger a component update, and useEffects are not triggered.
I suggest to put your ref inside a state so that effects are triggered when the ref changes :
const [ref, setRef] = useState(undefined)
return (
<B ref={setRef}/>
)
I have a state value ccFormDetails with a an empty object as default value.
const [ccFormDetails, setCCFormDetails] = useState({})
In first useEffect, I call a function the populates ccFormDetails with relevant data.
const getCCFormData = async () => {
const ccFormResult = await ContractService.getContractsCCFormData()
const { ccFormData } = ccFormResult
setCCFormDetails(ccFormData)
}
In second useEffect, I create an a event named message and assign a handleCgEvent handler to it.
useEffect(() => {
window.addEventListener('message', handleCgEvent)
return () => window.removeEventListener('message', handleCgEvent)
}, [])
I'm rendering an iframe with a submit button that emits the message event when clicked.
Then, when I click the sumbit button in the iframe, the handleCgEvent handler fires and should extract the correct (updated, fecthed) value of ccFormDetails. (Which I can see populated correctly in the React components tree)
const handleCgEvent = e => {
if (e.data === 'reload_cg') {
console.log('fail')
}
if (e.data['event_id'] === 'cg-success') {
console.log('success')
console.log('ccFormDetails1 ', ccFormDetails)
}
}
But what I get is {} meaning the original default state.
This should not happen per my knowledge of React.
Am I missing something/Does event handling messes state?
The problem here is you're using the handleCgEvent that's created when the component is mounted, and ccFormDetails is encapsulated within it using its default value. In order to get the most current state, you're going to have to use useRef. Something like:
const [ccFormDetails, setCCFormDetails] = useState({})
const formRef = useRef();
formRef.current = ccFormDetails;
const handleCgEvent = e => {
if (e.data === 'reload_cg') {
console.log('fail')
}
if (e.data['event_id'] === 'cg-success') {
console.log('success')
console.log('ccFormDetails1 ', formRef.current)
}
}
useEffect(() => {
window.addEventListener('message', handleCgEvent)
return () => window.removeEventListener('message', handleCgEvent)
}, []);
If it's okay for you to add and remove the event listener, then I would just add [ccFormDetails] as a dependency, assuming the entire object is recreated when its properties change.
For further reading about stale values in closures, this is a great blog entry: https://dmitripavlutin.com/react-hooks-stale-closures/
I am currently writing a map component using Mapbox. But I encounter an error on React hooks during development.
In useEffect state variable that is declared prints two different values.
As i explain in the below code. startDrawing is console.logs both true and false after second click on <IconButton/> button.
import React from "react";
import mapboxgl from "mapbox-gl";
import { Add, Delete } from "#material-ui/icons";
import IconButton from "#material-ui/core/IconButton";
export default function MapComponent() {
const mapContainerRef = React.useRef(null);
const [startDrawing, setStartDrawing] = React.useState(false);
const [map, setMap] = React.useState(null);
const initMap = () => {
mapboxgl.accessToken = "mapbox-token";
const mapbox = new mapboxgl.Map({
container: mapContainerRef.current,
style: "mapbox://styles/mapbox/streets-v11",
center: [0, 0],
zoom: 12,
});
setMap(mapbox);
};
React.useEffect(() => {
if (!map) {
initMap();
} else {
map.on("click", function (e) {
// After second click on set drawing mode buttons
// startDrawing value writes two values for each map click
// MapComponent.js:85 true
// MapComponent.js:85 false
// MapComponent.js:85 true
// MapComponent.js:85 false
// MapComponent.js:85 true
// MapComponent.js:85 false
// MapComponent.js:85 true
// MapComponent.js:85 false
// MapComponent.js:85 true
// MapComponent.js:85 false
console.log(startDrawing);
if (startDrawing) {
// do stuff
} else {
// do stuff
}
});
}
}, [map, startDrawing]);
return (
<>
<div>
{/* set drawing mode */}
<IconButton onClick={() => setStartDrawing(!startDrawing)}>
{startDrawing ? <Delete /> : <Add />}
</IconButton>
</div>
<div ref={mapContainerRef} />
</>
);
}
So my question is how can i solve this problem?
Thank you for your answers.
The issue is that you are adding a new event listener to map every time startDrawing changes. When you click on the rendered element all of those listeners are going to be fired, meaning you get every state of startDrawing the component has seen.
See this slightly more generic example of your code, and note that every time you click Add or Delete a new event listener gets added to the target element:
const { useState, useRef, useEffect } = React;
function App() {
const targetEl = useRef(null);
const [startDrawing, setStartDrawing] = useState(false);
const [map, setMap] = useState(null);
const initMap = () => {
setMap(targetEl.current);
};
useEffect(() => {
if (!map) {
initMap();
} else {
const log = () => console.log(startDrawing);
map.addEventListener('click', log);
}
}, [map, startDrawing]);
return (
<div>
<div>
<button onClick={() => setStartDrawing(!startDrawing)}>
{startDrawing ? <span>Delete</span> : <span>Add</span>}
</button>
</div>
<div ref={targetEl}>target element</div>
</div>
);
}
ReactDOM.render(<App/>, document.getElementById('root'));
<script crossorigin src="https://unpkg.com/react#16/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom#16/umd/react-dom.production.min.js"></script>
<div id="root"></div>
You can fix this by adding a return statement to your useEffect. This is triggered immediately before the effect updates with new values from the dependency array, and also when the component unmounts. Inside the return statement you should remove the previous event listener so that only one is attached to the element at any given point. For the above example it would look like this:
useEffect(() => {
if (!map) {
initMap();
} else {
const log = () => console.log(startDrawing);
map.addEventListener('click', log);
return () => {
map.removeEventListener('click', log);
};
};
}, [map, startDrawing]);
Ideally you would not use the standard JS event syntax at all, as the convention in React is to attach events declaratively in the return/render function so that they can always reference the current state. However, you are using an external library, and I don't know whether it has any explicit support for React - you should probably check that out.
The issue is that useEffect will trigger both on mount and unmount (render and destroy). Refer to this documentation for a detailed explanation.
To run the function only on the first render, you can pass an empty array as the second parameter of useEffect, just like this:
useEffect(()=>{
//do stuff
},[]); // <--- Look at this parameter
The last parameter serves as a flag and usually a state should be passed, which will make useEffect's function trigger only if the parameter's value is different from the previous.
Let's assume you want to trigger useEffect each and every time your state.map changes - you cold do the following:
const [map, setMap] = React.useState(null);
useEffect(()=>{
//do stuff
},map); // if map is not different from previous value, function won't trigger
State value showMenu is not updating within useEffect hook.
When testing, when the button is first clicked and the screen is touched to move, showMenu properly consoles to true. When the button is clicked a second time (and third, forth, etc) and the screen is touched to move, showMenu continues to console as true when it should alternate to false.
const [showMenu, setShowMenu] = useState(false)
useEffect(_ => {
const listener = e => {
e.preventDefault()
console.log(showMenu, ' useEffect - touchmove')
}
showMenu
? document.body.addEventListener('touchmove', listener, {passive: false})
: document.body.removeEventListener('touchmove', listener)
}, [showMenu])
return (
<button onclick={_ => {
console.log(!showMenu, ' button click')
setShowMenu(!showMenu)
}} />
)
Console Result
I think the event of body is not properly removed, because listener is changed every time useEffect.
So you can return a function in useEffect to clear the previous useEffect.
useEffect(() => {
if (showMenu) {
const listener = e => {
e.preventDefault();
console.log(showMenu, ' useEffect - touchmove');
};
document.body.addEventListener('touchmove', listener, { passive: false });
return () => {
document.body.removeEventListener('touchmove', listener);
}
}
}, [showMenu]);
You can also read cleaning-up-an-effect to learn more
I don't know what your intent is, but what you are doing with useEffect is probably not what you're expecting. When showMenu is false, you're removing a listener function that has not been bound because objects are compared by reference in JS and listener is being redefined each time showMenu changes.
The typical way to unbind a listener when useEffect changes is to return a function that handles the cleanup from your useEffect callback. Like so:
useEffect(() => {
const listener = e => {
e.preventDefault()
console.log(showMenu, ' useEffect - touchmove')
}
document.body.addEventListener('touchmove', listener, { passive: false })
return () = {
document.body.removeEventListener('touchmove', listener, { passive: false })
}
}, [showMenu])
Cannot for the life of me figure out what is going on, but for some reason when "Click me" is clicked, the number increments as you'd expect. When a click is triggered by the Child component, it resets the state and ALWAYS prints 0.
function Child(props: {
onClick?: (id: string) => void,
}) {
const ref = useCallback((ref) => {
ref.innerHTML = 'This Doesnt';
ref.addEventListener('click',() => {
props.onClick!('')
})
}, [])
return (<div ref={ref}></div>)
}
function Parent() {
const [number, setNumber] = useState(0);
return <div>
<div onClick={() => {
setNumber(number + 1);
console.log(number);
}}>
This Works
</div>
<Child
onClick={(id) => {
setNumber(number + 1);
console.log(number);;
}}
/>
</div>
}
And here is a demonstration of the problem: https://jscomplete.com/playground/s333177
Both the onClick handlers in parent component are re-created on every render, rightly so as they have a closure on number state field.
The problem is, the onClick property sent to Child component is used in ref callback, which is reacted only during initial render due to empty dependency list. So onClick prop received by Child in subsequent renders does not get used at all.
Attempt to resolve this error, by either removing dependency param or sending props.onClick as in dependency list, we land into issue due to caveat mentioned in documentation. https://reactjs.org/docs/refs-and-the-dom.html
So you add null handling, and you see your updated callback now getting invoked, but... all earlier callbacks are also invoked as we have not removed those event listeners.
const ref = useCallback((ref) => {
if(!ref) return;
ref.innerHTML = 'This Doesnt';
ref.addEventListener('click',() => {
props.onClick!('')
})
}, [props.onClick])
I believe this just an experiment being done as part of learning hooks, otherwise there is no need to go in roundabout way to invoke the onClick from ref callback. Just pass it on as prop to div.
Edit:
As per your comment, as this is not just an experiment but simplification of some genuine requirement where click handler needs to be set through addEventListener, here is a possible solution:
const ref = useRef(null);
useEffect(() => {
if(!ref.current) return;
ref.current.innerHTML = 'This Doesnt';
const onClick = () => props.onClick!('');
ref.current.addEventListener('click',onClick)
// return the cleanup function to remove the click handler, which will be called before this effect is run next time.
return () => {ref.current.removeEventListener("click", onClick)}
}, [ref.current, props.onClick]);
Basically, we need to use useEffect so that we get a chance to remove old listener before adding new one.
Hope this helps.
function Child(props: {
onClick?: (id: string) => void,
}) {
function handleClick() {
props.onClick('')
}
const ref = useRef();
useEffect(() => {
ref.current.innerHTML = 'This Doesnt';
ref.current.addEventListener('click', handleClick)
return () => { ref.current.removeEventListener('click', handleClick); }
}, [props.onClick])
return (<div ref={ref}></div>)
}
#ckder almost works but console.log displayed all numbers from 0 to current number value.
Issue was with event listener which has not been remove after Child component umount so to achive this I used useEffect and return function where I unmount listener.