Keydown/up events with React Hooks not working properly - javascript

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)
}
}, [])

Related

How to add a keydown Event Listener for a react functional component

I'm trying to 'move' in my 10x10 grid by updating the activeCellId state. However none of the methods I tried works. This is my code.
const GridCells: React.FC = () => {
const gridArray = [...Array(100).keys()];
const color = [
"bg-slate-50",
"bg-slate-100",
"bg-slate-200",
"bg-slate-300",
"bg-slate-400",
"bg-slate-500",
"bg-slate-600",
"bg-slate-700",
"bg-slate-800",
"bg-slate-900",
];
const [activeCellId, setActiveCellId] = useState(42);
// useEffect(() => {
// document.addEventListener("keydown", updateActiveCellId, false);
// }, []); // this doesn't work. the activeCellId is only incremented once, and afterwards the setActiveCellId doesn't get called at all
const updateActiveCellId = (e: React.KeyboardEvent) => {
// will eventually be a switch case logic here, for handling arrow up, left, right down
console.log(activeCellId);
setActiveCellId(activeCellId + 1);
};
return (
<div
className="grid-rows-10 grid grid-cols-10 gap-0.5"
// onKeyDown={updateActiveCellId} this also doesn't work
>
{gridArray.map((value, id) => {
const colorId = Math.floor(id / 10);
return (
<div
key={id}
className={
"h-10 w-10 "
+ color[colorId]
+ (id === activeCellId ? " scale-125 bg-yellow-400" : "")
}
>
{id}
</div>
);
})}
</div>
);
};
I'm trying to update a state in the react component by pressing certain keys. I've tried UseEffect with [] dep array and tried onKeyDown and it also doesn't work. I also tried following this useRef way it doesn't work too.
const innerRef = useRef(null);
useEffect(() => {
const div = innerRef.current;
div.addEventListener("keydown", updateActiveCellId, false);
}, []); // this doesn't work at all
const updateActiveCellId = (e: React.KeyboardEvent) => {
console.log(activeCellId);
setActiveCellId(activeCellId + 1);
};
return (
<div
className="grid-rows-10 grid grid-cols-10 gap-0.5"
ref={innerRef}
>
...
)
Try this:
useEffect(() => {
document.addEventListener("keydown", updateActiveCellId, false);
return () => {
document.removeEventListener("keydown", updateActiveCellId, false);
}
}, [activeCellId]);
The [activeCellId] is the dependency of useEffect. Everytimes activeCellId changes, the function inside useEffect will run.
You had an empty dependency, so it ran on initial component mount only.
The returned function containing removeEventListner is executed when the component unmounts (See cleanup function in the docs). That is to ensure you have only one event listener runnign at once.
Documentation

Value of variable outside of useEffect hook has old data

