ReactJS unexpected behavior in window event - javascript

Stuck on an issue that is probable easy to fix. I have the following React code (which can be found at CodeSandbox):
export const App = ()=> {
const [toggle,setToggle] = useState(false);
const setItemToggle = (e) => {
e.stopPropagation()
e.target.tagName==="H1" ? setToggle(!toggle) : setToggle(false)
e.preventDefault()
}
useEffect(()=>{
window.addEventListener("click",setItemToggle,false);
return () => window.removeEventListener("click",setItemToggle,false);
},[])
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
<h2>Current Value Of toggle is {toggle ? "True" : "False"}</h2>
</div>
);
}
When you click on the <h1>Hello CodeSandbox</h> I expected the toggle to change accordingly; however, it changes once and doesn't toggle after that. Clicking outside of the element does change the toggle to false. I know it has to do with the eventListener added to the Window but I can't track it down. Thanks for any help.

You have a stale prop problem
since the event handler you assign to the event listener is using the initial closure it was created in (so it doesn't see changes to the closed-over toggle value),
and you don't set the function as a dependency to the useEffect invocation so a new version of the function never gets used.
Had you omitted the empty dependency array altogether, this would have worked, with the caveat that you would be continuously re-registering event handlers.
I recommend using the eslint-plugin-react-hooks linter – it would have caught this.
The easy way to fix this is to not close over (refer to) the toggle variable at all in the event handler at all by using the function form of setState: setToggle(toggle => !toggle).

You are simply changing the state value by doing setState(!state). This pattern does not guarantee that you have the correct value of state. You are better off using the functional pattern:
import { useEffect, useState } from "react";
import "./styles.css";
const App = () => {
const [toggle, setToggle] = useState(false);
const setItemToggle = (e) => {
e.stopPropagation();
e.target.tagName === "H1"
? setToggle((toggle) => !toggle)
: setToggle(false);
e.preventDefault();
};
useEffect(() => {
window.addEventListener("click", setItemToggle, false);
return () => window.removeEventListener("click", setItemToggle, false);
}, []);
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
<h2>Current Value Of toggle is {toggle ? "True" : "False"}</h2>
</div>
);
};
export default App;
setState(prevState => !prevState) is a sure way to ensure you have the correct previous value of state. This is the ideal way to set state when new value of state depends on previous value of state.
Sandbox Link
The problem is that your function is unaware of the new value of state, one more way to tackle is using the dependancy array. As the name suggests you are defining the dependencies of the your code in the array. Just pass it as the second param to useEffect and you should be good to go:
import { useEffect, useState } from "react";
import "./styles.css";
const App = () => {
const [toggle, setToggle] = useState(false);
useEffect(() => {
const setItemToggle = (e) => {
e.stopPropagation();
e.target.tagName === "H1" ? setToggle(!toggle) : setToggle(false);
e.preventDefault();
};
window.addEventListener("click", setItemToggle, false);
return () => window.removeEventListener("click", setItemToggle, false);
}, [toggle]);
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
<h2>Current Value Of toggle is {toggle ? "True" : "False"}</h2>
</div>
);
};
export default App;
We are once again tackling the problem of incorrect state value but this time by rerunning the code inside the useEffect callback and hence redefining the function setItemToggle.
Link
Note: Also if a function is only relevant inside useEffect, best to define it there only. This is also one of the pros of using hooks instead of classes. Your relevant code is in one place.

You can just add onClick to h1 element and pass a function directly there.
<h1 onClick={() => setToggle(!toggle)}>Hello CodeSandbox</h1>
some good resource on the topic of Handling Events in react can be found here and working sandbox of what I present can be found here

Related

How unmounting really works?

