State won't change in "keydown" event in React - javascript

I am making a an app where I need to register the keys the user presses. To do that I am using keydown and keyup events. However, I don't want to register the same key multiple times when the user holds it down.
I want to make it so the downHandler function won't run multiple times whenever you hold down a key. I only want it to run once. I try to use an if statement for that and setting keyPressed to true so it won't run again. I try to log keyPressed right after I set it to true, but it logs as false, and the function still runs again.
I tried to remove setKeyPressed(false) in the downHandler function, but keyPressed was still false no matter what.
Here is my app.js:
import { useState, useEffect } from "react";
function App() {
const key = useKeyPress();
function useKeyPress(targetKey) {
const [keyPressed, setKeyPressed] = useState(false);
function downHandler(key) {
if (keyPressed === false) {
setKeyPressed(true);
console.log(keyPressed)
}
}
const upHandler = (key) => {
console.log("up")
setKeyPressed(false);
};
useEffect(() => {
window.addEventListener("keydown", downHandler);
window.addEventListener("keyup", upHandler);
return () => {
window.removeEventListener("keydown", downHandler);
window.removeEventListener("keyup", upHandler);
};
}, []);
return keyPressed;
}
return (
<div>
</div>
)
}
export default App;

The custom hook should be created in outer scope, when you writing it in the render function (function component body) you are recreating the hook on every render. Hence you losing state each time.
Surprised that you didn't get any lint warning of breaking the rules of hook - writing a hook inside a function, make sure its configured.
Moreover, you are making comparison with stale state keyPressed === false duo to closures, you can fix it by using a reference.
function useKeyPress(targetKey) {
const [keyPressed, setKeyPressed] = useState(false);
const keyPressedRef = useRef();
useEffect(() => {
keyPressedRef.current = keyPressed;
console.log("keyPressed", keyPressed);
}, [keyPressed]);
useEffect(() => {
function downHandler(key) {
if (keyPressedRef.current === false) {
setKeyPressed(true);
}
}
const upHandler = (key) => {
setKeyPressed(false);
};
window.addEventListener("keydown", downHandler);
window.addEventListener("keyup", upHandler);
return () => {
window.removeEventListener("keydown", downHandler);
window.removeEventListener("keyup", upHandler);
};
}, []);
return keyPressed;
}
export default function App() {
const key = useKeyPress();
return <div></div>;
}
https://codesandbox.io/s/inspiring-jasper-3lym7p?file=/src/App.js:77-897

Related

Prevent reload and navigation when form is left dirty in React

I've am creating a react app and I wanna stop navigation to another page if my form is left dirty
import { useEffect, useState } from "react";
const unsavedChanges = () => {
const [isDirty, setDirty] = useState(false);
useEffect(() => {
function confirmNavigation(event) {
if (isDirty) {
event.preventDefault();
event.returnValue = "Are you sure you want to leave? Your changes will be lost."; // eslint-disable-line no-param-reassign
}
}
window.addEventListener("beforeunload", confirmNavigation);
return () => {
window.removeEventListener("beforeunload", confirmNavigation);
};
}, [isDirty]);
const onDirty = () => setDirty(true);
const onPristine = () => setDirty(false);
return { onDirty, onPristine };
};
export default unsavedChanges;
The code I wrote lets me not reload the page if my form is dirty but I can't prevent it from navigating to another page since react doesn't load the whole page it just loads the data to be changed. I can't use Prompt, useHistory, or useBlocking because they don't exist in react-router v6.4.4.
How can I achieve that?
Why not register a window.onbeforeunload listener? See https://stackoverflow.com/a/1119324/9824103
const unsavedChanges = () => {
const [isDirty, setDirty] = useState(false);
useEffect(() => {
function confirmNavigation(event) {
return isDirty ? "Are you sure you want to leave? Your changes will be lost." : null; // eslint-disable-line no-param-reassign
}
window.onbeforeunload = confirmNavigation;
return () => {
window.beforeunload = null;
};
}, [isDirty]);
const onDirty = () => setDirty(true);
const onPristine = () => setDirty(false);
return { onDirty, onPristine };
};

