Please take a look at this code snippet.
function App() {
useEffect(() => {
document.addEventListener('keydown', onKeyDown);
}, []);
const onKeyDown = e => {
setKeyMap({ ...keyMap, [e.keyCode]: true });
};
const [keyMap, setKeyMap] = useState({});
// JSON.stringify gives only one key-value pair
return (
<div className="App">
<h1>Hello {JSON.stringify(keyMap)}</h1>
</div>
);
}
So using useEffect, I am adding an eventlistener to keydown event only once. This is similar to componentDidMount apparently since this occurs only once.
In the event handler for keydown event, I am trying to add all keycodes which were pressed to be true.
So say I press 'a', then my keyMap should look like this:
{
"65": true
}
Now if I press 'b', then my keyMap should look like this:
{
"65": true,
"66": true
}
But what's happening is this:
{
"66": true
}
Why am I unable to mutate the previous state and add to it? Like I previously had "65" as the key, but when I press another key on my keyboard, suddenly this "65" key-value disappears. That is at any given point, only one key-value pair exists for some reason. I want to be able to add to this object(keyMap object I mean) as many times as I want. Is the ... operator(spread) not working?
useEffect(() => {
document.addEventListener('keydown', onKeyDown);
}, []);
Due to the empty array as the second parameter, this gets run only once, when the component first renders. The onKeyDown function has a reference to the state at that time, so the keyMap in setKeyMap({ ...keyMap, [e.keyCode]: true }); refers to an empty object. Any time this key handler is called, it sets the state to an empty object, plus the latest key.
Instead, you want it to use the latest state. You have two main options for how to do this
1) Don't skip the effect. This way you'll always have an event listener with the latest state. Remember to return a teardown function so that you don't have an event listener leak:
useEffect(() => {
const onKeyDown = e => {
setKeyMap({ ...keyMap, [e.keyCode]: true });
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}); // You could also pass [keyMap] as the second param to skip irrelevant changes
2) Or, use the function version of setKeyMap. It will pass you the latest value of the state.
const onKeyDown = e => {
setKeyMap(prevKeyMap => ({ ...prevKeyMap, [e.keyCode]: true }));
};
You just need to move your onKeyDown function to inside the useEffect hook like this. Note I have added keyMap in the dependency array for the useEffect hook:
https://codesandbox.io/s/red-sun-23bmu?fontsize=14
Related
I am building a simple game in react. The problem is context is properly updated but the context value inside an event listener function is not updating. If I access the value outside the event function then the new updated value is rendered.
For the first keyup event the value will be 0 but for the next events, it should be a new updated value.
const updateGame = useUpdateGame();
const gameData = useGameData();
//Assume Default gameData.board value is 0
// Assume context value updated to 5
useEffect(() => {
document.addEventListener("keyup", (e) => {
e.stopImmediatePropagation();
console.log(gameData.board) //0
handleKeyPress(e.key, gameData.board, updateGame);
});
}, []);
console.log(gameData.board) //5
The event listener has closed over the old value of context (think closures). You will need to keep the updated value of the context in the event listener.
This could either be done by defining the event listener everytime the context value changes or using a ref.
Register and clear the event listener in the useEffect after every render when gameData changes.
const updateGame = useUpdateGame();
const gameData = useGameData();
useEffect(() => {
let listener = (e) => {
e.stopImmediatePropagation();
handleKeyPress(e.key, gameData.board, updateGame);
};
document.addEventListener("keyup", listener);
return () => {
document.removeEventListener("keyup", listener);
};
}, [gameData]); //updateGame should be added here too ideally
Keep a ref which mimics the value you are looking for. ref are stable containers so they will always have the correct value of the state
const updateGame = useUpdateGame();
const gameData = useGameData();
const gameDataRef = useRef(gameData?.board ?? null);
useEffect(() => {
document.addEventListener("keyup", (e) => {
e.stopImmediatePropagation();
handleKeyPress(e.key, gameDataRef.current, updateGame);
});
}, []);
useEffect(() =>{
gameDataRef.current = gameData.board;
},[gameData]);
Whenever gameData changes, you should add another keyup listener (and remove the one that currently exists, if any) so that the state value in the closure is the most up-to-date one - right now, the empty dependency array means that the gameData in the handler always refers to its initial value, which isn't what you want.
updateGame is also referenced inside the listener, so it should be in the dependency array as well.
useEffect(() => {
const handler = (e) => {
e.stopImmediatePropagation();
handleKeyPress(e.key, gameData.board, updateGame);
};
document.addEventListener("keyup", handler);
return () => document.removeEventListener("keyup", handler);
}, [gameData, updateGame]);
Consider enabling and following the exhaustive-deps rule to automatically prompt you to fix these sorts of potential mistakes in the future.
In an event handler in React, how do you get and set outside data?
Here's an example that counts keyup events. Because the function is only created once, the listener always sees the keyup count as what it was initially, and it will never count above 1.
Working JSFiddle, or see code sample below.
function CountKeypresses() {
const [keypressCount, setKeypressCount] = React.useState(0);
const handleKeyup = (event) => {
setKeypressCount(keypressCount + 1); //keypressCount is always 0 here no matter what
}
//Only create the event listener once
React.useEffect(() => {
window.addEventListener("keydown", handleKeyup);
}, []);
return keypressCount;
}
ReactDOM.render(<CountKeypresses />, document.querySelector("#app"))
Use the callback form of the setter so that you can set the new value to the current previous value plus 1:
setKeypressCount(keypressCount => keypressCount + 1);
Another option is to add and remove the listener every time keypressCount changes, though it's a bit uglier:
React.useEffect(() => {
window.addEventListener("keydown", handleKeyup);
return () => window.removeEventListener("keydown", handleKeyup);
}, [keypressCount]);
If you're in a similar situation where you don't want to set state, but you need to get the current value in state, and the useEffect approach above isn't suitable, you can also store the value in a ref instead, but such situations are somewhat unusual in my experience.
I'm using the following component to submit comments in my app:
const App = () => {
const [text, setText] = React.useState("");
const send = React.useCallback(() => {
setText("");
console.log("sending", text);
}, [text]);
React.useEffect(() => {
const handler = e => {
switch (e.keyCode) {
case 13: // enter
if (e.shiftKey) {
e.preventDefault();
send();
}
break;
}
}
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, []);
return <div className="App">
<textarea
className="App__text"
value={text}
onChange={e => {
setText(e.target.value);
}} />
<button className="App__send" onClick={send}>send</button>
</div>;
};
working demo here
It's a simple text field and button. When either the button, or shift-enter are pressed, the text in the text field is sent to the server (here we just console.log it).
The button works fine - enter "hello world" (or whatever) press the button, and the console will say hello world.
Shift-enter, however, always prints an empty string.
I'm guessing I'm misunderstanding useCallback. As I understand it, useCallback wraps your function. When one of the dependencies change, React swaps out your function without changing the wrapper function so it's still an up-to-date reference wherever it's used. Given that the send being called in useEffect appears to have the initial value of text in scope, but the one used in the onClick of the button has the newest, my assumptions seem incorrect.
I've also tried adding text as a dependency of the useEffect but
I don't want to remove/create/add the listener on every keystroke and
It's still missing the last character pressed anyway.
How can I keep a current version of my send function inside of another callback?
You are missing send in your dependencies, take a look at this updated code:
React.useEffect(() => {
const handler = e => {
switch (e.keyCode) {
case 13: // enter
if (e.shiftKey) {
e.preventDefault();
send(); // This will always be called an older version, instead of updated one
}
break;
}
}
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [send]); // You need to put send here, since it is part of this component, and needs to be updated
The reason It works with it, is because send function is memoised as well:
const send = React.useCallback(() => {
setText("");
console.log("sending", text);
}, [text]);
so you need to make sure to update to its newer version, with its newer text (which never happened, thats why you got no text in your SHIFT+ENTER)
EDIT: Upon further investigation, it seems to me that the biggest problem was out-of sync text and listener handler.
I have modified the code to work, by removing the listener alltogether, and using onKeyDown prop directly on textarea. Take a look at this working codepen:
https://codepen.io/antonioerda/pen/zYqYWgx
Two things to make sure you do when using the useCallback hook:
make sure to add the list of all the needed dependencies that are external to the method are listed.
if inside this method with the usecallback you call another method that you created using the usecallback, include the dependencies there in this one as well.
I'm trying to add some behaviour on keydown event, but the useEffect is not working correctly.
Here is the code:
const Test = ({}) => {
const [value, setValue] = React.useState(0);
const add= (sum) => {
setValue(value+sum);
}
React.useEffect(()=> {
const handleKeyDown = (e: KeyboardEvent) => {
console.log("keypress", value);
if (e.key === 'ArrowLeft') {
add(-1);
} else if (e.key === 'ArrowRight') {
add(1);
}
}
document.addEventListener('keydown', event => handleKeyDown(event));
return () => {
console.log("remove event")
document.removeEventListener('keydown', event => handleKeyDown(event));
};
}, [add])
return <span>Count {value}</span>
}
Here is jsfiddle code - You can see it calling many times on keypress on the console.
If I put an empty array on useEffect dependencies, when calling add function, the value will always be 0. If I put add or value on dependency array, every time I press the key useEffect will trigger many times. Is there a solution for this?
By passing an inline arrow function to addEventListener you're creating a new handler each time, and removing it doesn't work because, again, you're declaring a new function to remove--a function that was never added in the first place:
// creates a new handler function every time
document.addEventListener('keydown', event => handleKeyDown(event));
// removing a function that isn't currently registered as a
// listener on the document, because it's a brand new inline function.
document.removeEventListener('keydown', event => handleKeyDown(event));
Another instance of your keydown listener gets added every time this renders and they never get removed, so as you interact with the app they pile up.
There's no need to create a new function just to call the existing one. Try this instead:
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
Similar problem with your add function. Declaring add as an effect dependency causes the effect to run every time, because add gets re-declared on every render. Wrap it in a useCallback to prevent it from getting recreated every time:
const add = React.useCallback((sum) => {
setValue(value+sum);
}, [value]);
In my component I'm setting up an event listener within the useEffect hook:
useEffect(() => {
const target = subtitleEl.current;
target.addEventListener("click", () => {
console.log("click");
onSubtitleClick();
});
return () => {
target.removeEventListener("click", null);
};
}, []);
.. but when I call onSubtitleClick, my state is stale - it's the original value. Example code here. How can I access state from the event handler that was setup with the useEffect?
Your event listener registers a function (reference) which has count as 0 in the environment it is defined and when a new render happens, your reference is not being updated which is registered with that element and that registered function reference still knows that count is 0 even though count has been changed but that updated function was not registered which knows the updated value in its context. So you need to update event listener with new function reference.
useEffect(() => {
const target = subtitleEl.current;
target.addEventListener("click", onSubtitleClick);
return () => {
console.log("removeEventListener");
target.removeEventListener("click", onSubtitleClick);
};
}, [onSubtitleClick]);
However, you don't need that messy code to achieve what you are doing now or similar stuff. You can simply call that passed function on click and don't attach to element through ref but directly in jsx.
<div
className="panelText"
style={{ fontSize: "13px" }}
onClick={onSubtitleClick}
ref={subtitleEl}
>
Button2
</div>
I think basically the addeventlistener creates a closure with its own version of count separate to the parent component's. A simple fix is to allow the useEffect function to rerun on changes (remove the [] arg):
useEffect(() => {
const target = subtitleEl.current;
target.addEventListener("click", () => {
console.log("click");
onSubtitleClick();
});
return () => {
target.removeEventListener("click", null);
};
}); // removed [] arg which prevents useeffect being run twice