Ref issue and Calling custom hook after useEffect - reactjs

I am using a custom hook to detect outside clicks
const useClickOutside = (nodeElement, handler) => {
function handleClickOutside(event) {
if (nodeElement && !nodeElement.contains(event.target)) {
handler();
}
}
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
}
And I am calling it like this
const Modal = ({ ... }) => {
const modalRef = useRef(null);
console.log(modalRef.current) // null
useEffect(() => {
console.log(modalRef.current) // work fine here and display the dom element
}, [])
// here the hooks it is called with modalRef.current as null
useClickOutside(modalRef.current, () => {
dispatch(hideModal());
});
return (
<div className="pop-container">
<div className="pop-dialog" ref={modalRef}>
...
</div>
</div>
)
}
The problem is that my custom hooks useClickOutside is called with modalRef.current as null
And as you see in the useEffet hook the modalRef.current value is correct
However i can't call my custom hook there in useEffet otherwise i will get Uncaught Invariant Violation: Hooks can only be called inside the body of a function component
So how to solve this issue ?

Instead of passing ref.current, if you just pass ref, your code will work since ref.current will be mutated at its reference when ref is assigned
const useClickOutside = (nodeElement, handler) => {
function handleClickOutside(event) {
if (nodeElement.current && !nodeElement.current.contains(event.target)) {
handler();
}
}
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
}
and in modal
const Modal = ({ ... }) => {
const modalRef = useRef(null);
// here the hooks it is called with modalRef.current as null
useClickOutside(modalRef, () => {
dispatch(hideModal());
});
return (
<div className="pop-container">
<div className="pop-dialog" ref={modalRef}>
...
</div>
</div>
)
}
Working demo

Related

why useRef current value , isn't sharing trough custom hook?

I wanted to calculate the user scroll height , so I created a custom hook. and I wanted to share this value to another component. but it doesnt work.
code:
const useScroll = () => {
let scrollHeight = useRef(0);
const scroll = () => {
scrollHeight.current =
window.pageYOffset ||
(document.documentElement || document.body.parentNode || document.body)
.scrollTop;
};
useEffect(() => {
window.addEventListener("scroll", scroll);
return () => {
window.removeEventListener("scroll", () => {});
};
}, []);
return scrollHeight.current;
};
export default useScroll;
the value is not updating here.
but if I use useState here , it works. but that causes tremendous amount of component re-rendering. can you have any idea , how its happening?
Since the hook won't rerender you will only get the return value once. What you can do, is to create a useRef-const in the useScroll hook. The useScroll hook returns the reference of the useRef-const when the hook gets mounted. Because it's a reference you can write the changes in the useScroll hook to the useRef-const and read it's newest value in a component which implemented the hook. To reduce multiple event listeners you should implement the hook once in the parent component and pass the useRef-const reference to the child components. I made an example for you.
The hook:
import { useCallback, useEffect, useRef } from "react";
export const useScroll = () => {
const userScrollHeight = useRef(0);
const scroll = useCallback(() => {
userScrollHeight.current =
window.pageYOffset ||
(document.documentElement || document.body.parentNode || document.body)
.scrollTop;
}, []);
useEffect(() => {
window.addEventListener("scroll", scroll);
return () => {
window.removeEventListener("scroll", scroll);
};
}, []);
return userScrollHeight;
};
The parent component:
import { SomeChild, SomeOtherChild } from "./SomeChildren";
import { useScroll } from "./ScrollHook";
const App = () => {
const userScrollHeight = useScroll();
return (
<div>
<SomeChild userScrollHeight={userScrollHeight} />
<SomeOtherChild userScrollHeight={userScrollHeight} />
</div>
);
};
export default App;
The child components:
export const SomeChild = ({ userScrollHeight }) => {
const someButtonClickHandlerWhichPrintsUserScrollHeight = () => {
console.log("userScrollHeight from SomeChild", userScrollHeight.current);
};
return (
<div style={{
width: "100vw",
height: "100vh",
backgroundColor: "aqua"
}}>
<h1>SomeChild 1</h1>
<button onClick={() => someButtonClickHandlerWhichPrintsUserScrollHeight()}>Console.log userScrollHeight</button>
</div>
);
};
export const SomeOtherChild = ({ userScrollHeight }) => {
const someButtonClickHandlerWhichPrintsUserScrollHeight = () => {
console.log("userScrollHeight from SomeOtherChild", userScrollHeight.current);
};
return (
<div style={{
width: "100vw",
height: "100vh",
backgroundColor: "orange"
}}>
<h1>SomeOtherChild 1</h1>
<button onClick={() => someButtonClickHandlerWhichPrintsUserScrollHeight()}>Console.log userScrollHeight</button>
</div>
);
};
import { useRef } from 'react';
import throttle from 'lodash.throttle';
/**
* Hook to return the throttled function
* #param fn function to throttl
* #param delay throttl delay
*/
const useThrottle = (fn, delay = 500) => {
// https://stackoverflow.com/a/64856090/11667949
const throttledFn = useRef(throttle(fn, delay)).current;
return throttledFn;
};
export default useThrottle;
then, in your custom hook:
const scroll = () => {
scrollHeight.current =
window.pageYOffset ||
(document.documentElement || document.body.parentNode || document.body)
.scrollTop;
};
const throttledScroll = useThrottle(scroll)
Also, I like to point out that you are not clearing your effect. You should be:
useEffect(() => {
window.addEventListener("scroll", throttledScroll);
return () => {
window.removeEventListener("scroll", throttledScroll); // remove Listener
};
}, [throttledScroll]); // this will never change, but it is good to add it here. (We've also cleaned up effect)

