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
Related
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
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 = '');
};
}, []);
In my React project, I have a scroll event listener that checks when the user scrolls passed 590, setting state to true. This seems to work fine however I only want the function associated with the to run once so I'm adding removeEventListener as seen below, however the event continues to fire.
How do I appropriately remove this event listener?
useEffect(() => {
const handleNavSlide = () => {
if (window.scrollY > 590) {
setIsOpen(String(true))
setTimeout(() => {
setIsOpen(String(false))
}, 2000)
}
}
window.addEventListener("scroll", handleNavSlide)
return () => window.removeEventListener("scroll", handleNavSlide)
}, [])
This will happen because as per your dependency array which is [] here, your listener will only get removed when the component unmounts. For the once behaviour, change your implementation like so :-
useEffect(() => {
const handleNavSlide = () => {
if (window.scrollY > 590) {
setIsOpen(String(true))
setTimeout(() => {
setIsOpen(String(false))
}, 2000)
window.removeEventListener("scroll", handleNavSlide)
}
}
window.addEventListener("scroll", handleNavSlide)
}, [])
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)
}
}, [])
Conditional rendering of components based on window.innerWidth seems to not work as intended just in the production build of Gatsby based website.
The hook I am using to check the viewport's width, with the additional check for the window global to avoid Gatsby-node production build errors, is the following:
import { useState, useEffect } from 'react'
const useWindowWidth = () => {
const windowGlobal = typeof window !== 'undefined'
if(windowGlobal) {
const [width, setWidth] = useState(window.innerWidth)
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth)
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
})
return width
}
}
export default useWindowWidth
Then in my actual component I do the following:
IndexPage.Booking = () => {
const windowWidth = useWindowWidth()
return (
<div className="section__booking__wrapper">
{ windowWidth <= mediaQueries.lg && <IndexPage.Cta /> }
<div className="section__booking-bg" style={{ backgroundImage: `url(${bg})` }}>
{ windowWidth > mediaQueries.lg && <IndexPage.Cta /> }
</div>
</div>
)
}
It works as it should in development but the production build fails to render:
<div className="section__booking-bg" style={{ backgroundImage: `url(${bg})` }}>
When resizing the window below the mediaQueries.lg (1024) it then triggers the actual normal behaviour or conditionally rendering mobile and desktop versions of the component.
To doublecheck if it was because the render triggers on just the resize event (which it doesn't as it works on load in development environment) I also simply, from within the hook console.log() the return value and it gets printed, in production correctly on load.
There are also no errors or warnings in the production or development build whatsoever.
Edit as per #Phillip 's suggestion
const useWindowWidth = () => {
const isBrowser = typeof window !== 'undefined'
const [width, setWidth] = useState(isBrowser ? window.innerWidth : 0)
useEffect(() => {
if (!isBrowser) return false
const handleResize = () => setWidth(window.innerWidth)
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
})
return width
}
It now works just when you resize it, once, under the mediaQueries.lg threshold and then it works flawlessly across desktop and mobile but not on load.
I had a similar problem to this and haven't found a solution, but a work around. Put the following at the start of your render:
if (typeof window === `undefined`) {
return(<></>);
}
What I think is happening is that Gatsby is building the page with a style based off the window width (which will be 0 / undefined). Then it's not updating the style in the DOM once the page loads as it thinks it has already performed that action. I think this is a small bug in Gatsby maybe?
Either way, the above renders your component blank during the build, forcing it to fully respect all logic when the page loads. Hopefully that provides a solution albeit not a satisfying/complete explanation :)
I'm guessing it is too late to answer but calling handleResize before adding the event listener should work. Here is a code I used for same purpose:
useEffect(() => {
setWidth(window.innerWidth);
window.addEventListener("resize", () => {
setWidth(window.innerWidth);
});
return () => {
window.removeEventListener("resize", () => {});
};
}, []);
Don’t call Hooks inside loops, conditions, or nested functions (from React docs)
React Hooks must run in the exact same order on every render. Move your condition into the useEffect callback:
useEffect(() => {
if (typeof window === 'undefined') return;
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize)
};
});