I'm trying to get increased item inside updateItem handler whenever I click on window. It appears updated on the screen, but what is strange is that only initial value 0 appears inside updateItem event handler.
const TodoApp = () => {
const [item, setItem] = React.useState(0);
const updateItem = () => {
console.log(item); // always prints 0
setItem((val) => val + 1);
};
React.useEffect(() => {
window.addEventListener('click', updateItem);
return () => {
window.removeEventListener('click', updateItem);
};
}, []);
return (
<div>
<h2>{item}</h2>
</div>
)
}
What is the main problem?
Thank you.
I found the reason on my side.
Any value being used inside event handler gets its value from the closure where it was defined. So it only appears as its initial value.
The solution is to subscribe the event handler again whenever the state is changed,
like this:
React.useEffect(() => {
window.addEventListener('click', updateItem);
return () => {
window.removeEventListener('click', updateItem);
};
}, [item]);
There's also another way, using ref because it always keeps the latest value.
Related
I'm trying to build an infinite scroll component in React (specifically using NextJS). I am having trouble with this feature because when I set a scroll event on the window, it doesn't have access to updated state. How can I write a scroll event that listens to any scrolling on the entire window that also has access to state like router query params?
Here's some code to see what I'm trying to do:
useEffect(() => {
window.addEventListener('scroll', handleScroll);
},[]);
const handleScroll = () => {
const el = infiniteScroll.current;
if (el) {
const rect = el.getBoundingClientRect();
const isVisible =
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <=
(window.innerHeight || document.documentElement.clientHeight) &&
rect.right <=
(window.innerWidth || document.documentElement.clientWidth);
if (isVisible && !isComplete && !isFetching) {
nextPage();
}
}
};
const nextPage = () => {
const params = router.query as any; // <------ these params here never update with state and are locked in to the the values they were at when the component mounted
params.page = params.page
? (parseInt((params as any).page) + 1).toString()
: '1';
router.replace(router, undefined, { scroll: false });
};
The issue is that the router value is locked at the place it was when the component mounted.
I've tried removing the empty array of dependencies for the useEffect at the top, but as you can imagine, this creates multiple scroll listeners and my events fire too many times. I've tried removing the eventListener before adding it every time, but it still fires too many times.
Every example I've found online seems to not need access to state variables, so they write code just like this and it works for them.
Any ideas how I can implement this?
I've tried to use the onScroll event, but it doesn't work unless you have a fixed height on the container so that you can use overflow-y: scroll.
You can use a ref to access and modify your state in the scope of the handleScroll function.
Here is how:
const yourRef = useRef('foo');
useEffect(() => {
const handleScroll = () => {
const value = yourRef.current;
if (value === 'foo') {
yourRef.current = 'bar'
}
};
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);
I figured something out that works. Posting in case anyone else is having the same issue.
I created a custom hook called useScrollPosition that sets a listener on the window and updates the scroll position. It looks like this:
const useScrollPosition = () => {
const [scrollPosition, setScrollPosition] = useState(0);
useEffect(() => {
const updatePosition = () => {
setScrollPosition(window.pageYOffset);
};
window.addEventListener('scroll', updatePosition);
updatePosition();
return () => window.removeEventListener('scroll', updatePosition);
}, []);
return scrollPosition;
};
and using that in my component like this:
useEffect(() => {
handleScroll();
}, [scrollPosition]);
allows me to access the current state of the router
So i have this hook that does something every time the document is clicked. but i want it to exclude a reference to the dom node of my choosing.
const useClickOutside = (cb: () => void) => {
const domNodeRef: any = useRef(null);
useEffect(() => {
const handler = (event: Event) => {
console.log("target: ", event.target);
if (domNodeRef.current && !domNodeRef.current?.contains(event.target))
cb();
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [cb]);
return domNodeRef;
};
export default useClickOutside;
but for some reason every time it reaches
if (domNodeRef.current && !domNodeRef.current?.contains(event.target))
it does not work as expected. it works on some places and it doesn't work on many places and i have checked every event.target wherever i click and i never get an unexpected DOM node.
yet my condition does not work as expected... but when i grab each element form the Elements tab in chrome and write the condition in the chrome console it always behaves as expected.
Your custom hook takes a single value which is the callback so it doesn't know what element you want to exclude. You need to pass a second argument ref and wherever you use that hook you need to pass it the ref of the element you want to exclude. Here is a working example (Although not in TS)
import { useEffect } from "react";
export const useOnClickOutside = (ref, handleClick) => {
useEffect(() => {
const listener = (event) => {
if (ref.current && !ref.current.contains(event.target)) {
handleClick(event);
}
};
document.addEventListener("mousedown", listener);
document.addEventListener("touchstart", listener);
return () => {
document.removeEventListener("mousedown", listener);
document.removeEventListener("touchstart", listener);
};
}, [ref, handleClick]);
};
export default useOnClickOutside;
In the example below doSomething() will trigger any time you click anywhere except for the referenced button
import { useRef } from 'react';
const Component = () => {
const btnRef = useRef();
useOnClickOutside(btnRef, () => doSomething());
return(
<button ref={btnRef}>
Referenced Button
</button>
);
}
So I am writing a product prototype in create-react-app, and in my App.js, inside the app() function, I have:
const [showCanvas, setShowCanvas] = useState(true)
This state is controlled by a button with an onClick function; And then I have a function, inside it, the detectDots function should be ran in an interval:
const runFaceDots = async (key, dot) => {
const net = await facemesh.load(...);
setInterval(() => {
detectDots(net, key, dot);
}, 10);
// return ()=>clearInterval(interval);};
And the detectDots function works like this:
const detectDots = async (net, key, dot) => {
...
console.log(showCanvas);
requestFrame(()=>{drawDots(..., showCanvas)});
}
}};
I have a useEffect like this:
useEffect(()=>{
runFaceDots(); return () => {clearInterval(runFaceDots)}}, [showCanvas])
And finally, I can change the state by clicking these two buttons:
return (
...
<Button
onClick={()=>{setShowCanvas(true)}}>
Show Canvas
</Button>
<Button
onClick={()=> {setShowCanvas(false)}}>
Hide Canvas
</Button>
...
</div>);
I checked a few posts online, saying that not clearing interval would cause state loss. In my case, I see some strange behaviour from useEffect: when I use onClick to setShowCanvas(false), the console shows that console.log(showCanvas) keeps switching from true to false back and forth.
a screenshot of the console message
you can see initially, the showCanvas state was true, which makes sense. But when I clicked the "hide canvas" button, and I only clicked it once, the showCanvas was set to false, and it should stay false, because I did not click the "show canvas" button.
I am very confused and hope someone could help.
Try using useCallback for runFaceDots function - https://reactjs.org/docs/hooks-reference.html#usecallback
And ensure you return the setInterval variable to clear the timer.
const runFaceDots = useCallback(async (key, dot) => {
const net = await facemesh.load(...);
const timer = setInterval(() => {
detectDots(net, key, dot);
}, 10);
return timer //this is to be used for clearing the interval
},[showCanvas])
Then change useEffect to this - running the function only if showCanvas is true
useEffect(()=>{
if (showCanvas) {
const timer = runFaceDots();
return () => {clearInterval(timer)}
}
}, [showCanvas])
Update: Using a global timer
let timer // <-- create the variable outside the component.
const MyComponent = () => {
.....
useEffect(()=>{
if (showCanvas) {
runFaceDots(); // You can remove const timer here
return () => {clearInterval(timer)}
} else {
clearInterval(timer) //<-- clear the interval when hiding
}
}, [showCanvas])
const runFaceDots = useCallback(async (key, dot) => {
const net = await facemesh.load(...);
timer = setInterval(() => { //<--- remove const and use global variable
detectDots(net, key, dot);
}, 10);
return timer //this is to be used for clearing the interval
},[showCanvas])
.....
}
I tried adding the condition on mouseenter and mouseleave however the modal is not working but when I tried to create a button onClick={() => {openModal();}} the modal will show up. Can you please tell me what's wrong on my code and which part.
const openModal = event => {
if (event) event.preventDefault();
setShowModal(true);
};
const closeModal = event => {
if (event) event.preventDefault();
setShowModal(false);
};
function useHover() {
const ref = useRef();
const [hovered, setHovered] = useState(false);
const enter = () => setHovered(true);
const leave = () => setHovered(false);
useEffect(() => {
if (ref.current.addEventListener('mouseenter', enter)) {
openModal();
} else if (ref.current.addEventListener('mouseleave', leave)) {
closeModal();
}
return () => {
if (ref.current.addEventListener('mouseenter', enter)) {
openModal();
} else if (ref.current.addEventListener('mouseleave', leave)) {
closeModal();
}
};
}, [ref]);
return [ref, hovered];
}
const [ref, hovered] = useHover();
<div className="hover-me" ref={ref}>hover me</div>
{hovered && (
<Modal active={showModal} closeModal={closeModal} className="dropzone-modal">
<div>content here</div>
</Modal>
)}
building on Drew Reese's answer, you can cache the node reference inside the useEffect closure itself, and it simplifies things a bit. You can read more about closures in this stackoverflow thread.
const useHover = () => {
const ref = useRef();
const [hovered, setHovered] = useState(false);
const enter = () => setHovered(true);
const leave = () => setHovered(false);
useEffect(() => {
const el = ref.current; // cache external ref value for cleanup use
if (el) {
el.addEventListener("mouseenter", enter);
el.addEventListener("mouseleave", leave);
return () => {
el.removeEventLisener("mouseenter", enter);
el.removeEventLisener("mouseleave", leave);
};
}
}, []);
return [ref, hovered];
};
I almost gave up and passed on this but it was an interesting problem.
Issues:
The first main issue is with the useEffect hook of your useHover hook, it needs to add/remove both event listeners at the same time, when the ref's current component mounts and unmounts. The key part is the hook needs to cache the current ref within the effect hook in order for the cleanup function to correctly function.
The second issue is you aren't removing the listener in the returned effect hook cleanup function.
The third issue is that EventTarget.addEventListener() returns undefined, which is a falsey value, thus your hook never calls modalOpen or modalClose
The last issue is with the modal open/close state/callbacks being coupled to the useHover hook's implementation. (this is fine, but with this level of coupling you may as well just put the hook logic directly in the parent component, completely defeating the point of factoring it out into a reusable hook!)
Solution
Here's what I was able to get working:
const useHover = () => {
const ref = useRef();
const _ref = useRef();
const [hovered, setHovered] = useState(false);
const enter = () => setHovered(true);
const leave = () => setHovered(false);
useEffect(() => {
if (ref.current) {
_ref.current = ref.current; // cache external ref value for cleanup use
ref.current.addEventListener("mouseenter", enter);
ref.current.addEventListener("mouseleave", leave);
}
return () => {
if (_ref.current) {
_ref.current.removeEventLisener("mouseenter", enter);
_ref.current.removeEventLisener("mouseleave", leave);
}
};
}, []);
return [ref, hovered];
};
Note: using this with a modal appears to have interaction issues as I suspected, but perhaps your modal works better.
I create simple custom hook that save the screen height and width .
The problem is that I want to re-render(update state) only if some condition in my state is happened and not in every resize event..
I try first with simple implementation :
const useScreenDimensions = () => {
const [height, setHeight] = useState(window.innerWidth);
const [width, setWidth] = useState(window.innerHeight);
const [sizeGroup, setSizeGroup]useState(getSizeGroup(window.innerWidth));
useEffect(() => {
const updateDimensions = () => {
if (getSizeGroup() !== sizeGroup) {
setSizeGroup(getSizeGroup(width));
setHeight(window.innerHeight);
setWidth(window.innerWidth);
}
};
window.addEventListener('resize', updateDimensions);
return () => window.removeEventListener('resize', updateDimensions);
}, [sizeGroup, width]);
return { height, width };
}
The problem with this approach is that the effect calls every time , I want that the effect will call just once without dependencies (sizeGroup, width) because I don't want to register the event every time there is a change in screen width/size group(window.addEventListener).
So, I try with this approach with UseCallBack , but also here my 'useEffect' function called many times every time there is any change in the state..
//useState same as before..
const updateDimensions = useCallback(() => {
if (getSizeGroup(window.innerWidth) !== sizeGroup) {
setSizeGroup(getSizeGroup(width));
setHeight(window.innerHeight);
setWidth(window.innerWidth);
}
}, [sizeGroup, width]);
useEffect(() => {
window.addEventListener('resize', updateDimensions);
return () => window.removeEventListener('resize', updateDimensions);
}, [updateDimensions]);
....
return { height, width };
The question is what the correct and effective way for my purposes? I want just "register" the event once, and update the my state only when my state variable is true and not every time the width or something else get updated..
I know that when you set empty array as second argument to 'UseEffect' it's run only once but in my case I want that the register of my event listener run once and on resize I will update the state only if some condition is true
Thanks a lot.
use 2 different useEffect
first one for register event.So below code will run at the time of componentDidMount.
useEffect(() => {
window.addEventListener('resize', updateDimensions);
}, []);
second useEffect to run based on state change.
useEffect(() => {
updateDimensions();
return () => window.removeEventListener('resize', updateDimensions);
}, [sizeGroup, width])
const updateDimensions = useCallback(() => {
setSizeGroup(getSizeGroup(width));
setHeight(window.innerHeight);
setWidth(window.innerWidth);
}
I'm not sure useCallback function need to use or not. And I've not tested this code.