How to remove event listener inside useCallback hook

I want to create a generic react hook that will add a scroll event to the element and return a boolean indicating that the user has scrolled to the top of the element.
Now, the problem is this element might not be visible right away. Hence I'm not able to use useEffect. As I understand in that situation it is advised to use useCallback
So I did, and it works:
function useHasScrolled() {
const [hasScrolled, setHasScrolled] = useState(false);
const ref = useRef(null);
const setRef = useCallback((element) => {
const handleScroll = (e) => {
setHasScrolled(e.target.scrollTop !== 0);
};
if (element) {
element.addEventListener("scroll", handleScroll);
}
ref.current = element;
}, []);
return {
hasScrolled,
scrollingElementRef: setRef
};
}
I can use my hook like this:
const { hasScrolled, scrollingElementRef } = useHasScrolled();
....
return <div ref={scrollingElementRef}>....
However, the problem is, I don't know how to remove the event listener. With the useEffect hook, it's pretty straightforward - you just return the cleanup function.
Here's the codesandbox, if you want to check the implementation: https://codesandbox.io/s/pedantic-dhawan-83fdw3
Expected behavior - when node is removed from DOM - event listeners will be also removed and collected by GC.
But
Codesandbox example is a bit tricky, React treats
<div>Loading...</div>
and
<div className="scrollingDiv" ref={scrollingElementRef}>
<h1>Hello, I've finally loaded!</h1>
<Lorem />
</div>
as a same div, same object, just with different props (className and children), so when div.scrollingDiv is replaced by conditional rendering to div(loading) - event listeners are still there and accumulating.
This behavior can be fixed as is by using keys.
{loading ? (
<div key="div1">Loading...</div>
) : (
<div key="div2" className="scrollingDiv" ref={scrollingElementRef}>
<h1>Hello, I've finally loaded!</h1>
<Lorem />
</div>
)}
In that way event listeners will be removed as expected.
Another solution is to add 1 more useRef and useEffect to the custom hook to store and execute actual unsubscribe function:
function useHasScrolled() {
const [hasScrolled, setHasScrolled] = useState(false);
const ref = useRef(null);
const unsubscribeRef = useRef(null);
const setRef = useCallback((element) => {
const eventName = "scroll";
const handleScroll = (e) => {
setHasScrolled(e.target.scrollTop !== 0);
};
if (unsubscribeRef.current) {
unsubscribeRef.current();
unsubscribeRef.current = null;
}
if (element) {
element.addEventListener(eventName, handleScroll);
unsubscribeRef.current = () => {
console.log("removeEventListener called on: ", element);
element.removeEventListener(eventName, handleScroll);
};
ref.current = element;
} else {
unsubscribeRef.current = null;
ref.current = null;
}
}, []);
useEffect(() => {
return () => {
if (unsubscribeRef.current) {
unsubscribeRef.current();
unsubscribeRef.current = null;
}
};
}, []);
return {
hasScrolled,
scrollingElementRef: setRef
};
}
That code will work without adding key.
Utility code for Chrome dev console to count scroll listeners:
Array.from(document.querySelectorAll('*'))
.reduce(function(pre, dom){
var clks = getEventListeners(dom).scroll;
pre += clks ? clks.length || 0 : 0;
return pre
}, 0)
Updated codesandbox: https://codesandbox.io/s/angry-einstein-6fb1u4?file=/src/App.js

UseEffect runs on first render without being called using Context [duplicate]