What the code does: It's performing a DOM search based on what's typed in an input (it's searching elements by text). All this is happening in a React component.
import { useEffect, useReducer } from "react";
let elements: any[] = [];
const App = () => {
const initialState = { keyEvent: {}, value: "Initial state" };
const [state, updateState] = useReducer(
(state: any, updates: any) => ({ ...state, ...updates }),
initialState
);
function handleInputChange(event: any) {
updateState({ value: event.target.value });
}
function isCommand(event: KeyboardEvent) {
return event.ctrlKey;
}
function handleDocumentKeyDown(event: any) {
if (isCommand(event)) {
updateState({ keyEvent: event });
}
}
useEffect(() => {
document.addEventListener("keydown", handleDocumentKeyDown);
return () => {
document.removeEventListener("keydown", handleDocumentKeyDown);
};
}, []);
useEffect(() => {
const selectors = "button";
const pattern = new RegExp(state.value === "" ? "^$" : state.value);
elements = Array.from(document.querySelectorAll(selectors)).filter(
(element) => {
if (element.childNodes) {
const nodeWithText = Array.from(element.childNodes).find(
(childNode) => childNode.nodeType === Node.TEXT_NODE
);
if (nodeWithText) {
// The delay won't happenn if you comment out this conditional statement:
if (nodeWithText.textContent?.match(pattern)) {
return element;
}
}
}
}
);
console.log('elements 1:', elements)
}, [state]);
console.log('elemets 2:', elements)
return (
<div>
<input
id="input"
type="text"
onChange={handleInputChange}
value={state.value}
/>
<div id="count">{elements.length}</div>
<button>a</button>
<button>b</button>
<button>c</button>
</div>
);
};
export default App;
The problem: The value of elements outside of useEffect is the old data. For example, if you type a in the input, console.log('elements 1:', elements) will log 1, and console.log('elements 2:', elements) will log 0. Note: there are 3 buttons, and one of them has the text a.
The strange thing is that the problem doesn't happen if you comment out this if-statement:
// The delay won't happenn if you comment out this conditional statement:
if (nodeWithText.textContent?.match(pattern)) {
return element;
}
In this case, if you type anything (since the pattern matching has been commented out), console.log('elements 1:', elements) and console.log('elements 2:', elements) will log 3. Note: there are 3 buttons.
Question: What could be the problem, and how to fix it? I want to render the current length of elements.
Live code:
It's happening because of the elements variable is not a state, so it's not reactive.
Create a state for the elements:
const [elements, setElements] = useState<HTMLButtonElement[]>([])
And use this state to handle the elements.
import { useEffect, useReducer, useState } from "react";
const App = () => {
const initialState = { keyEvent: {}, value: "Initial state" };
const [state, updateState] = useReducer(
(state: any, updates: any) => ({ ...state, ...updates }),
initialState
);
const [elements, setElements] = useState<HTMLButtonElement[]>([])
function handleInputChange(event: any) {
updateState({ value: event.target.value });
}
function isCommand(event: KeyboardEvent) {
return event.ctrlKey;
}
function handleDocumentKeyDown(event: any) {
if (isCommand(event)) {
updateState({ keyEvent: event });
}
}
useEffect(() => {
document.addEventListener("keydown", handleDocumentKeyDown);
return () => {
document.removeEventListener("keydown", handleDocumentKeyDown);
};
}, []);
useEffect(() => {
const selectors = "button";
const pattern = new RegExp(state.value === "" ? "^$" : state.value);
let newElements = Array.from(document.querySelectorAll(selectors)).filter(
(element) => {
if (element.childNodes) {
const nodeWithText = Array.from(element.childNodes).find(
(childNode) => childNode.nodeType === Node.TEXT_NODE
);
if (nodeWithText) {
// The delay won't happenn if you comment out this conditional statement:
if (nodeWithText.textContent?.match(pattern)) {
return element;
}
}
}
}
);
setElements(newElements)
console.log("elements 1:", elements?.length);
}, [state]);
console.log("elemets 2:", elements?.length);
return (
<div>
<input
id="input"
type="text"
onChange={handleInputChange}
value={state.value}
/>
<div id="count">{elements?.length}</div>
<button>a</button>
<button>b</button>
<button>c</button>
</div>
);
};
export default App;
Your useEffect() runs after your component has rendendered. So the sequence is:
You type something into input, that triggers handleInputChange
handleInputChange then updates your state using updateState()
The state update causes a rerender, so App is called App()
console.log('elemets 2:', elements.length) runs and logs elements as 0 as it's still empty
App returns the new JSX
Your useEffect() callback runs, updating elements
Notice how we're only updating the elements after you've rerendered and App has been called.
The state of your React app should be used to describe your UI in React. Since elements isn't React state, it has a chance of becoming out of sync with the UI (as you've seen), whereas using state doesn't have this issue as state updates always trigger a UI update. Consider making elements part of your state. If it needs to be accessible throughout your entire App, you can pass it down as props to children components, or use context to make it accessible throughout all your components.
With that being said, I would make the following updates:
Add elements to your state
Remove your useEffect() with the dependency of [state]. If we were to update the elements state within this effect, then that would trigger another rerender directly after the one we just did for the state update. This isn't efficient, and instead, we can tie the update directly to your event handler. See You Might Not Need an Effect for more details and dealing with other types of scenarios:
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/#babel/standalone/babel.min.js"></script>
<script type="text/babel">
const { useEffect, useReducer} = React;
const App = () => {
const initialState = {keyEvent: {}, value: "Initial state", elements: []};
const [state, updateState] = useReducer(
(state: any, updates: any) => ({ ...state, ...updates}),
initialState
);
function searchDOM(value) {
const selectors = "button";
const pattern = new RegExp(value === "" ? "^$" : value);
return Array.from(document.querySelectorAll(selectors)).filter(
(element) => {
if (element.childNodes) {
const nodeWithText = Array.from(element.childNodes).find(
(childNode) => childNode.nodeType === Node.TEXT_NODE
);
return nodeWithText?.textContent?.match(pattern);
}
return false;
}
);
}
function handleInputChange(event) {
updateState({
value: event.target.value,
elements: searchDOM(event.target.value)
});
}
function isCommand(event) {
return event.ctrlKey;
}
function handleDocumentKeyDown(event) {
if (isCommand(event)) {
updateState({
keyEvent: event
});
}
}
useEffect(() => {
document.addEventListener("keydown", handleDocumentKeyDown);
return () => {
document.removeEventListener("keydown", handleDocumentKeyDown);
};
}, []);
console.log("elements:", state.elements.length);
return (
<div>
<input id="input" type="text" onChange={handleInputChange} value={state.value} />
<div id="count">{state.elements.length}</div>
<button>a</button>
<button>b</button>
<button>c</button>
</div>
);
};
ReactDOM.createRoot(document.body).render(<App />);
</script>
useEffect triggered after react completed its render phase & flush the new changes to the DOM.
In your case you have two useEffects. The first one register your event lister which will then update your component state when input field change. This triggers a state update.( because of the setState )
So React will start render the component again & finish the cycle. And now you have 2nd useEffect which has state in dependency array. Since the state was updated & the new changes are committed to the DOM, react will execute 2nd useEffect logic.
Since your 2nd useEffect just assign some values to a normal variable React will not go re render your component again.
Based on your requirement you don't need a 2nd useEffect. You can use a useMemo,
let elements = useMemo(() => {
const selectors = "button";
const pattern = new RegExp(state.value === "" ? "^$" : state.value);
return Array.from(document.querySelectorAll(selectors)).filter(
(element) => {
if (element.childNodes) {
const nodeWithText = Array.from(element.childNodes).find(
(childNode) => childNode.nodeType === Node.TEXT_NODE
);
if (nodeWithText) {
// The delay won't happenn if you comment out this conditional statement:
if (nodeWithText.textContent?.match(pattern)) {
return element;
}
}
}
})
}, [state])
Note: You don't need to assign your elements into another state. It just create another unwanted re render in cycle. Since you are just doing a calculation to find out the element array you can do it with the useMemo

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 = '');
};
}, []);

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

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

