Hide and Show modal on mouseenter and mouseleave using React Hooks - reactjs

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.

Related

ref.current.contains(event.target) not working as expected

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>
);
}

Run event handler functions synchronously after a React state change

I know useEffect allows you to run a function after state is updated.
However, I want to run different logic after a state change based on which different event handler causes a state change.
Context
I have a Parent component that shows or hides a child DialogModal component based on the [isDialogShown, setIsDialogShown] = useState(false) in Parent.
When isDialogShown
The Parent passes setIsDialogShown and 2 event handler callbacks to DialogModal: onDismiss (which adds focus to some element) and onConfirm (which adds focus to another element).
When onDismiss or onConfirm on the DialogModal is pressed, setIsDialogShown(false) should run first to hide the DialogModal, then run the respective callbacks to focus on differing elements of the page.
const Parent = () => {
const [isDialogShown, setIsDialogShown] = useState(false);
// These need to run after Dialog is closed.
// In other words, after isDialogShown state is updated to false.
const focusOnElementA = () => { .... };
const focusOnElementB = () => { .... };
const handleDismiss = () => {
setIsDialogShown(false);
focusOnElementA() // Needs to run after state has changed to close the modal
}
const handleConfirm = () => {
setIsDialogShown(false);
focusOnElementB() // Needs to run after state has changed to close the modal
}
return (
<>
<Button onClick={() => { setIsDialogShown(true) }>Open dialog</Button>
<DialogModal
isOpen={isDialogShown}
onDismiss={handleDismiss}
onConfirm={handleConfirm}
/>
</>
)
}
What's the right pattern for dealing with this scenario?
I would use a separate state for the elements A and B to trigger them by in an additional effect. Enqueueing the toggle A/B state ensures the effect handles the update to call the focus A/B handles on the next render after the modal has closed.
const Parent = () => {
const [isDialogShown, setIsDialogShown] = useState(false);
const [toggleA, setToggleA] = useState(false);
const [toggleB, setToggleB] = useState(false);
useEffect(() => {
if (toggleA) {
focusOnElementA();
setToggleA(false);
}
if (toggleB) {
focusOnElementB();
setToggleB(false);
}
}, [toggleA, toggleB]);
const focusOnElementA = () => { .... };
const focusOnElementB = () => { .... };
const handleDismiss = () => {
setIsDialogShown(false);
setToggleA(true);
}
const handleConfirm = () => {
setIsDialogShown(false);
setToggleB();
}
return (
<>
<Button onClick={() => { setIsDialogShown(true) }>Open dialog</Button>
<DialogModal
isOpen={isDialogShown}
onDismiss={handleDismiss}
onConfirm={handleConfirm}
/>
</>
)
}
A slight difference to Drew's answer but achieved using the same tools (useEffect).
// Constants for dialog state
const DIALOG_CLOSED = 0;
const DIALOG_OPEN = 1;
const DIALOG_CONFIRM = 2;
const DIALOG_CANCELLED = 3;
const Parent = () => {
// useState to keep track of dialog state
const [dialogState, setDialogState] = useState(DIALOG_CLOSED);
// Set dialog state to cancelled when dismissing.
const handleDismiss = () => {
setDialogState(DIALOG_CANCELLED);
}
// set dialog state to confirm when confirming.
const handleConfirm = () => {
setDialogState(DIALOG_CONFIRM);
}
// useEffect that triggers on dialog state change.
useEffect(() => {
// run code when confirm was selected and dialog is closed.
if (dialogState === DIALOG_CONFIRM) {
const focusOnElementB = () => { .... };
focusOnElementB()
}
// run code when cancel was selected and dialog is closed.
if (dialogState === DIALOG_CANCELLED) {
const focusOnElementA = () => { .... };
focusOnElementA()
}
}, [dialogState])
return (
<>
<Button onClick={() => { setDialogState(DIALOG_OPEN) }}>Open dialog</Button>
<DialogModal
isOpen={dialogState === DIALOG_OPEN}
onDismiss={handleDismiss}
onConfirm={handleConfirm}
/>
</>
)
}
You should add another state for which element was triggered and then trigger the effect when the states change:
const [action, setAction] = useState('');
// ...code
const handleDismiss = () => {
setAction('dismiss');
setIsDialogShown(false);
}
const handleConfirm = () => {
setAction('confirm');
setIsDialogShown(false);
}
// Add dependencies to useEffect and it will run only when the states change
useEffect(() => {
if(!isDialogShown) {
if(action === 'dismiss') {
focusOnElementA()
} else {
focusOnElementB()
}
}
}, [action, isDialogShown])

Is it safe to use ref.current as useEffect's dependency when ref points to a DOM element?

I'm aware that ref is a mutable container so it should not be listed in useEffect's dependencies, however ref.current could be a changing value.
When a ref is used to store a DOM element like <div ref={ref}>, and when I develop a custom hook that relies on that element, to suppose ref.current can change over time if a component returns conditionally like:
const Foo = ({inline}) => {
const ref = useRef(null);
return inline ? <span ref={ref} /> : <div ref={ref} />;
};
Is it safe that my custom effect receiving a ref object and use ref.current as a dependency?
const useFoo = ref => {
useEffect(
() => {
const element = ref.current;
// Maybe observe the resize of element
},
[ref.current]
);
};
I've read this comment saying ref should be used in useEffect, but I can't figure out any case where ref.current is changed but an effect will not trigger.
As that issue suggested, I should use a callback ref, but a ref as argument is very friendly to integrate multiple hooks:
const ref = useRef(null);
useFoo(ref);
useBar(ref);
While callback refs are harder to use since users are enforced to compose them:
const fooRef = useFoo();
const barRef = useBar();
const ref = element => {
fooRef(element);
barRef(element);
};
<div ref={ref} />
This is why I'm asking whether it is safe to use ref.current in useEffect.
It isn't safe because mutating the reference won't trigger a render, therefore, won't trigger the useEffect.
React Hook useEffect has an unnecessary dependency: 'ref.current'.
Either exclude it or remove the dependency array. Mutable values like
'ref.current' aren't valid dependencies because mutating them doesn't
re-render the component. (react-hooks/exhaustive-deps)
An anti-pattern example:
const Foo = () => {
const [, render] = useReducer(p => !p, false);
const ref = useRef(0);
const onClickRender = () => {
ref.current += 1;
render();
};
const onClickNoRender = () => {
ref.current += 1;
};
useEffect(() => {
console.log('ref changed');
}, [ref.current]);
return (
<>
<button onClick={onClickRender}>Render</button>
<button onClick={onClickNoRender}>No Render</button>
</>
);
};
A real life use case related to this pattern is when we want to have a persistent reference, even when the element unmounts.
Check the next example where we can't persist with element sizing when it unmounts. We will try to use useRef with useEffect combo as above, but it won't work.
// BAD EXAMPLE, SEE SOLUTION BELOW
const Component = () => {
const ref = useRef();
const [isMounted, toggle] = useReducer((p) => !p, true);
const [elementRect, setElementRect] = useState();
useEffect(() => {
console.log(ref.current);
setElementRect(ref.current?.getBoundingClientRect());
}, [ref.current]);
return (
<>
{isMounted && <div ref={ref}>Example</div>}
<button onClick={toggle}>Toggle</button>
<pre>{JSON.stringify(elementRect, null, 2)}</pre>
</>
);
};
Surprisingly, to fix it we need to handle the node directly while memoizing the function with useCallback:
// GOOD EXAMPLE
const Component = () => {
const [isMounted, toggle] = useReducer((p) => !p, true);
const [elementRect, setElementRect] = useState();
const handleRect = useCallback((node) => {
setElementRect(node?.getBoundingClientRect());
}, []);
return (
<>
{isMounted && <div ref={handleRect}>Example</div>}
<button onClick={toggle}>Toggle</button>
<pre>{JSON.stringify(elementRect, null, 2)}</pre>
</>
);
};
See another example in React Docs: How can I measure a DOM node?
Further reading and more examples see uses of useEffect
2021 answer:
This article explains the issue with using refs along with useEffect: Ref objects inside useEffect Hooks:
The useRef hook can be a trap for your custom hook, if you combine it with a useEffect that skips rendering. Your first instinct will be to add ref.current to the second argument of useEffect, so it will update once the ref changes.
But the ref isn’t updated till after your component has rendered — meaning, any useEffect that skips rendering, won’t see any changes to the ref before the next render pass.
Also as mentioned in this article, the official react docs have now been updated with the recommended approach (which is to use a callback instead of a ref + effect). See How can I measure a DOM node?:
function MeasureExample() {
const [height, setHeight] = useState(0);
const measuredRef = useCallback(node => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height);
}
}, []);
return (
<>
<h1 ref={measuredRef}>Hello, world</h1>
<h2>The above header is {Math.round(height)}px tall</h2>
</>
);
}
I faced the same problem and I created a custom hook with Typescript and an official approach with ref callback. Hope that it will be helpful.
export const useRefHeightMeasure = <T extends HTMLElement>() => {
const [height, setHeight] = useState(0)
const refCallback = useCallback((node: T) => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height)
}
}, [])
return { height, refCallback }
}
I faced a similar problem wherein my ESLint complained about ref.current usage inside a useCallback. I added a custom hook to my project to circumvent this eslint warning. It toggles a variable to force re-computation of the useCallback whenever ref object changes.
import { RefObject, useCallback, useRef, useState } from "react";
/**
* This hook can be used when using ref inside useCallbacks
*
* Usage
* ```ts
* const [toggle, refCallback, myRef] = useRefWithCallback<HTMLSpanElement>();
* const onClick = useCallback(() => {
if (myRef.current) {
myRef.current.scrollIntoView({ behavior: "smooth" });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [toggle]);
return (<span ref={refCallback} />);
```
* #returns
*/
function useRefWithCallback<T extends HTMLSpanElement | HTMLDivElement | HTMLParagraphElement>(): [
boolean,
(node: any) => void,
RefObject<T>
] {
const ref = useRef<T | null>(null);
const [toggle, setToggle] = useState(false);
const refCallback = useCallback(node => {
ref.current = node;
setToggle(val => !val);
}, []);
return [toggle, refCallback, ref];
}
export default useRefWithCallback;
I've stopped using useRef and now just use useState once or twice:
const [myChart, setMyChart] = useState(null)
const [el, setEl] = useState(null)
useEffect(() => {
if (!el) {
return
}
// attach to element
const myChart = echarts.init(el)
setMyChart(myChart)
return () => {
myChart.dispose()
setMyChart(null)
}
}, [el])
useEffect(() => {
if (!myChart) {
return
}
// do things with attached object
myChart.setOption(... data ...)
}, [myChart, data])
return <div key='chart' ref={setEl} style={{ width: '100%', height: 1024 }} />
Useful for charting, auth and other non-react libraries, because it keeps an element ref and the initialized object around and can dispose of it directly as needed.
I'm now not sure why useRef exists in the first place...?