According to the docs:
componentDidUpdate() is invoked immediately after updating occurs. This method is not called for the initial render.
We can use the new useEffect() hook to simulate componentDidUpdate(), but it seems like useEffect() is being ran after every render, even the first time. How do I get it to not run on initial render?
As you can see in the example below, componentDidUpdateFunction is printed during the initial render but componentDidUpdateClass was not printed during the initial render.
function ComponentDidUpdateFunction() {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
console.log("componentDidUpdateFunction");
});
return (
<div>
<p>componentDidUpdateFunction: {count} times</p>
<button
onClick={() => {
setCount(count + 1);
}}
>
Click Me
</button>
</div>
);
}
class ComponentDidUpdateClass extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0,
};
}
componentDidUpdate() {
console.log("componentDidUpdateClass");
}
render() {
return (
<div>
<p>componentDidUpdateClass: {this.state.count} times</p>
<button
onClick={() => {
this.setState({ count: this.state.count + 1 });
}}
>
Click Me
</button>
</div>
);
}
}
ReactDOM.render(
<div>
<ComponentDidUpdateFunction />
<ComponentDidUpdateClass />
</div>,
document.querySelector("#app")
);
<script src="https://unpkg.com/react#16.7.0-alpha.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom#16.7.0-alpha.0/umd/react-dom.development.js"></script>
<div id="app"></div>
We can use the useRef hook to store any mutable value we like, so we could use that to keep track of if it's the first time the useEffect function is being run.
If we want the effect to run in the same phase that componentDidUpdate does, we can use useLayoutEffect instead.
Example
const { useState, useRef, useLayoutEffect } = React;
function ComponentDidUpdateFunction() {
const [count, setCount] = useState(0);
const firstUpdate = useRef(true);
useLayoutEffect(() => {
if (firstUpdate.current) {
firstUpdate.current = false;
return;
}
console.log("componentDidUpdateFunction");
});
return (
<div>
<p>componentDidUpdateFunction: {count} times</p>
<button
onClick={() => {
setCount(count + 1);
}}
>
Click Me
</button>
</div>
);
}
ReactDOM.render(
<ComponentDidUpdateFunction />,
document.getElementById("app")
);
<script src="https://unpkg.com/react#16.7.0-alpha.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom#16.7.0-alpha.0/umd/react-dom.development.js"></script>
<div id="app"></div>
You can turn it into custom hooks, like so:
import React, { useEffect, useRef } from 'react';
const useDidMountEffect = (func, deps) => {
const didMount = useRef(false);
useEffect(() => {
if (didMount.current) func();
else didMount.current = true;
}, deps);
}
export default useDidMountEffect;
Usage example:
import React, { useState, useEffect } from 'react';
import useDidMountEffect from '../path/to/useDidMountEffect';
const MyComponent = (props) => {
const [state, setState] = useState({
key: false
});
useEffect(() => {
// you know what is this, don't you?
}, []);
useDidMountEffect(() => {
// react please run me if 'key' changes, but not on initial render
}, [state.key]);
return (
<div>
...
</div>
);
}
// ...
I made a simple useFirstRender hook to handle cases like focussing a form input:
import { useRef, useEffect } from 'react';
export function useFirstRender() {
const firstRender = useRef(true);
useEffect(() => {
firstRender.current = false;
}, []);
return firstRender.current;
}
It starts out as true, then switches to false in the useEffect, which only runs once, and never again.
In your component, use it:
const firstRender = useFirstRender();
const phoneNumberRef = useRef(null);
useEffect(() => {
if (firstRender || errors.phoneNumber) {
phoneNumberRef.current.focus();
}
}, [firstRender, errors.phoneNumber]);
For your case, you would just use if (!firstRender) { ....
Same approach as Tholle's answer, but using useState instead of useRef.
const [skipCount, setSkipCount] = useState(true);
...
useEffect(() => {
if (skipCount) setSkipCount(false);
if (!skipCount) runYourFunction();
}, [dependencies])
EDIT
While this also works, it involves updating state which will cause your component to re-render. If all your component's useEffect calls (and also all of its children's) have a dependency array, this doesn't matter. But keep in mind that any useEffect without a dependency array (useEffect(() => {...}) will be run again.
Using and updating useRef will not cause any re-renders.
#ravi, yours doesn't call the passed-in unmount function. Here's a version that's a little more complete:
/**
* Identical to React.useEffect, except that it never runs on mount. This is
* the equivalent of the componentDidUpdate lifecycle function.
*
* #param {function:function} effect - A useEffect effect.
* #param {array} [dependencies] - useEffect dependency list.
*/
export const useEffectExceptOnMount = (effect, dependencies) => {
const mounted = React.useRef(false);
React.useEffect(() => {
if (mounted.current) {
const unmount = effect();
return () => unmount && unmount();
} else {
mounted.current = true;
}
}, dependencies);
// Reset on unmount for the next mount.
React.useEffect(() => {
return () => mounted.current = false;
}, []);
};
a simple way is to create a let, out of your component and set in to true.
then say if its true set it to false then return (stop) the useEffect function
like that:
import { useEffect} from 'react';
//your let must be out of component to avoid re-evaluation
let isFirst = true
function App() {
useEffect(() => {
if(isFirst){
isFirst = false
return
}
//your code that don't want to execute at first time
},[])
return (
<div>
<p>its simple huh...</p>
</div>
);
}
its Similar to #Carmine Tambasciabs solution but without using state :)
‍‍‍‍‍‍
‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍
function useEffectAfterMount(effect, deps) {
const isMounted = useRef(false);
useEffect(() => {
if (isMounted.current) return effect();
else isMounted.current = true;
}, deps);
// reset on unmount; in React 18, components can mount again
useEffect(() => {
isMounted.current = false;
});
}
We need to return what comes back from effect(), because it might be a cleanup function. But we don't need to determine if it is or not. Just pass it on and let useEffect figure it out.
In an earlier version of this post I said resetting the ref (isMounted.current = false) wasn't necessary. But in React 18 it is, because components can remount with their previous state (thanks #Whatabrain).
I thought creating a custom hook would be overkill and I didn't want to muddle my component's readability by using the useLayoutEffect hook for something unrelated to layouts, so, in my case, I simply checked to see if the value of my stateful variable selectedItem that triggers the useEffect callback is its original value in order to determine if it's the initial render:
export default function MyComponent(props) {
const [selectedItem, setSelectedItem] = useState(null);
useEffect(() => {
if(!selectedItem) return; // If selected item is its initial value (null), don't continue
//... This will not happen on initial render
}, [selectedItem]);
// ...
}
This is the best implementation I've created so far using typescript. Basically, the idea is the same, using the Ref but I'm also considering the callback returned by useEffect to perform cleanup on component unmount.
import {
useRef,
EffectCallback,
DependencyList,
useEffect
} from 'react';
/**
* #param effect
* #param dependencies
*
*/
export default function useNoInitialEffect(
effect: EffectCallback,
dependencies?: DependencyList
) {
//Preserving the true by default as initial render cycle
const initialRender = useRef(true);
useEffect(() => {
let effectReturns: void | (() => void) = () => {};
// Updating the ref to false on the first render, causing
// subsequent render to execute the effect
if (initialRender.current) {
initialRender.current = false;
} else {
effectReturns = effect();
}
// Preserving and allowing the Destructor returned by the effect
// to execute on component unmount and perform cleanup if
// required.
if (effectReturns && typeof effectReturns === 'function') {
return effectReturns;
}
return undefined;
}, dependencies);
}
You can simply use it, as usual as you use the useEffect hook but this time, it won't run on the initial render. Here is how you can use this hook.
useNoInitialEffect(() => {
// perform something, returning callback is supported
}, [a, b]);
If you use ESLint and want to use the react-hooks/exhaustive-deps rule for this custom hook:
{
"rules": {
// ...
"react-hooks/exhaustive-deps": ["warn", {
"additionalHooks": "useNoInitialEffect"
}]
}
}
#MehdiDehghani, your solution work perfectly fine, one addition you have to do is on unmount, reset the didMount.current value to false. When to try to use this custom hook somewhere else, you don't get cache value.
import React, { useEffect, useRef } from 'react';
const useDidMountEffect = (func, deps) => {
const didMount = useRef(false);
useEffect(() => {
let unmount;
if (didMount.current) unmount = func();
else didMount.current = true;
return () => {
didMount.current = false;
unmount && unmount();
}
}, deps);
}
export default useDidMountEffect;
Simplified implementation
import { useRef, useEffect } from 'react';
function MyComp(props) {
const firstRender = useRef(true);
useEffect(() => {
if (firstRender.current) {
firstRender.current = false;
} else {
myProp = 'some val';
};
}, [props.myProp])
return (
<div>
...
</div>
)
}
You can use custom hook to run use effect after mount.
const useEffectAfterMount = (cb, dependencies) => {
const mounted = useRef(true);
useEffect(() => {
if (!mounted.current) {
return cb();
}
mounted.current = false;
}, dependencies); // eslint-disable-line react-hooks/exhaustive-deps
};
Here is the typescript version:
const useEffectAfterMount = (cb: EffectCallback, dependencies: DependencyList | undefined) => {
const mounted = useRef(true);
useEffect(() => {
if (!mounted.current) {
return cb();
}
mounted.current = false;
}, dependencies); // eslint-disable-line react-hooks/exhaustive-deps
};
For people who are having trouble with React 18 strict mode calling the useeffect on the initial render twice, try this:
// The init variable is necessary if your state is an object/array, because the == operator compares the references, not the actual values.
const init = [];
const [state, setState] = useState(init);
const dummyState = useRef(init);
useEffect(() => {
// Compare the old state with the new state
if (dummyState.current == state) {
// This means that the component is mounting
} else {
// This means that the component updated.
dummyState.current = state;
}
}, [state]);
Works in development mode...
function App() {
const init = [];
const [state, setState] = React.useState(init);
const dummyState = React.useRef(init);
React.useEffect(() => {
if (dummyState.current == state) {
console.log('mount');
} else {
console.log('update');
dummyState.current = state;
}
}, [state]);
return (
<button onClick={() => setState([...state, Math.random()])}>Update state </button>
);
}
ReactDOM.createRoot(document.getElementById("app")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
<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>
And in production.
function App() {
const init = [];
const [state, setState] = React.useState(init);
const dummyState = React.useRef(init);
React.useEffect(() => {
if (dummyState.current == state) {
console.log('mount');
} else {
console.log('update');
dummyState.current = state;
}
}, [state]);
return (
<button onClick={() => setState([...state, Math.random()])}>Update state </button>
);
}
ReactDOM.createRoot(document.getElementById("app")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
<script crossorigin src="https://unpkg.com/react#18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom#18/umd/react-dom.production.min.js"></script>
<div id="app"></div>
If you want to skip the first render, you can create a state "firstRenderDone" and set it to true in the useEffect with empty dependecy list (that works like a didMount). Then, in your other useEffect, you can check if the first render was already done before doing something.
const [firstRenderDone, setFirstRenderDone] = useState(false);
//useEffect with empty dependecy list (that works like a componentDidMount)
useEffect(() => {
setFirstRenderDone(true);
}, []);
// your other useEffect (that works as componetDidUpdate)
useEffect(() => {
if(firstRenderDone){
console.log("componentDidUpdateFunction");
}
}, [firstRenderDone]);
All previous are good, but this can be achieved in a simplier way considering that the action in useEffect can be "skipped" placing an if condition(or any other ) that is basically not run first time, and still with the dependency.
For example I had the case of :
Load data from an API but my title has to be "Loading" till the date were not there, so I have an array, tours that is empty at beginning and show the text "Showing"
Have a component rendered with different information from those API.
The user can delete one by one those info, even all making the tour array empty again as the beginning but this time the API fetch is been already done
Once the tour list is empty by deleting then show another title.
so my "solution" was to create another useState to create a boolean value that change only after the data fetch making another condition in useEffect true in order to run another function that also depend on the tour length.
useEffect(() => {
if (isTitle) {
changeTitle(newTitle)
}else{
isSetTitle(true)
}
}, [tours])
here my App.js
import React, { useState, useEffect } from 'react'
import Loading from './Loading'
import Tours from './Tours'
const url = 'API url'
let newTours
function App() {
const [loading, setLoading ] = useState(true)
const [tours, setTours] = useState([])
const [isTitle, isSetTitle] = useState(false)
const [title, setTitle] = useState("Our Tours")
const newTitle = "Tours are empty"
const removeTours = (id) => {
newTours = tours.filter(tour => ( tour.id !== id))
return setTours(newTours)
}
const changeTitle = (title) =>{
if(tours.length === 0 && loading === false){
setTitle(title)
}
}
const fetchTours = async () => {
setLoading(true)
try {
const response = await fetch(url)
const tours = await response.json()
setLoading(false)
setTours(tours)
}catch(error) {
setLoading(false)
console.log(error)
}
}
useEffect(()=>{
fetchTours()
},[])
useEffect(() => {
if (isTitle) {
changeTitle(newTitle)
}else{
isSetTitle(true)
}
}, [tours])
if(loading){
return (
<main>
<Loading />
</main>
)
}else{
return (
<main>
<Tours tours={tours} title={title} changeTitle={changeTitle}
removeTours={removeTours} />
</main>
)
}
}
export default App
const [dojob, setDojob] = useState(false);
yourfunction(){
setDojob(true);
}
useEffect(()=>{
if(dojob){
yourfunction();
setDojob(false);
}
},[dojob]);

window.removeEventListener not working in React

I am trying to block tab refresh/closing when a user is editing the page. This is stored in state. I have a useEffect that is triggered whenever the isEditing state changes:
const [isEditing, setIsEditing] = useState<boolean>(false);
const handleBrowserCloseReload = (e: any) => {
e.preventDefault();
return (e.returnValue = '');
};
useEffect(() => {
if (isEditing) {
window.addEventListener('beforeunload', handleBrowserCloseReload);
} else {
console.log('remove');
window.removeEventListener('beforeunload', handleBrowserCloseReload);
}
}, [isEditing]);
The problem is even when the 'remove' is logged to the console, I still get the prompt to save changes. Another question is does anyone know the type of the event for this? I do not want to leave it as "any"
Don't bother with an else case, just return a "cleanup" function that removes the handler. When the component re-renders, the cleanup function will run. If you only attach when isEditing is true, then when it is false it won't get added. Plus you have the benefit that if that component unmounts but the page isn't unloaded, the cleanup will also run.
Just make sure to define your handleBrowserCloseReload handler within the useEffect hook so you can reuse the reference.
const [isEditing, setIsEditing] = useState<boolean>(false);
useEffect(() => {
const handleBrowserCloseReload = (e: any) => {
e.preventDefault();
return (e.returnValue = '');
};
if (isEditing) {
window.addEventListener('beforeunload', handleBrowserCloseReload);
}
return () => {
console.log('remove');
window.removeEventListener('beforeunload', handleBrowserCloseReload);
};
}, [isEditing]);
Anyone finding this similar issue, it was fixed with memoizing the handleBrowserCloseReload function:
const handleBrowserCloseReload = useMemo(() => {
return (e: any) => {
e.preventDefault();
return (e.returnValue = '');
};
}, []);

Keydown/up events with React Hooks not working properly

I'm trying to create arrow based keyboard controls for a game I'm working on. Of course I'm trying to stay up to date with React so I wanted to create a function component and use hooks. I've created a JSFiddle for my buggy component.
It's almost working as expected, except when I press a lot of the arrow keys at the same time. Then it seems like some keyup events aren't triggered. It could also be that the 'state' is not updated properly.
Which I do like this:
const ALLOWED_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']
const [pressed, setPressed] = React.useState([])
const handleKeyDown = React.useCallback(event => {
const { key } = event
if (ALLOWED_KEYS.includes(key) && !pressed.includes(key)) {
setPressed([...pressed, key])
}
}, [pressed])
const handleKeyUp = React.useCallback(event => {
const { key } = event
setPressed(pressed.filter(k => k !== key))
}, [pressed])
React.useEffect(() => {
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('keyup', handleKeyUp)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('keyup', handleKeyUp)
}
})
I have the idea that I'm doing it correctly, but being new to hooks it is very likely that this is where the problem is. Especially since I've re-created the same component as a class based component:
https://jsfiddle.net/vus4nrfe/
And that seems to work fine...
There are 3 key things to do to make it work as expected just like your class component.
As others mentioned for useEffect you need to add an [] as a dependency array which will trigger only once the addEventLister functions.
The second thing which is the main issue is that you are not mutating the pressed array's previous state in functional component as you did in class component, just like below:
// onKeyDown event
this.setState(prevState => ({
pressed: [...prevState.pressed, key],
}))
// onKeyUp event
this.setState(prevState => ({
pressed: prevState.pressed.filter(k => k !== key),
}))
You need to update in functional one as the following:
// onKeyDown event
setPressedKeys(previousPressedKeys => [...previousPressedKeys, key]);
// onKeyUp event
setPressedKeys(previousPressedKeys => previousPressedKeys.filter(k => k !== key));
The third thing is that the definition of the onKeyDown and onKeyUp events have been moved inside of useEffect so you don't need to use useCallback.
The mentioned things solved the issue on my end. Please find the following working GitHub repository what I've made which works as expected:
https://github.com/norbitrial/react-keydown-useeffect-componentdidmount
Find a working JSFiddle version if you like it better here:
https://jsfiddle.net/0aogqbyp/
The essential part from the repository, fully working component:
const KeyDownFunctional = () => {
const [pressedKeys, setPressedKeys] = useState([]);
useEffect(() => {
const onKeyDown = ({key}) => {
if (Consts.ALLOWED_KEYS.includes(key) && !pressedKeys.includes(key)) {
setPressedKeys(previousPressedKeys => [...previousPressedKeys, key]);
}
}
const onKeyUp = ({key}) => {
if (Consts.ALLOWED_KEYS.includes(key)) {
setPressedKeys(previousPressedKeys => previousPressedKeys.filter(k => k !== key));
}
}
document.addEventListener('keydown', onKeyDown);
document.addEventListener('keyup', onKeyUp);
return () => {
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('keyup', onKeyUp);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <>
<h3>KeyDown Functional Component</h3>
<h4>Pressed Keys:</h4>
{pressedKeys.map(e => <span key={e} className="key">{e}</span>)}
</>
}
The reason why I'm using // eslint-disable-next-line react-hooks/exhaustive-deps for the useEffect is because I don't want to reattach the events every single time once the pressed or pressedKeys array is changing.
I hope this helps!
User #Vencovsky mentioned the useKeyPress recipe by Gabe Ragland. Implementing this made everything work as expected. The useKeyPress recipe:
// Hook
const useKeyPress = (targetKey) => {
// State for keeping track of whether key is pressed
const [keyPressed, setKeyPressed] = React.useState(false)
// If pressed key is our target key then set to true
const downHandler = ({ key }) => {
if (key === targetKey) {
setKeyPressed(true)
}
}
// If released key is our target key then set to false
const upHandler = ({ key }) => {
if (key === targetKey) {
setKeyPressed(false)
}
}
// Add event listeners
React.useEffect(() => {
window.addEventListener('keydown', downHandler)
window.addEventListener('keyup', upHandler)
// Remove event listeners on cleanup
return () => {
window.removeEventListener('keydown', downHandler)
window.removeEventListener('keyup', upHandler)
}
}, []) // Empty array ensures that effect is only run on mount and unmount
return keyPressed
}
You can then use that "hook" as follows:
const KeyboardControls = () => {
const isUpPressed = useKeyPress('ArrowUp')
const isDownPressed = useKeyPress('ArrowDown')
const isLeftPressed = useKeyPress('ArrowLeft')
const isRightPressed = useKeyPress('ArrowRight')
return (
<div className="keyboard-controls">
<div className={classNames('up-button', isUpPressed && 'pressed')} />
<div className={classNames('down-button', isDownPressed && 'pressed')} />
<div className={classNames('left-button', isLeftPressed && 'pressed')} />
<div className={classNames('right-button', isRightPressed && 'pressed')} />
</div>
)
}
Complete fiddle can be found here.
The difference with my code is that it use hooks and state per key instead of all the keys at once. I'm not sure why that would matter though. Would be great if somebody could explain that.
Thanks to everyone who tried to help and made the hooks concept clearer for me. And thanks for #Vencovsky for pointing me to the usehooks.com website by Gabe Ragland.
React.useEffect(() => {
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('keyup', handleKeyUp)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('keyup', handleKeyUp)
}
}, [handleKeyDown, handleKeyUp]); // <---- Add this deps array
You need to add the handlers as dependencies to the useEffect, otherwise it gets called on every render.
Also, make sure your deps array is not empty [], because your handlers could change based on the value of pressed.
All the solutions I found were pretty bad. For instance, the solutions in this thread only allow you to hold down 2 buttons, or they simply don't work like a lot of the use-hooks libraries.
After working on this for a long time with #asafaviv from #Reactiflux I think this is my favorite solution:
import { useState, useLayoutEffect } from 'react'
const specialKeys = [
`Shift`,
`CapsLock`,
`Meta`,
`Control`,
`Alt`,
`Tab`,
`Backspace`,
`Escape`,
]
const useKeys = () => {
if (typeof window === `undefined`) return [] // Bail on SSR
const [keys, setKeys] = useState([])
useLayoutEffect(() => {
const downHandler = ({ key, shiftKey, repeat }) => {
if (repeat) return // Bail if they're holding down a key
setKeys(prevKeys => {
return [...prevKeys, { key, shiftKey }]
})
}
const upHandler = ({ key, shiftKey }) => {
setKeys(prevKeys => {
return prevKeys.filter(k => {
if (specialKeys.includes(key))
return false // Special keys being held down/let go of in certain orders would cause keys to get stuck in state
return JSON.stringify(k) !== JSON.stringify({ key, shiftKey }) // JS Objects are unique even if they have the same contents, this forces them to actually compare based on their contents
})
})
}
window.addEventListener(`keydown`, downHandler)
window.addEventListener(`keyup`, upHandler)
return () => {
// Cleanup our window listeners if the component goes away
window.removeEventListener(`keydown`, downHandler)
window.removeEventListener(`keyup`, upHandler)
}
}, [])
return keys.map(x => x.key) // return a clean array of characters (including special characters 🎉)
}
export default useKeys
I believe you're Breaking the Rules of Hooks:
Do not call Hooks inside functions passed to useMemo, useReducer, or useEffect.
You're calling the setPressed hook inside a function passed to useCallback, which basically uses useMemo under the hood.
useCallback(fn, deps) is equivalent to useMemo(() => fn, deps).
https://reactjs.org/docs/hooks-reference.html#usecallback
See if removing the useCallback in favor of a plain arrow function solves your problem.
useEffect runs on every render, resulting on adding/removing your listeners on each keypress. This could potential lead to a key press/release without a listener attached.
Suppling an empty array [] as second parameter to useEffect, React will know that this effect does not depend on any of the props/state values so it never needs to re-run, attaching and cleaning up your listeners once
React.useEffect(() => {
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('keyup', handleKeyUp)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('keyup', handleKeyUp)
}
}, [])

Categories