I am finding that I am reusing behaviour across an app that when a user clicks outside an element I can hide it.
With the introduction of hooks is this something I could put in a hook and share across components to save me writing the same logic in every component?
I have implemented it once in a component as follows.
const Dropdown = () => {
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const handleHideDropdown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsDropdownVisible(false);
}
};
const handleClickOutside = (event: Event) => {
if (
wrapperRef.current &&
!wrapperRef.current.contains(event.target as Node)
) {
setIsDropdownVisible(false);
}
};
useEffect(() => {
document.addEventListener('keydown', handleHideDropdown, true);
document.addEventListener('click', handleClickOutside, true);
return () => {
document.removeEventListener('keydown', handleHideDropdown, true);
document.removeEventListener('click', handleClickOutside, true);
};
});
return(
<DropdownWrapper ref={wrapperRef}>
<p>Dropdown</p>
</DropdownWrapper>
);
}
Detecting an outside click of a functional component Let's build an HTML tooltip by creating a React functional component named InfoBox . The tooltip will appear when the user clicks a button, and it will be closed if the user clicks outside of the tooltip component.
You can not use hooks outside a component function, it is simply how they work. But, you can make a composition of hooks. React relies on an amount and order of how hooks appear in the component function.
This is possible.
You can create a reusable hook called useComponentVisible
import { useState, useEffect, useRef } from 'react';
export default function useComponentVisible(initialIsVisible) {
const [isComponentVisible, setIsComponentVisible] = useState(initialIsVisible);
const ref = useRef<HTMLDivElement>(null);
const handleHideDropdown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsComponentVisible(false);
}
};
const handleClickOutside = (event: Event) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
setIsComponentVisible(false);
}
};
useEffect(() => {
document.addEventListener('keydown', handleHideDropdown, true);
document.addEventListener('click', handleClickOutside, true);
return () => {
document.removeEventListener('keydown', handleHideDropdown, true);
document.removeEventListener('click', handleClickOutside, true);
};
});
return { ref, isComponentVisible, setIsComponentVisible };
}
Then in the component you wish to add the functionality to do the following:
const DropDown = () => {
const { ref, isComponentVisible } = useComponentVisible(true);
return (
<div ref={ref}>
{isComponentVisible && (<p>Going into Hiding</p>)}
</div>
);
}
Find a codesandbox example here.
Well, after struggling this for a bit, I have come to the next workarround, IN ADITION to what Paul Fitzgerald did, and having in count that my answer includes transitions too
First, I want my dropdown to be closed on ESCAPE key event and mouse click outside. To avoid creating a useEffect per event, I ended with a helper function:
//useDocumentEvent.js
import { useEffect } from 'react'
export const useDocumentEvent = (events) => {
useEffect(
() => {
events.forEach((event) => {
document.addEventListener(event.type, event.callback)
})
return () =>
events.forEach((event) => {
document.removeEventListener(event.type, event.callback)
})
},
[events]
)
}
After that, useDropdown hook that brings all the desired functionality:
//useDropdown.js
import { useCallback, useState, useRef } from 'react'
import { useDocumentEvent } from './useDocumentEvent'
/**
* Functions which performs a click outside event listener
* @param {*} initialState initialState of the dropdown
* @param {*} onAfterClose some extra function call to do after closing dropdown
*/
export const useDropdown = (initialState = false, onAfterClose = null) => {
const ref = useRef(null)
const [isOpen, setIsOpen] = useState(initialState)
const handleClickOutside = useCallback(
(event) => {
if (ref.current && ref.current.contains(event.target)) {
return
}
setIsOpen(false)
onAfterClose && onAfterClose()
},
[ref, onAfterClose]
)
const handleHideDropdown = useCallback(
(event) => {
if (event.key === 'Escape') {
setIsOpen(false)
onAfterClose && onAfterClose()
}
},
[onAfterClose]
)
useDocumentEvent([
{ type: 'click', callback: handleClickOutside },
{ type: 'keydown', callback: handleHideDropdown },
])
return [ref, isOpen, setIsOpen]
}
Finally, to use this(it has some emotion styling):
//Dropdown.js
import React, { useState, useEffect } from 'react'
import styled from '@emotion/styled'
import { COLOR } from 'constants/styles'
import { useDropdown } from 'hooks/useDropdown'
import { Button } from 'components/Button'
const Dropdown = ({ children, closeText, openText, ...rest }) => {
const [dropdownRef, isOpen, setIsOpen] = useDropdown()
const [inner, setInner] = useState(false)
const [disabled, setDisabled] = useState(false)
const timeout = 150
useEffect(() => {
if (isOpen) {
setInner(true)
} else {
setDisabled(true)
setTimeout(() => {
setDisabled(false)
setInner(false)
}, timeout + 10)
}
}, [isOpen])
return (
<div style={{ position: 'relative' }} ref={dropdownRef}>
<Button onClick={() => setIsOpen(!isOpen)} disabled={disabled}>
{isOpen ? closeText || 'Close' : openText || 'Open'}
</Button>
<DropdownContainer timeout={timeout} isVisible={isOpen} {...rest}>
{inner && children}
</DropdownContainer>
</div>
)
}
const DropdownContainer = styled.div(
{
position: 'absolute',
backgroundColor: COLOR.light,
color: COLOR.dark,
borderRadius: '2px',
width: 400,
boxShadow: '0px 0px 2px 0px rgba(0,0,0,0.5)',
zIndex: 1,
overflow: 'hidden',
right: 0,
},
(props) => ({
transition: props.isVisible
? `all 700ms ease-in-out`
: `all ${props.timeout}ms ease-in-out`,
maxHeight: props.isVisible ? props.maxHeight || 300 : 0,
})
)
export { Dropdown }
And, to use it, simply:
//.... your code
<Dropdown>
<Whatever.Needs.To.Be.Rendered />
</Dropdown>
//... more code
Credits to this solution are for previous answer here, this entry in medium and this article.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With