I observed a strange behavior in React while unmounting a component. Typically, when we unmount a component, we do this.
const [isVisible, setVisibility] = useState(false)
const onKeyDown = (event) => { console.log(event) }
useEffect(() => {
window.addEventListener('keydown', onKeyDown)
return () => { window.removeEventListener('keydown', onKeyDown) }
}, [isVisible])
return (
<button onClick={() => setVisibility(!isVisible)}>Click me!</button>
)
It works perfectly fine, but when we try to achieve this same thing using an if-else statement instead of a return, unmounting doesn't work. I searched for the reason, and I found out that when the state changes, React re-renders the handler function, which results in mismatching of both the handler (old and new one), and it won't remove.
Code using if-else statement.
const [isVisible, setVisibility] = useState(false)
const onKeyDown = (event) => { console.log(event) }
useEffect(() => {
if(isVisible) window.addEventListener('keydown', onKeyDown)
else window.removeEventListener('keydown', onKeyDown)
}, [isVisible])
return (
<button onClick={() => setVisibility(!isVisible)}>Click me!</button>
)
But now my question is how it works when we write return()? Does React save the same handler function when we write return()?
I don't know if this is the answer you're looking for, but I had issues where a stateful variable was needed on my unmount function (saving a form value to localstorage), and the issue seemed to lie in the fact that the original value on initial render was being used, and would not update: (I assume because my useEffect had been set to only run at the start via ,[])
If the if/else statement you mentioned is based on a stateful variable, you likely ran into the same situation.
Here's a small code snippet of what solved things for me, the same idea likely applies for you. Just use a ref and keep the ref updated as you state value changes:
const formValueRef = useRef<any>();
useEffect(() => {
return () => {
saveEntryFormToLocalStorage(formValueRef.current); // save form to storage when the component is destroyed
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
formValueRef.current = form.values;
}, [form.values]); // eslint-disable-line react-hooks/exhaustive-deps

missing dependency warning when working with event listeners and states inside useEffect

Everytime I work with addEventListener(), and also want to access some state inside useEffect, I get the same issue. I can't add the state as dependency, because then I would create multiple event listeners each time the state changes.
I almost everytime find myself stuck with the "React Hook useEffect has a missing dependency" warning.
Let's say I have a component that needs to change it state on window.onClick() and on window.onDoubleClick(). If the state is true, click should change it to false, and if the state is false, double click should change it to true.
So here's what I whould write:
import React, { useState, useEffect } from 'react';
export default function someComponent() {
const [toggle, setToggle] = useState(false);
useEffect(() => {
window.addEventListener('click', (event) => {
if (toggle) setToggle(false)
})
window.addEventListener('dblclick', (event) => {
if (!toggle) setToggle(true)
})
}, [])
return (
<p>The toggle state is {toggle.toString()}</p>
);
}
This code works, but I get the missing dependency warning. I can't add toggle to the dependency array, because then it will add another event listener each time the toggle state changes.
What am I doing wrong here? how should I fix this?
Edit: Maybe this example wasn't too good, but it's the simplest I could think of. But, this issue is also for when I create other event listeners, that have to be on the windows object, like scroll. I know I can use return to remove the event listener everytime, but for events like scroll it makes it much slower. It doesn't make sense to me that I have to remove and add it everytime, when I just don't need it to fire again.
With react you don't have to use the window element in this case. Not even a useEffect.
By using the useEffect hook you are telling react to do something after render (depending on the dependency array). In this case changing state is not necessary immediately after rendering the page, only when the user interacts with the element.
Adding click events through the useEffect is probably not needed most of the time and and doing it like the example below will probably save you time and a headache and maybe even performance (correct me if i'm wrong).
I would personally do it like this.
import React, { useState } from 'react';
export default function someComponent() {
const [toggle, setToggle] = useState(false);
return (
<p
onClick={() => setToggle(false)}
onDoubleClick={() => setToggle(true)}
>
The toggle state is {toggle.toString()}
</p>
);
}
You could also call functions from the element like so
const [toggle, setToggle] = useState(false);
const handleClick = () => {
if (toggle) {
setToggle(false);
}
};
const handleDoubleClick = () => {
if (!toggle) {
setToggle(true);
}
};
return (
<p
onClick={() => handleClick()}
onDoubleClick={() => handleDoubleClick()}
>
The toggle state is {toggle.toString()}
</p>
);
CodeSandbox example
You can add a clean-up function to the useEffect hook to remove old listeners. This way you can pass toggle into the dependency array and you won't have stacking event listeners.
https://reactjs.org/docs/hooks-effect.html
useEffect(() => {
const handleClick = () => toggle ? setToggle(false) : setToggle(true);
window.addEventListener('click', handleClick);
window.addEventListener('dblclick', handleClick);
return () => {
window.removeEventListener('click', handleClick);
window.removeEventListener('dblclick', handleClick);
}
}, [toggle]);
I can't add the state as dependency, because then I would create multiple event listeners each time the state changes.
There is a way around this, and that is to return a cleanup function from the useEffect callback. I would encourage you to read the linked section of the docs, then the below solution would become much clearer:
useEffect(() => {
const handleClick = () => {
setToggle(!toggle)
}
window.addEventListener('click', handleClick)
return () => {
window.removeEventListener('click', handleClick)
}
}, [toggle])
with the above solution, each time toggle is updated, the cleanup function is called, which removes the current event listener before running the effect again.
Also note that you can provide a callback function to setToggle, which receives the current value of toggle and returns the new value. With this approach you wouldn't need to pass toggle as a dependency to useEffect:
useEffect(() => {
const handleClick = () => {
setToggle(currentValue => !currentValue)
}
window.addEventListener("click", handleClick)
return () => {
window.removeEventListener("click", handleClick)
}
}, [])

React, useEffect cleanup not working with removeEventListener, useRef, parallax effect

I am trying to apply a parallax effect to an .svg image by using useRef() to grab bubblesRef and translateY() onScroll.
The parallax works but when I navigate to the next page I receive error "TypeError: Cannot read property 'style' of null". I think it is because the addEventListener is still listening and trying to useRef() on bubblesRef while navigating to the next page. So I added the cleanup function in useEffect() but that doesn't seem to fix it.
Any help is appreciated. Thanks!
p.s. If anyone can share their approach to a simple parallax effect like this that would be great too. This is the only approach I've figured that won't rerender everything else on the page onScroll.
const HomePage = () => {
const [loadedPosts, setLoadedPosts] = useState([]);
const { sendRequest } = useHttpClient();
console.log("loadedPosts homePage", loadedPosts);
const bubblesRef = useRef();
useEffect(() => {
if (loadedPosts.length === 0) {
//api call
}
}, [sendRequest, loadedPosts]);
useEffect(() => {
const parallax = () => {
let scrolledValue = window.scrollY / 3.5;
bubblesRef.current.style.transform = `translateY(
-${scrolledValue + "px"}
)`;
console.log("scrolling...", scrolledValue);
};
window.addEventListener("scroll", parallax);
return () => window.removeEventListener("scroll", parallax);
}, []);
return (
<HomePageContainer>
<Header />
<SectionOne posts={loadedPosts} />
<SectionTwo />
<BubbleBlobs className="bubbleBlobs" ref={bubblesRef} />
<BlobTop className="backBlobBottom" preserveAspectRatio="none" />
</HomePageContainer>
);
};
export default HomePage;
You definitely need the cleanup function any time you add a listener to the window, or the handler (and thus the component instance itself) will live on forever. However, since React runs those cleanup hooks asynchronously, it might not happen until after other window events. The value of the ref is set to null when the component unmounts, so you need to check that it is still defined before using the value.
useEffect(() => {
const handler = () => {
if (ref.current) {
// perform update
}
}
window.addEventListener('scroll', handler)
return () => window.removeEventListener('scroll', handler)
}, [])
When you call useEffect, your reference has not been instantiated, so the error message appears, in your useEffect dependency array, insert your ref and before running the code in useEffect, make sure your current reference is defined.

Why does my onClick handler not log out the latest version of state and how can I troubleshoot this?

Sandbox
import React, { useEffect, useState } from "react";
export default function App() {
let [button, setButton] = useState(null);
let [num, setNum] = useState(5);
function revealState() {
console.log(num);
}
function changeState() {
setNum(Math.random());
}
useEffect(() => {
const el = (
<button id="logStateButton" onClick={revealState}>
Log state
</button>
);
setButton(el);
}, []);
return (
<>
{button}
<button onClick={changeState}>Change state</button>
</>
);
}
Clicking on the 'Log state button' successfully logs num state. Clicking on the 'Change state button' successfully changes num state. Re-clicking the 'Log state button' doesn't log the updated value of state - it logs the old one.
Why is this? My guess is that, since useEffect runs only the once, it references only the first revealState function which references only the first num variable. Because it's not in the component's return statement it doesn't get 'refreshed'.
Whatever the cause of the problem, what're some work-arounds? Some of the requirements are:
the tag can't be rendered directly in the return statement.
we have to have the useEffect that's there and it needs to have a dep array of some sort (its undesirable for it to fire every-time the function component is re-executed).
In the real project, some important changes to the tags useEffect's callback renders might get made - therefore it's impractical to re-run the useEffect by putting something like num in its dep array.
IMO, the neatest solution is to simply add the updated event listener, every time the page is rendered:
useEffect(() => {
el.onclick = onClickHandler
});
The event listener always has access to the latest state (and props). IMO, this solution is more scalable than previously-mentioned solutions - if my event listener has to track the latest versions of multiple state & props, this could get messy. With this, all I need to do is add extra listeners in into this one useEffect callback. Thoughts?
import React, { useEffect, useState, useCallback } from "react";
export default function App() {
let [button, setButton] = useState(null);
let [num, setNum] = useState(5);
const revealState = useCallback(() => {
console.log(num);
}, [num])
function changeState() {
setNum(Math.random());
}
useEffect(() => {
const el = (
<button id="logStateButton" onClick={revealState}>
Log state
</button>
);
setButton(el);
}, [revealState]);
return (
<>
{button}
<button onClick={changeState}>Change state</button>
</>
);
}
you can listen to the revealState in useEffect. which gets initialized only when num is changed achieved using useCallback. so whenever you click the button the num is changed which initializes the revealState function and not initialized on other rerenders
you have to add num as dependency to useEffect:
useEffect(() => {
const el = (
<button id="logStateButton" onClick={revealState}>
Log state
</button>
);
setButton(el);
}, [num]);
After more clarification on your problem it seems you need to watch over both the num and the HTML state. Combining both Alan and kishore's code together is the solution. Since the useEffect is only watching the num in Alan's answer so if any changes to the tag will not cause it to rerun. kishore also mentioned another fix which is to use the useCallback but what needs to be watch is the button and not the num. Like this:
const updateButton = useCallback(function (newButton) {
setButton(newButton);
}, [button])
useEffect(() => {
const el = (
<button id="logStateButton" onClick={revealState}>
Log state
</button>
);
updateButton(el)
}, [num]);
This will tell useEffect to watch num and will return a new button only when button state is changed.

ClickAwayListener doesn't fire when clicking on a link/button to navigate to other route

I'm using Material-UI ClickAwayListener component with the existing code that used react-router. The button is outside of the <ClickAwayListener> ... </ClickAwayListener> and so I expected the onClickAway to fire before navigating to other route. But it didn't
Below are the replicate of my code, to some extent to demonstrate what I mean
function Component(){
const handleClickAway = () => {
// Do something here
}
return (
<>
<button>
<Link to="/other-route">Click here </Link>
</button>
// Some other element here
<ClickAwayListener onClickAway={handleClickAway}>
<div>
// Content
</div>
</ClickAwayListener>
</>
)
}
So if I click any where that is outside of <ClickAwayListener> and <button> the handleClickAway fired, but if I click onto the <button> which contains the like to other route it doesn't.
I tried to look onto source code of ClickAwayListener and this part, I believe, is responsible for detecting the click
React.useEffect(() => {
if (mouseEvent !== false) {
const mappedMouseEvent = mapEventPropToEvent(mouseEvent);
const doc = ownerDocument(nodeRef.current);
doc.addEventListener(mappedMouseEvent, handleClickAway);
return () => {
doc.removeEventListener(mappedMouseEvent, handleClickAway);
};
}
return undefined;
}, [handleClickAway, mouseEvent]);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.1/umd/react-dom.production.min.js"></script>
As far as I can understand, this part will, first add an event listener to click event when the component mount/re-render and will remove that listener before the component unmount (default behavior for useEffect()). But if this is the case, then before the component get unmount by any event that involved clicking outside of the area of ClickAwayListener, the onClickAway listener should be fired because the listener still attach to the click event.
So in short this is the behavior I expect:
Button click --> onClickAway fire --> component get unmount --> go to new route --> clean-up code of useEffect() run --> the listener get removed
But this is what happen so far
Button click --> component get umount --> go to new route --> clean-up code of useEffect() run --> the listener get removed
Can someone help explain to me why this happen?
ClickAwayListener component works by attaching the event listener to the document, when a mouse event fires, it fires onClickAway only when the mouse event is not inside the element.
The Link component from react-router-dom essentially renders something like this:
<a onClick={() => navigate()}>click</a>
When you click the link and call navigate(), React unmounts the current component and mounts the next page component in the next frame. But the thing is, document handlers are only processed after the next re-render, at that point, the event handler from the ClickAwayListener had already been removed since it was unmounted, so nothing get called.
The problem can be solved by waiting until after the next re-render when the handlers from document have been called.
<button
onClick={() => {
setTimeout(() => {
history.push("/2");
});
}}
>
Live Demo
In the issue that Tejogol linked, a commenter recommended use of these props on the ClickAwayListener component, as it allows the component to catch the touch event at its start, before the link does.
<ClickAwayListener mouseEvent="onMouseDown" touchEvent="onTouchStart">
I was having the same issue as you, and this worked for me! It was also the least intrusive, as I didn't want to mess with my react-router-dom code.
An issue related to your question was opened in September 2019 in the Material UI repository where the Clickawaylistener doesn't fire for both buttons and links. However as recently as May 2020, the issue does not appear to have been adequately addressed. I recommend opening a new issue in the repository to confirm that this isn't a bug as it relates to buttons.
As a potential workaround in the meantime, and depending on your use case, you could either use a <Link> or <Button> component with an onClick prop:
import React from "react";
import ClickAwayListener from "#material-ui/core/ClickAwayListener";
import { Link, useHistory } from "react-router-dom";
import Button from "#material-ui/core/Button";
export default function Home() {
let history = useHistory();
const handleClickAway = () => {
console.log("Clicked Away");
};
function handleClick() {
handleClickAway();
history.push("/about");
}
return (
<div>
<Link to="/about" onClick={handleClickAway}>
Link
</Link>
<Button type="button" onClick={handleClick}>
Button
</Button>
<ClickAwayListener onClickAway={handleClickAway}>
<div>Home Page</div>
</ClickAwayListener>
</div>
);
}
I've also included a working example on Codesandbox.
I was able to solve this issue by using this function instead of the material-ui ClickAwayListener: https://github.com/streamich/react-use/blob/master/src/useClickAway.ts
import { RefObject, useEffect, useRef } from 'react';
import { off, on } from './util';
const defaultEvents = ['mousedown', 'touchstart'];
const useClickAway = <E extends Event = Event>(
ref: RefObject<HTMLElement | null>,
onClickAway: (event: E) => void,
events: string[] = defaultEvents
) => {
const savedCallback = useRef(onClickAway);
useEffect(() => {
savedCallback.current = onClickAway;
}, [onClickAway]);
useEffect(() => {
const handler = (event) => {
const { current: el } = ref;
el && !el.contains(event.target) && savedCallback.current(event);
};
for (const eventName of events) {
on(document, eventName, handler);
}
return () => {
for (const eventName of events) {
off(document, eventName, handler);
}
};
}, [events, ref]);
};
export default useClickAway;
I didn't want to import the library as a dependency so I created a hook out of this code and copied the util functions into it
const on = (obj: any, ...args: any[]) => obj.addEventListener(...args);
const off = (obj: any, ...args: any[]) => obj.removeEventListener(...args);
An example of the hook in use is provided here: https://streamich.github.io/react-use/?path=/story/ui-useclickaway--demo
Most of the answers here offer solutions, but none went into detail about why this happened. So this is what I think is happening
At the time I posted this question, I was using React 17. For any React version below 18, there is one "bug" that appears when using addEventListener or any async action like async/await or Promise. This is discussed in GitHub React 18 discussion
You can try out this code in React version below 18 and you should observe similar result
import React, { useState, useEffect } from "react";
export default function App() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("Test in useEffect " + count);
});
useEffect(() => {
console.log("Another one useEffect");
});
useEffect(() => {
const button = document.getElementById("buttonId");
button.addEventListener("click", handleClick);
return () => {
button.removeEventListener("click", handleClick);
};
}, []);
const handleClick = () => {
console.log("First print Count is " + count);
setCount(count + 1);
setCount(count + 100);
console.log("Second print Count is " + count);
};
return (
<>
<div>Count is: {count} </div>
<button id="buttonId">Click</button>
</>
);
}

Categories

Resources