React: save ref to state in a custom hook

I want to create a ref to an element, save it in state and use it somewhere else, down the line. Here is what I have so far:
const Header = () => {
const topElement = useRef();
const { setRootElement } = useScrollToTop();
useEffect(() => {
setRootElement(topElement);
}, []);
return (
<div ref={topElement}>
...
</div>
)
}
The useScrollToTop hook:
export const useScrollToTop = () => {
const [rootElement, setRootElement] = useState();
const scrollToTop = () => {
rootElement.current.scrollIntoView();
};
return {
scrollToTop: scrollToTop,
setRootElement: setRootElement
};
};
And in a different component:
const LongList = () => {
const { scrollToTop } = useScrollToTop();
return (
<div>
....
<button onClick={() => scrollToTop()} />
</div>
);
}
The setRootElemet works okay, it saves the element that I pass to it but when I call scrollToTop() the element is undefined. What am I missing here?
As hooks are essentially just functions, there is no state shared between calls. Each time you call useScrollToTop you are getting a new object with its own scrollToTop and setRootElement. When you call useScrollToTop in LongList, the returned setRootElement is never used and therefore that instance rootElement will never have a value.
What you need to do is have one call to useScrollToTop and pass the returned items to their respective components. Also, instead of using a state in the hook for the element, you can use a ref directly and return it.
Putting these together, assuming you have an App structure something like:
App
Header
LongList
Hook:
export const useScrollToTop = () => {
const rootElement = useRef();
const scrollToTop = () => {
rootElement.current.scrollIntoView();
};
return {
scrollToTop,
rootElement,
};
};
App:
...
const { scrollToTop, rootElement } = useScrollToTop();
return (
...
<Header rootElementRef={rootElement} />
<LongList scrollToTop={scrollToTop} />
...
);
Header:
const Header = ({ rootElementRef }) => {
return (
<div ref={rootElementRef}>
...
</div>
);
}
LongList:
const LongList = ({ scrollToTop }) => {
return (
<div>
...
<button onClick={() => scrollToTop()} />
</div>
);
}
The issue probably is topElement would be null initially and useEffect would trigger setRootElement with null. You would need to keep topElement in state variable and check when it changes and set the value inside your JSX as
const [topElement, setTopElement] = useState(null);
useEffect(() => {topElement && setRootElement(topElement);}, [topElement])
return (
<div ref={(ref) => setTopElement(ref)}>
...
</div>
);

When accessing a state variable from a useCallback, value is not updated

