React useEffect and mousedown listener - javascript

I have a modal that closes if user clicked outside it.
approach one - passing isModalOpened so the state updates on click only if isModalOpened is true.
const [isModalOpened, toggleModal] = useState(false);
const ref = useRef(null);
const clickOut = (e) => {
if (!ref.current.contains(e.target) && isModalOpened) {
toggleModal(false);
}
};
React.useEffect(() => {
window.addEventListener('mousedown', clickOut);
return () => {
window.removeEventListener('mousedown', clickOut);
};
}, [isModalOpened]);
approach two - removing the isModalOpened from the dep array.
const [isModalOpened, toggleModal] = useState(false);
const ref = useRef(null);
const clickOut = (e) => {
if (!ref.current.contains(e.target)) {
toggleModal(false);
}
};
React.useEffect(() => {
window.addEventListener('mousedown', clickOut);
return () => {
window.removeEventListener('mousedown', clickOut);
};
}, []);
Question: should I pass or not to pass the isModalOpened to the dep array?

You do not need it.
The reason being that if you toggleModal to the same false value it will not cause a re-render.
So you do not need to guard against the value of isModalOpened which leads to not including the variable in you function, which leads to not requiring the dependency at all.

No, you shouldn't pass isModalOpen to the deep array because in this case your effect will just remove and add again the listener. It is unnecessary

Approach one will run the hook every time isModalOpened changes, removing the global listener when the modal closes. Approach two will run the hook when the component is (un) mounted, meaning the global listener will be active throughout the component lifecycle.
The answer depends on how you're planning to use the component. I'm guessing you plan to mount the component before the modal is opened (hence the false initial state), which means your 2nd approach will listen to the mousedown event from the moment the component is mounted to the moment it's unmounted. This approach would be valid if you were planning to mount the component only when the modal is opened. But you're not, which means you should set the global listener only when the modal is opened.
1st approach is correct.
*edit
But you can remove the isModalOpened check in your if statement.

I would suggest a bit different approach towards this. I would here wrap the clickOut function in useCallback which have dependencies as toggleModal and ref and have clickOut as dependency in useEffect. In this way whenever you ref or toggleModal changes, you have a new reference for clickOut and if you have new reference for clickOut you listeners will be assigned again in useEffect. This will help you will unnecessary clickOut function creation on each render and optimize the rendering as well.
So your code as per my suggestion will look like this:
const [isModalOpened, toggleModal] = useState(false);
const ref = useRef(null);
const clickOut = useCallback((e) => {
if (!ref.current.contains(e.target) && isModalOpened) {
toggleModal(false);
}
}, [isModalOpened, ref]);
React.useEffect(() => {
window.addEventListener('mousedown', clickOut);
return () => {
window.removeEventListener('mousedown', clickOut);
};
}, [clickOut]);

Related

What is useCallback in React and when to use it?