React useState doesn't update in window events

State does get set on the scroll, but logged from the eventlistener, it seems to be stuck at the initial value.
I guess it's something to do with scrolling being set when the side effect's defined, but how could I trigger a state change from a scroll otherwise? Same goes for any window event I presume.
Here's a codesandbox example: https://codesandbox.io/s/react-test-zft3e
const [scrolling, setScrolling] = useState(false);
useEffect(() => {
window.addEventListener("scroll", () => {
console.log(scrolling);
if (scrolling === false) setScrolling(true);
});
}, []);
return (
<>
scrolling: {scrolling}
</>
);
So your anonymous function is locked on initial value of scrolling. It's how closures works in JS and you better find out some pretty article on that, it may be tricky some time and hooks heavily rely on closures.
So far there are 3 different solutions here:
1. Recreate and re-register handler on each change
useEffect(() => {
const scrollHandler = () => {
if (scrolling === false) setScrolling(true);
};
window.addEventListener("scroll", scrollHandler);
return () => window.removeEventListener("scroll", scrollHandler);
}, [scrolling]);
while following this path ensure your are returning cleanup function from useEffect. It's good default approach but for scrolling it may affect performance because scroll event triggers too often.
2. Access data by reference
const scrolling = useRef(false);
useEffect(() => {
const handler = () => {
if (scrolling.current === false) scrolling.current = true;
};
window.addEventListener("scroll", handler);
return () => window.removeEventListener("scroll", handler);
}, []);
return (
<>
scrolling: {scrolling}
</>
);
downside: changing ref does not trigger re-render. So you need to have some other variable to change it triggering re-render.
3. Use functional version of setter to access most recent value
(I see it as preferred way here):
useEffect(() => {
const scrollHandler = () => {
setScrolling((currentScrolling) => {
if (!currentScrolling) return true;
return false;
});
};
window.addEventListener("scroll", scrollHandler);
return () => window.removeEventListener("scroll", scrollHandler);
}, []);
Note Btw even for one-time use effect you better return cleanup function anyway.
PS Also by now you don't set scrolling to false, so you could just get rid of condition if(scrolling === false), but sure in real world scenario you may also run into something alike.
The event listener callback is only initialized once
This means that the variable at that moment are also "trapped" at that point, since on rerender you're not reinitializing the event listener.
It's kind of like a snapshot of the on mount moment.
If you move the console.log outside you will see it change as the rerenders happen and set the scroll value again.
const [scrolling, setScrolling] = useState(false);
useEffect(() => {
window.addEventListener("scroll", () => {
if (scrolling === false) setScrolling(true);
});
}, []);
console.log(scrolling);
return (
<>
scrolling: {scrolling}
</>
);
A solution that has personally served me well when I need to access a state (getState and setState) in an eventListener, without having to create a reference to that state (or all the states it has), is to use the following custom hook:
export function useEventListener(eventName, functionToCall, element) {
const savedFunction = useRef();
useEffect(() => {
savedFunction.current = functionToCall;
}, [functionToCall]);
useEffect(() => {
if (!element) return;
const eventListener = (event) => savedFunction.current(event);
element.addEventListener(eventName, eventListener);
return () => {
element.removeEventListener(eventName, eventListener);
};
}, [eventName, element]);
}
What I do is make a reference to the function to be called in the eventListener. in the component where I need an eventLister, it will look like this:
useEventListener("mousemove", getAndSetState, myRef.current); //myRef.current can be directly the window object
function getAndSetState() {
setState(state + 1);
}
I leave a codesandbox with a more complete code

Categories