At a certain place of my code I'm accessing a state variable of my component from a call back ( UserCallback ) and I find the state variable has not updated from the initial value and call back is referring to the initial value. As I read in the documentation when variable is passed as one of array items then it should update the function when it is updated. Following is a sample code.
const Child = forwardRef((props, ref) => {
const [count, setCount] = useState(0);
const node = useRef(null);
useImperativeHandle(ref, () => ({
increment() {
setCount(count + 1);
}
}));
const clickListener = useCallback(
e => {
if (!node.current.contains(e.target)) {
alert(count);
}
},
[count]
);
useEffect(() => {
// Attach the listeners on component mount.
document.addEventListener("click", clickListener);
// Detach the listeners on component unmount.
return () => {
document.removeEventListener("click", clickListener);
};
}, []);
return (
<div
ref={node}
style={{ width: "500px", height: "100px", backgroundColor: "yellow" }}
>
<h1>Hi {count}</h1>
</div>
);
});
const Parent = () => {
const childRef = useRef();
return (
<div>
<Child ref={childRef} />
<button onClick={() => childRef.current.increment()}>Click</button>
</div>
);
};
export default function App() {
return (
<div className="App">
<Parent />
</div>
);
}
What I'm originally building is a custom confirmation modal. I have a state variable which set either display:block or display:none to the root element. Then if there is a click outside the component I need to close the modal by setting state variable to false. Following is the original function.
const clickListener = useCallback(
(e: MouseEvent) => {
console.log('isVisible - ', isVisible, ' count - ', count, ' !node.current.contains(e.target) - ', !node.current.contains(e.target))
if (isVisible && !node.current.contains(e.target)) {
setIsVisible(false)
}
},
[node.current, isVisible],
)
It doesn't get closed because isVisible is always false which is the initial value.
What am I doing wrong here?
For further clarifications following is the full component.
const ConfirmActionModal = (props, ref) => {
const [isVisible, setIsVisible] = useState(false)
const [count, setCount] = useState(0)
const showModal = () => {
setIsVisible(true)
setCount(1)
}
useImperativeHandle(ref, () => {
return {
showModal: showModal
}
});
const node = useRef(null)
const stateRef = useRef(isVisible);
const escapeListener = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsVisible(false)
}
}, [])
useEffect(() => {
stateRef.current = isVisible;
}, [isVisible]);
useEffect(() => {
const clickListener = e => {
if (stateRef.current && !node.current.contains(e.target)) {
setIsVisible(false)
}
};
// Attach the listeners on component mount.
document.addEventListener('click', clickListener)
document.addEventListener('keyup', escapeListener)
// Detach the listeners on component unmount.
return () => {
document.removeEventListener('click', clickListener)
document.removeEventListener('keyup', escapeListener)
}
}, [])
return (
<div ref={node}>
<ConfirmPanel style={{ display : isVisible ? 'block': 'none'}}>
<ConfirmMessage>
Complete - {isVisible.toString()} - {count}
</ConfirmMessage>
<PrimaryButton
type="submit"
style={{
backgroundColor: "#00aa10",
color: "white",
marginRight: "10px",
margin: "auto"
}}
onClick={() => {console.log(isVisible); setCount(2)}}
>Confirm</PrimaryButton>
</ConfirmPanel>
</div>
)
}
export default forwardRef(ConfirmActionModal)
You assign a function clickListener to document.addEventListener on component mount, this function has a closure on count value.
On the next render, the count value will be stale.
One way to solve it is implementing a function with refernce closure instead:
const Child = forwardRef((props, ref) => {
const [count, setCount] = useState(0);
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
useEffect(() => {
// countRef.current always holds the most updated state
const clickListener = e => {
if (!node.current.contains(e.target)) {
alert(countRef.current);
}
};
document.addEventListener("click", clickListener);
return () => {
document.removeEventListener("click", clickListener);
};
}, []);
...
}
You can pass a callback to setIsvisible so you don't need isVisible as a dependency of the useCallback. Adding node.current is pointless since node is a ref and gets mutated:
const clickListener = useCallback((e) => {
setIsVisible((isVisible) => {//pass callback to state setter
if (isVisible && !node.current.contains(e.target)) {
return false;
}
return isVisible;
});
}, []);//no dependencies needed
While your clickListener does change when count changes you only bind the initial clickListener once on mount because your useEffect dependency list is empty. You could ad clickListener to the dependency list as well:
useEffect(() => {
// Attach the listeners on component mount.
document.addEventListener("click", clickListener);
// Detach the listeners on component unmount.
return () => {
document.removeEventListener("click", clickListener);
};
}, [clickListener]);
Side note: using node.current in a dependency list doesn't do anything as react does not notice any changes to a ref. Dependencies can only be state or props.

OutSider click event using React Hook

I am trying to develop a click event handler function for the DOM element so that when I click to the outside of the div the corresponding dom element close. I have been trying the following code but I am getting the error of TypeError: node.contains is not a function. Not sure if I am doing it correctly with the react hook. Any kinds of help would be really appreciated.
import React, { useState, useEffect, useRef } from 'react';
const OutSiderClickComponent = () => {
const [visible, setVisible] = useState(false);
const node = useRef();
const handleClick = () => {
if (!visible) {
document.addEventListener('click', handleOutsideClick, false);
} else {
document.removeEventListener('click', handleOutsideClick, false);
}
setVisible(prevState => ({
visible: !prevState.visible,
}));
}
const handleOutsideClick = (e) => {
if (node.contains(e.target)) {
return;
}
handleClick();
}
return(
<div ref={node}>
<button onClick={handleClick}>Click to See</button>
{visible && <div>You Clicked the Button</div>}
</div>
);
};
export default OutSiderClickComponent;
When you use useRef you need to remember that the value is in current attribute of ref.
Try node.current.contains().
The rest should look more like that, using React.useEffect:
const handleOutsideClick = (e) => {
if (node.current.contains(e.target)) {
console.log('clicked inside');
// this.setVisible(true);
} else {
this.setVisible(false);
}
}
React.useEffect(() => {
document.addEventListener('click', handleOutsideClick, false);
return () => void document.removeEventListener('click', handleOutsideClick, false);
}, []);
and
<button onClick={() => void setVisible(true)}>Click to See</button>
There are two changes. First, you need to use node.current to check for the ref,
node.current.contains(e.target) . Also the ref must be attached to the node to which you need to detect outside click
var { useState, useEffect, useRef } = React;
const OutSiderClickComponent = () => {
const [visible, setVisible] = useState(false);
const node = useRef();
const handleClick = () => {
if (!visible) {
document.addEventListener('click', handleOutsideClick, false);
} else {
document.removeEventListener('click', handleOutsideClick, false);
}
setVisible(prevState => ({
visible: !prevState.visible,
}));
}
const handleOutsideClick = (e) => {
if (node.current.contains(e.target)) {
return;
}
setVisible(prev => !prev.visible)
}
return(
<div>
<button onClick={handleClick}>Click to See</button>
{visible && <div ref={node}>You Clicked the Button</div>}
</div>
);
};
ReactDOM.render(<OutSiderClickComponent />, document.getElementById('app'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.3/umd/react-dom.production.min.js"></script>
<div id="app" />

Strange React hooks behavior, can't access new state from a function

I use the library react-use-modal, and
I'm trying to read the updated value of confirmLoading when inside the handleClick function.
handleClick does read the first value of confirmLoading defined when doing const [ confirmLoading, setConfirmLoading ] = useState(false), but never updates when I setConfirmLoading inside handleOk.
I don't understand what I'm doing wrong
import { Button, Modal as ModalAntd } from 'antd'
import { useModal } from 'react-use-modal'
export interface ModalFormProps {
form: React.ReactElement
}
export const ModalForm: React.FC = () => {
const [ confirmLoading, setConfirmLoading ] = useState(false)
const { showModal, closeModal } = useModal()
const handleOk = () => {
setConfirmLoading(true)
setTimeout(() => {
setConfirmLoading(false)
closeModal()
}, 1000)
}
const handleCancel = () => {
closeModal()
}
const handleClick = () => {
showModal(({ show }) => (
<ModalAntd
onCancel={handleCancel}
onOk={handleOk}
title='Title'
visible={show}
>
// the value of confirmLoading is always the one defined
// with useState at the beginning of the file.
<p>{confirmLoading ? 'yes' : 'no'}</p>
</ModalAntd>
))
}
return (
<div>
<Button onClick={handleClick}>
Open Modal
</Button>
</div>
)
}
This is happening because of closures. The component that you pass to showModal remembers confirmLoading and when you call function setConfirmLoading your component renders again and function handleClick is recreated. 'Old' handleClick and 'old' component in showModal know nothing about the new value in confirmLoading.
Try to do this:
export const ModalForm: React.FC = () => {
const { showModal, closeModal } = useModal();
const handleClick = () => {
showModal(({ show }) => {
const [ confirmLoading, setConfirmLoading ] = useState(false);
const handleOk = () => {
setConfirmLoading(true)
setTimeout(() => {
setConfirmLoading(false)
closeModal()
}, 1000)
};
const handleCancel = () => {
closeModal()
};
return (
<ModalAntd
onCancel={handleCancel}
onOk={handleOk}
title='Title'
visible={show}
>
// the value of confirmLoading is always the one defined
// with useState at the beginning of the file.
<p>{confirmLoading ? 'yes' : 'no'}</p>
</ModalAntd>
);
})
};
return (
<div>
<Button onClick={handleClick}>
Open Modal
</Button>
</div>
)
}

Resources