I have gone through a couple of articles on useCallback and useMemo on when to use and when not to use but I have mostly seen very contrived code. I was looking at a code at my company where I have noticed someone have done this:
const takePhoto = useCallback(() => {
launchCamera({ mediaType: "photo", cameraType: "front" }, onPickImage);
}, []);
const pickPhotoFromLibrary = async () => {
launchImageLibrary({ mediaType: "photo" }, onPickImage);
}
const onUploadPress = useCallback(() => {
Alert.alert(
"Upload Photo",
"From where would you like to take your photo?",
[
{ text: "Camera", onPress: () => takePhoto() },
{ text: "Library", onPress: () => pickPhotoFromLibrary() },
]
);
}, [pickPhotoFromLibrary, takePhoto]);
This is how onUploadPress is called:
<TouchableOpacity
style={styles.retakeButton}
onPress={onUploadPress}
>
Do you think this is the correct way of calling it? Based on my understanding from those articles, this looks in-correct. Can someone tell me when to use useCallback and also maybe explain useCallback in more human terms?
Article I read: When to useMemo and useCallback.
useCallback returns a normal JavaScript function regarding how to use it. It is the same as the one it gets as first parameter regarding what it does. The difference is that this function doesn't get recreated on a new memory reference every time the component re-renders, while a normal function does. It gets recreated on a new reference if one of the variables inside useCalback's dependency array changes.
Now, why would you wanna bother with this? Well, It's worth it whenever the normal behavior of a function is problematic for you. For example, if you have that function in the dependency array of an useEffect, or if you pass it down to a component that is memoized with memo.
The callback of an useEffect gets called on the first render and every time one of the variables inside the dependency array changes. And since normally a new version of that function is created on every render, the callback might get called infinitely. So useCallback is used to memoize it.
A memoized component with memo re-renders only if its state or props changes, not because its parent re-renders. And since normally a new version of that passed function as props is created, when the parent re-renders, the child component gets a new reference, hence it re-renders. So useCallback is used to memoize it.
To illustrate, I created the below working React application. Click on that button to trigger re-renders of the parent and watch the console. Hope it clears things up!
const MemoizedChildWithMemoizedFunctionInProps = React.memo(
({ memoizedDummyFunction }) => {
console.log("MemoizedChildWithMemoizedFunctionInProps renders");
return <div></div>;
}
);
const MemoizedChildWithNonMemoizedFunctionInProps = React.memo(
({ nonMemoizedDummyFunction }) => {
console.log("MemoizedChildWithNonMemoizedFunctionInProps renders");
return <div></div>;
}
);
const NonMemoizedChild = () => {
console.log("Non memoized child renders");
return <div></div>;
};
const Parent = () => {
const [state, setState] = React.useState(true);
const nonMemoizedFunction = () => {};
const memoizedFunction = React.useCallback(() => {}, []);
React.useEffect(() => {
console.log("useEffect callback with nonMemoizedFunction runs");
}, [nonMemoizedFunction]);
React.useEffect(() => {
console.log("useEffect callback with memoizedFunction runs");
}, [memoizedFunction]);
console.clear();
console.log("Parent renders");
return (
<div>
<button onClick={() => setState((prev) => !prev)}>Toggle state</button>
<MemoizedChildWithMemoizedFunctionInProps
memoizedFunction={memoizedFunction}
/>
<MemoizedChildWithNonMemoizedFunctionInProps
nonMemoizedFunction={nonMemoizedFunction}
/>
<NonMemoizedChild />
</div>
);
}
ReactDOM.render(
<Parent />,
document.getElementById("root")
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.0/umd/react-dom.production.min.js"></script>
<div id="root"></div>
It's to know that memoizing is not free, doing it wrong is worse than not having it. In your case, using useCallback for onUploadPress is a waste because a non memoized function, pickPhotoFromLibrary, is in the dependency array. Also, it's a waste if TouchableOpacity is not memoized with memo, which I'm not sure it's.
As a side note, there is useMemo, which behaves and is used like useCallback to memoize non-function but referenced values such as objects and arrays for the same reasons, or to memoize any result of a heavy calculation that you don't wanna repeat between renders.
A great resource to understand React render process in depth to know when to memorize and how to do it well: React Render.
In simple words, useCallback is used to save the function reference somewhere outside the component render so we could use the same reference again. That reference will be changed whenever one of the variables in the dependencies array changes.
As you know React tries to minimize the re-rendering process by watching some variables' value changes, then it decides to re-render not depending on the old-value and new-value of those variables.
So, the basic usage of useCallback is to hold old-value and the new-value equally.
I will try to demonstrate it more by giving some examples in situations we must use useCalback in.
Example 1: When the function is one of the dependencies array of the useEffect.
function Component(){
const [state, setState] = useState()
// Should use `useCallback`
function handleChange(input){
setState(...)
}
useEffect(()=>{
handleChange(...)
},[handleChange])
return ...
}
Example 2: When the function is being passed to one of the children components. Especially when it is being called on their useEffect hook, it leads to an infinite loop.
function Parent(){
const [state, setState] = useState()
function handleChange(input){
setState(...)
}
return <Child onChange={handleChange} />
}
function Child({onChange}){
const [state, setState] = useState()
useEffect(()=>{
onChange(...)
},[onChange])
return "Child"
}
Example 3: When you use React Context that holds a state and returns only the state setters functions, you need the consumer of that context to not rerender every time the state update as it may harm the performance.
const Context = React.createContext();
function ContextProvider({children}){
const [state, setState] = useState([]);
// Should use `useCallback`
const addToState = (input) => {
setState(prev => [...prev, input]);
}
// Should use `useCallback`
const removeFromState = (input) => {
setState(prev => prev.filter(elem => elem.id !== input.id));
}
// Should use `useCallback` with empty []
const getState = () => {
return state;
}
const contextValue= React.useMemo(
() => ({ addToState , removeFromState , getState}),
[addToState , removeFromState , getState]
);
// if we used `useCallback`, our contextValue will never change, and all the subscribers will not re-render
<Context.Provider value={contextValue}>
{children}
</Context.Provider>
}
Example 4: If you are subscribed to the observer, timer, document events, and need to unsubscribe when the component unmounts or for any other reason. So we need to access the same reference to unsubscribe from it.
function Component(){
// should use `useCallback`
const handler = () => {...}
useEffect(() => {
element.addEventListener(eventType, handler)
return () => element.removeEventListener(eventType, handler)
}, [eventType, element])
return ...
}
That's it, there are multiple situations you can use it too, but I hope these examples demonstrated the main idea behind useCallback. And always remember you don't need to use it if the cost of the re-render is negligible.

React Hooks missing dependency with function passed from parent to child

I have this type of situation in my current React (v17.0.2) project that gives me 'React Hook useEffect has a missing dependency onTogglePopup' warning.
I need to pass a shared function from parent component to child component and there add an event listener to window when component 'mounts'. It currently does what I want but I believe there must be a better way to accomplish the same and make the linter happy.
I can neither include the function into dependency array nor remove the empty array since I'd loose the functionality.
How do I solve this situation properly without any anti-patterns, hacks or just plain hiding of linter warnings?
const App = ()=>{
const [showPopup, setShowPopup] = useState(false);
const onTogglePopup = ()=>{
setShowPopup(prev => !prev);
// do more App-related stuff here
}
return(
<main>
{/* other elements */}
{showPopup && <Popup onTogglePopup={onTogglePopup}/> }
</main>
)
}
const Popup = ({onTogglePopup})=>{
useEffect(()=>{
const onKeyDown = ev =>{
if (ev.key === 'Escape') onTogglePopup(); // the problem spot
}
window.addEventListener('keydown', onKeyDown);
// remove kbd listener on unmount
return ()=>{
window.removeEventListener('keydown', onKeyDown);
}
},[]);
return createPortal(
<aside id="popup">...
)
}
You can memoize onTogglePopup function before passing it to the child component as a prop.
To memoize the function, use the useCallback hook as shown below:
const onTogglePopup = useCallback(() => {
setShowPopup(prev => !prev);
// do more App-related stuff here
}, []);
Doing this will allow you to safely specify this function in the dependency array of the useEffect hook without causing unnecessary execution of the useEffect hook's callback function every time the parent component re-renders.
Note: useCallback hook also has a dependency array, so you need to be careful not to omit any of its dependencies to avoid the problems because of stale closure.
In your case, if setShowPopup is the only dependency of the useCallback hook, it can be included or excluded without any problem because setShowPopup is a state setter function whose reference is guaranteed to never change.
In App, instead of this:
const onTogglePopup = ()=>{
setShowPopup(prev => !prev);
// do more App-related stuff here
}
Use this:
const onTogglePopup = useCallback(()=>{
setShowPopup(prev => !prev);
// do more App-related stuff here
}, [setShowPopup]);
useCallback preserves the reference to the function unless dependencies change, so you don't trigger useEffect every render.

ref.current in useEffect deps doesn't work when expected (compared to useCallback ref)

Popular component renders the Modal, but it will be visible depending on the modal.isVisible Boolean state. My goal is to attach a mouseevent listener and a handler, to close the Modal when clicking outside of it. I tried to do it using useRef, and pass the ref to the Modal dialog, in order to capture the event and check if clicked outside using event.target.contains.
The problem is, on first render I do not want to assign 'mousedown' handler to the document, but only when wrapper && wrapper.current is defined and it outside the Modal dialog. on first render, I can see that the effect runs as expected, but when expanding the Modal by setting isVisible -> true, the ref.current should have changed, but the effect doesn't run again, it will run again if I close the Modal, and then it will work as expecetd. The ref.current changes it not reflected in the effect, even though the effect is supposed to run after the DOM update. Why is that?
const Modal = ({ isVisible, repo, onClose }) => {
const wrapper = useRef();
console.count('render modal');
const escapeHandler = useCallback(({ key }) => {
if (key == 'Escape') onClose();
}, [onClose]);
useEffect(() => {
document.addEventListener('keydown', escapeHandler);
return () => document.removeEventListener('keydown', escapeHandler);
}, []);
useEffect(() => {
// runs after first render, but when setting isVisible to true and causing a rerender
// the effect doesn't run again despite ref.current is changed to <div>
// only after closing the Modal with Escape, it will work as expected, why?
console.count('effect modal');
console.log(wrapper.current);
}, [wrapper.current]);
return !isVisible ? null : (
<div className="modal">
<div className="modal-dialog" ref={wrapper}>
<span className="modal-close" onClick={onClose}>×</span>
{repo && <pre>{JSON.stringify(repo, null, 4)}</pre>}
</div>
</div>
);
};
const Popular = () => {
const [modal, setModal] = useState({ isVisible: false, repo: null });
const closeModal = useCallback(() => {
setModal({ isVisible: false, repo: null });
}, []);
return <Modal onClose={closeModal} {...modal} />
};
However, after reading the docs, if I use a useCallback and pass it as a ref, it works as expetced, like so, why?
const wrapper = useCallback(node => {
// works as expected every time the ref changes
console.log(node);
}, []);
Let me know if the question phrasing is a bit unclear, I'll try to explain a little better
I am not really sure why despite adding wrapper.current to dependency and component re-rendering due to prop change, the useEffect isn't running the first time but is called on all subsequent isVisible state changes. It could be a bug in react. Maybe you can create a issue on reactjs for that.
That said,
However all the other observations you have are justified. Making use of useCallback call the function everytime because in that way you assign the ref using the ref callback pattern wherein ref is assigned like ref={node => wrapper.current = node}.
This way everytime your visible state is true the ref callback is called resulting in the useCallback function to be called if you use it like
const modelRef= useRef(null);
const wrapper = useCallback((node) => {
modelRef.current = node;
console.log(node);
}, [])
...
<div ref={wrapper} />
The above useEffect code will however run correctly and deterministically if you add isVisible as a dependency to useEffect instead of wrapper.current
useEffect(() => {
console.count('effect modal');
console.log(wrapper.current);
}, [isVisible])
and that is a correct way to go too since you ref is changed only when the isVisible flag is changed and depending on a mutable value as a useEffect is not a great solution as there may arise some cases when the mutation isn't accompanied a re-render at the same time and the useEffect won't run at all in that case

Why is the cleanup function from `useEffect` called on every render?

I've been learning React and I read that the function returned from useEffect is meant to do cleanup and React performs the cleanup when the component unmounts.
So I experimented with it a bit but found in the following example that the function was called every time the component re-renders as opposed to only the time it got unmounted from the DOM, i.e. it console.log("unmount"); every time the component re-renders.
Why is that?
function Something({ setShow }) {
const [array, setArray] = useState([]);
const myRef = useRef(null);
useEffect(() => {
const id = setInterval(() => {
setArray(array.concat("hello"));
}, 3000);
myRef.current = id;
return () => {
console.log("unmount");
clearInterval(myRef.current);
};
}, [array]);
const unmount = () => {
setShow(false);
};
return (
<div>
{array.map((item, index) => {
return (
<p key={index}>
{Array(index + 1)
.fill(item)
.join("")}
</p>
);
})}
<button onClick={() => unmount()}>close</button>
</div>
);
}
function App() {
const [show, setShow] = useState(true);
return show ? <Something setShow={setShow} /> : null;
}
Live example: https://codesandbox.io/s/vigilant-leavitt-z1jd2
React performs the cleanup when the component unmounts.
I'm not sure where you read this but this statement is incorrect. React performs the cleanup when the dependencies to that hook changes and the effect hook needs to run again with new values. This behaviour is intentional to maintain the reactivity of the view to changing data. Going off the official example, let's say an app subscribes to status updates from a friends' profile. Being the great friend you are, you are decide to unfriend them and befriend someone else. Now the app needs to unsubscribe from the previous friend's status updates and listen to updates from your new friend. This is natural and easy to achieve with the way useEffect works.
useEffect(() => {
chatAPI.subscribe(props.friend.id);
return () => chatAPI.unsubscribe(props.friend.id);
}, [ props.friend.id ])
By including the friend id in the dependency list, we can indicate that the hook needs to run only when the friend id changes.
In your example you have specified the array in the dependency list and you are changing the array at a set interval. Every time you change the array, the hook reruns.
You can achieve the correct functionality simply by removing the array from the dependency list and using the callback version of the setState hook. The callback version always operates on the previous version of the state, so there is no need to refresh the hook every time the array changes.
useEffect(() => {
const id = setInterval(() => setArray(array => [ ...array, "hello" ]), 3000);
return () => {
console.log("unmount");
clearInterval(id);
};
}, []);
Some additional feedback would be to use the id directly in clearInterval as the value is closed upon (captured) when you create the cleanup function. There is no need to save it to a ref.
The React docs have an explanation section exactly on this.
In short, the reason is because such design protects against stale data and update bugs.
The useEffect hook in React is designed to handle both the initial render and any subsequent renders (here's more about it).
Effects are controlled via their dependencies, not by the lifecycle of the component that uses them.
Anytime dependencies of an effect change, useEffect will cleanup the previous effect and run the new effect.
Such design is more predictable - each render has its own independent (pure) behavioral effect. This makes sure that the UI always shows the correct data (since the UI in React's mental model is a screenshot of the state for a particular render).
The way we control effects is through their dependencies.
To prevent cleanup from running on every render, we just have to not change the dependencies of the effect.
In your case concretely, the cleanup is happening because array is changing, i.e. Object.is(oldArray, newArray) === false
useEffect(() => {
// ...
}, [array]);
// ^^^^^ you're changing the dependency of the effect
You're causing this change with the following line:
useEffect(() => {
const id = setInterval(() => {
setArray(array.concat("hello")); // <-- changing the array changes the effect dep
}, 3000);
myRef.current = id;
return () => {
clearInterval(myRef.current);
};
}, [array]); // <-- the array is the effect dep
As others have said, the useEffect was depending on the changes of "array" that was specified in the 2nd parameter in the useEffect. So by setting it to empty array, that'd help to trigger useEffect once when the component mounted.
The trick here is to change the previous state of the Array.
setArray((arr) => arr.concat("hello"));
See below:
useEffect(() => {
const id = setInterval(() => {
setArray((arr) => arr.concat("hello"));
}, 3000);
myRef.current = id;
return () => {
console.log("unmount");
clearInterval(myRef.current);
};
}, []);
I forked your CodeSandbox for demonstration:
https://codesandbox.io/s/heuristic-maxwell-gcuf7?file=/src/index.js
Looking at the code I could guess its because of the second param [array]. You are updating it, so it will call a re-render. Try setting an empty array.
Every state update will call a re-render and unmount, and that array is changing.
It seems expected. As per the documentation here, useEffect is called after first render, every update and unmount.
https://reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects
Tip
If you’re familiar with React class lifecycle methods, you can think
of useEffect Hook as componentDidMount, componentDidUpdate and before
componentWillUnmount combined.
This is a Jest test that shows the render and effect order.
As you can see from the expect, once the dependency foo changes due to the state update it triggers a NEW render followed by the cleanup function of the first render.
it("with useEffect async set state and timeout and cleanup", async () => {
jest.useFakeTimers();
let theRenderCount = 0;
const trackFn = jest.fn((label: string) => { });
function MyComponent() {
const renderCount = theRenderCount;
const [foo, setFoo] = useState("foo");
useEffect(() => {
trackFn(`useEffect ${renderCount}`);
(async () => {
await new Promise<string>((resolve) =>
setTimeout(() => resolve("bar"), 5000)
);
setFoo("bar");
})();
return () => trackFn(`useEffect cleanup ${renderCount}`);
}, [foo]);
++theRenderCount;
trackFn(`render ${renderCount}`);
return <span data-testid="asdf">{foo}</span>;
}
const { unmount } = render(<MyComponent></MyComponent>);
expect(screen.getByTestId("asdf").textContent).toBe("foo");
jest.advanceTimersByTime(4999);
expect(screen.getByTestId("asdf").textContent).toBe("foo");
jest.advanceTimersByTime(1);
await waitFor(() =>
expect(screen.getByTestId("asdf").textContent).toBe("bar")
);
trackFn("before unmount");
unmount();
expect(trackFn.mock.calls).toEqual([
['render 0'],
['useEffect 0'],
['render 1'],
['useEffect cleanup 0'],
['useEffect 1'],
['before unmount'],
['useEffect cleanup 1']
])
});

Using useEffect with event listeners

The issue I'm having is that when I set up an event listener, the value the event listener sees doesn't update with the state. It's as if it's bound to the initial state.
What is the correct way to do this?
Simple example:
import React, { useState, useEffect } from "react";
import ReactDOM from "react-dom";
const App = () => {
const [name, setName] = useState("Colin");
const [nameFromEventHandler, setNameFromEventHandler] = useState("");
useEffect(() => {
document.getElementById("name").addEventListener("click", handleClick);
}, []);
const handleButton = () => {
setName("Ricardo");
};
const handleClick = () => {
setNameFromEventHandler(name);
};
return (
<React.Fragment>
<h1 id="name">name: {name}</h1>
<h2>name when clicked: {nameFromEventHandler}</h2>
<button onClick={handleButton}>change name</button>
</React.Fragment>
);
};
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Gif below, since SO code snippet doesn't work for some reason.
So your problem is that you pass an empty array as the second argument to your effect so the effect will never be cleaned up and fired again. This means that handleClick will only ever be closed over the default state. You've essentially written: setNameFromEventHandler("Colin"); for the entire life of this component.
Try removing the second argument all together so the effect will be cleaned up and fired whenever the state changes. When the effect refires, the function that will be handling the click event that will be closed over the most recent version of your state. Also, return a function from your useEffect that will remove your event listener.
E.g.
useEffect(() => {
document.getElementById("name").addEventListener("click", handleClick);
return () => {
document.getElementById("name").removeEventListener("click", handleClick);
}
});
I think correct solution should be this: codesanbox. We are telling to the effect to take care about its dependency, which is the callback. Whenever it is changed we should do another binding with correct value in closure.
I believe the correct solution would be something like this:
useEffect(() => {
document.getElementById("name").addEventListener("click", handleClick);
}, [handleClick]);
const handleButton = () => {
setName("Ricardo");
};
const handleClick = useCallback(() => {
setNameFromEventHandler(name)
}, [name])
The useEffect should have handleClick as part of its dependency array otherwise it will suffer from what is known as a 'stale closure' i.e. having stale state.
To ensure the useEffect is not running on every render, move the handleClick inside a useCallback. This will return a memoized version of the callback that only changes if one of the dependencies has changed which in this case is 'name'.

Categories