[React] 드롭다운 헤더 클릭 시 popover 사라지게 만들기 (feat. useOutsideClick)
작업 내용
이번 next.js 프로젝트에서 "앨범 카드"라는 컴포넌트를 만드는 역할을 맡았다.
간략히 설명하자면,
위처럼 앨범 카드 컴포넌트가 있고, kebab 아이콘 버튼을 눌렀을 때, 카드 내용을 수정하기 / 삭제하기 버튼을 누를 수 있는 Popover를 띄우는 기능을 만들었다. 이 기능을 만들면서 나는 다음과 같은 로직으로 제작했다.
- Popover 노출 여부를 나타내는
isPopoverOpen
상태를 관리한다. - kebab 버튼을 클릭하면
isPopoverOpen
이true
로 설정되어 Popover 컴포넌트를 렌더링한다. - 다시 kebab 버튼을 클릭하면
false
가 되어 popover가 사라진다. useOutsideClick
커스텀 훅을 사용하여 Popover 컴포넌트의 외부를 클릭해도 Popover가 사라진다.
하지만 여기서 useOutsideClick
훅을 사용하면서 3번 기능에 문제가 발생했고, 삽질하면서 배운 점을 기록하고자 한다.
(별개로, useOutsideClick 훅을 사용하는 방법은 구글링하면 잘 나오니 이번 포스트에선 따로 언급하지 않는다)
문제 발생) Kebab 버튼을 클릭할 때 Popover가 사라지지 않는다.
useOutsideClick
을 사용한 Popover 컴포넌트는, 외부 영역을 누를 때 잘 사라지는 것을 볼 수 있는데, 이상하게 kebab 버튼을 클릭할 땐 사라지지 않는 문제가 발생했다.
해결 방법
결론부터 말하자면,
kebab 버튼을 클릭할 때 popover가 사라지게 하는 콜백 함수와 useOutsideClick
훅에서 실행할 콜백 함수(= 즉 외부를 클릭할 때 popover가 사라지게 하는 것)이 맞물려서 생긴 문제이다.
우선 kebab 버튼을 클릭할 때 popover가 사라지게 하는 코드는 다음과 같다.
// OptionsButton.tsx
// kebab 버튼 하위에 popover가 조건부 렌더링된다.
const OptionsButton = () => {
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const handleClickKebabButton = (e: React.MouseEvent<HTMLButtonElement>) => {
setIsPopoverOpen(!isPopoverOpen) // 👈 toggle state
};
return (
<div className={cx("kebabButton")}>
<button type="button" onClick={handleClickKebabButton}>
<Image src="/images/kebab.svg" width={16} height={16} alt="더보기" />
</button>
{isOpen && <Popover />}
</div>
);
};
export default OptionsButton;
kebab 버튼에 onClick을 달아주고, 클릭할 때마다 isPopoverOpen
이 토글되도록 했다.
즉, Popover가 나타나 있는 상황에서 다시 kebab 버튼을 클릭하면 isPopoverOpen
이 false
가 되어 Popover가 렌더링되지 않을 것이다.
외부를 클릭할 때도 Popover가 사라지게 하기 위해 사용한 useOutsideClick
훅 코드이다.
// useOutsideClick.ts
import { RefObject, useEffect } from "react";
type CallbackFunction = () => void;
const useOutsideClick = (
ref: RefObject<HTMLElement>,
callback: CallbackFunction,
) => {
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
callback();
}
};
useEffect(() => {
document.addEventListener("click", handleClick, { capture: true });
return () => {
document.removeEventListener("click", handleClick, { capture: true });
};
});
};
export default useOutsideClick;
해당 훅은 구글링해서 나온 코드를 그대로 사용했다.
Popover가 보여지는 상황에서 kebab 버튼을 누를 시 어떻게 동작할까?
1. 먼저 useOutsideClick
에서 onClose라는 콜백 함수를 전달한다. 이 콜백 함수는 Popover를 사라지게 해야 하므로 아래와 같다.
onClose={() => {
setIsPopoverOpen(false);
}}
그러면 외부를 클릭할 때, useOutsideClick
훅에 의해 isPopoverOpen
은 false로 설정된다.
그런데 여기서 끝이 아니라 아래의 단계가 추가로 진행된다.
2. kebab 버튼에 달아둔 onClick 이벤트 핸들러도 실행된다.
const handleClickOptionsButton = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
setIsOpen(!isOpen);
};
return (
<button type="button" onClick={handleClickOptionsButton}>
...
</button>
)
1, 2가 같이 실행되는 이유는 kebab 버튼은 Popover 내부가 아닌 바깥에 있기 때문에 외부로 인식되면서, 동시에 kebab 버튼에 달아둔 onClick 핸들러가 발동되기 때문이다.
즉, 1이 먼저 실행되고, 2가 실행됨에 따라 isPopoverOpen
은 false -> true
로 설정된다. 즉 개발자 입장에선 2번만 실행될 줄 알았지만, 이전에 1이 실행된 이후에 실행되기 때문에 사실상 Popover는 계속 true로 된다.
🙃 생각을 해봤을 때, 리액트는 state가 변할 때마다 매번 재렌더링되는 것이 아닌, 특정 batch size(또는 기간)로 모아둔 뒤 한꺼번에 업데이트한다고 배웠다. 그래서 isPopoverOpen
이 한 번에 true로 업데이트된 것 같다.
결국 디버깅할 때 깜빡임없이 항상 Popover가 노출되는 것으로 보여져서 에러를 잡아내는 게 삽질의 원인이었다 💦
해결 방법
useOutsideClick
의 callback 함수가 먼저 실행되는 것이 문제이므로, 간단하게 setTimeout
을 통해 지연시켜 kabab 버튼의 onClick 이벤트 핸들러가 먼저 실행되도록 해주니 해결됐다.
onClose={() => {
setTimeout(() => { return setIsPopoverOpen(false); }, 10); // 👈
}}
약간의 지연만 시켜주면 되기 때문에 time은 작은 값으로 주었다.
마치면서..
UX 향상을 위해 kabab 버튼을 클릭할 때도 Popover가 닫히는 게 더 좋다고 판단해서 이번 문제를 더 깊게 파봤다.
처음 문제를 직면했을 때, 구글링을 열심히 해봤으나 마땅한 해결방법을 찾지 못해 콘솔에 계속 찍어보기도 하고 렌더링 과정을 손코딩해보기도 하면서 삽질을 많이 한 것 같다 😂
하지만 이번 기회에 좀 더 리액트의 동작 과정을 알게 돼서 한 단계 성장할 수 있는 좋은 기회였다! 💪 💪