React Hooks How to get to componentWillUnmount

Hello I'm trying to pass the following code to reacthooks:
import { disableBodyScroll, enableBodyScroll, clearAllBodyScrollLocks } from 'body-scroll-lock';
class SomeComponent extends React.Component {
// 2. Initialise your ref and targetElement here
targetRef = React.createRef();
targetElement = null;
componentDidMount() {
// 3. Get a target element that you want to persist scrolling for (such as a modal/lightbox/flyout/nav).
// Specifically, the target element is the one we would like to allow scroll on (NOT a parent of that element).
// This is also the element to apply the CSS '-webkit-overflow-scrolling: touch;' if desired.
this.targetElement = this.targetRef.current;
}
showTargetElement = () => {
// ... some logic to show target element
// 4. Disable body scroll
disableBodyScroll(this.targetElement);
};
hideTargetElement = () => {
// ... some logic to hide target element
// 5. Re-enable body scroll
enableBodyScroll(this.targetElement);
}
componentWillUnmount() {
// 5. Useful if we have called disableBodyScroll for multiple target elements,
// and we just want a kill-switch to undo all that.
// OR useful for if the `hideTargetElement()` function got circumvented eg. visitor
// clicks a link which takes him/her to a different page within the app.
clearAllBodyScrollLocks();
}
render() {
return (
// 6. Pass your ref with the reference to the targetElement to SomeOtherComponent
<SomeOtherComponent ref={this.targetRef}>
some JSX to go here
</SomeOtherComponent>
);
}
}
And then I did the following with hooks:
const [modalIsOpen, setIsOpen] = useState(false);
const openModal = () => {
setIsOpen(true);
};
const closeModal = () => {
setIsOpen(false);
};
const targetRef = useRef();
const showTargetElement = () => {
disableBodyScroll(targetRef);
};
const hideTargetElement = () => {
enableBodyScroll(targetRef);
};
useEffect(() => {
if (modalIsOpen === true) {
showTargetElement();
} else {
hideTargetElement();
}
}, [modalIsOpen]);
I don't know if I did it correctly with useRef and useEffect, but it worked, but I can't imagine how I'm going to get to my componentWillUnmount to call mine:
clearAllBodyScrollLocks ();
The basic equivalents for componentDidMount and componentWillUnmount in React Hooks are:
//componentDidMount
useEffect(() => {
doSomethingOnMount();
}, [])
//componentWillUnmount
useEffect(() => {
return () => {
doSomethingOnUnmount();
}
}, [])
These can also be combined into one useEffect:
useEffect(() => {
doSomethingOnMount();
return () => {
doSomethingOnUnmount();
}
}, [])
This process is called effect clean up, you can read more from the documentation.

React custom Hook using useRef returns null for the first time the calling component Loads?

I have created a custom hook to scroll the element back into view when the component is scrolled.
export const useComponentIntoView = () => {
const ref = useRef();
const {current} = ref;
if (current) {
window.scrollTo(0, current.offsetTop );
}
return ref;
}
Now i am making use of this in a functional component like
<div ref={useComponentIntoView()}>
So for the first time the current always comes null, i understand that the component is still not mounted so the value is null . but what can we do to get this values always in my custom hook as only for the first navigation the component scroll doesn't work . Is there any work around to this problem .
We need to read the ref from useEffect, when it has already been assigned. To call it only on mount, we pass an empty array of dependencies:
const MyComponent = props => {
const ref = useRef(null);
useEffect(() => {
if (ref.current) {
window.scrollTo(0, ref.current.offsetTop);
}
}, []);
return <div ref={ref} />;
};
In order to have this functionality out of the component, in its own Hook, we can do it this way:
const useComponentIntoView = () => {
const ref = useRef(null);
useEffect(() => {
if (ref.current) {
window.scrollTo(0, ref.current.offsetTop);
}
}, []);
return ref;
};
const MyComponent = props => {
const ref = useComponentIntoView();
return <div ref={ref} />;
};
We could also run the useEffect hook after a certain change. In this case we would need to pass to its array of dependencies, a variable that belongs to a state. This variable can belong to the same Component or an ancestor one. For example:
const MyComponent = props => {
const [counter, setCounter] = useState(0);
const ref = useRef(null);
useEffect(() => {
if (ref.current) {
window.scrollTo(0, ref.current.offsetTop);
}
}, [counter]);
return (
<div ref={ref}>
<button onClick={() => setCounter(counter => counter + 1)}>
Click me
</button>
</div>
);
};
In the above example each time the button is clicked it updates the counter state. This update triggers a new render and, as the counter value changed since the last time useEffect was called, it runs the useEffect callback.
As you mention, ref.current is null until after the component is mounted. This is where you can use useEffect - which will fire after the component is mounted, i.e.:
const useComponentIntoView = () => {
const ref = useRef();
useEffect(() => {
if (ref.current) {
window.scrollTo(0, ref.current.offsetTop );
}
});
return ref;
}

Resources