본문으로 건너뛰기

useOutSideClick

· 약 7분
arch-spatula

예전에 포트폴리오 프로젝트를 진행할 때 동료가 custom hook을 추가했었습니다. 하지만 아쉬운 점이 많아서 개선한 버전을 다시 만들었습니다.

원래는 있는 줄 몰랐던 hook입니다.

nno3onn의 useOutsideClick - Codefolio

옛날 포트폴리오 프로젝트를 보니까 누군가가 custom hook을 폴더에서 대충 만들었습니다.

import { useEffect, RefObject } from 'react';

const useOutsideClick = <T extends HTMLElement>(
ref: RefObject<T>,
callback: () => void
) => {
const handleClick = (e: PointerEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
callback();
}
};

useEffect(() => {
window.addEventListener('pointerdown', handleClick);
return () => window.removeEventListener('pointerdown', handleClick);
}, [ref]);
};

export default useOutsideClick;

위가 코드 정의입니다. 다음은 코드를 호출하는 측면입니다. 어떻게 소비해야 하는지 제가 까먹었습니다.

useOutsideClick 사용법 - Codefolio

function Component() {
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const homeDropDownRef = useRef<HTMLUListElement>(null);

useOutsideClick(homeDropDownRef, () => setIsDropDownOpen(false));

return (
<HomeDropDownList ref={homeDropDownRef}>
{homeDropDownItems.map((item) => (
<DropDown
item={item}
key={item}
onClickHandler={onClickDropDownHandler}
/>
))}
</HomeDropDownList>
);
}

이렇게 사용하면 됩니다.

코드를 읽어보면 아쉬운 점이 상당히 많습니다. hook의 호출자 관점에서 제어하기 상당히 안 좋습니다. 닫기를 처리하는 로직인 () => setIsDropDownOpen(false)을 대입하기 때문에 설계가 잘 못되어 있습니다. 또 useState, useRef를 호출하고 적용해야 합니다. 처음부터 상호 의존적인 hook들을 따로 호출해서 조합해야 합니다. 모두 처음부터 추상화가 가능해 보입니다.

chakra-ui 참고

또 위 예시는 pointer down을 사용했습니다. 그래서 적용이 안됬습니다.

useOutsideClick - chakra-ui

chakra-ui는 mousedown을 활용했습니다.

그리고 원본도 mousedown을 사용하고 있었습니다.

import { useEffect } from 'react';

const useOutsideClick = (ref: any, callback: any) => {
const handleClick = (e: Event) => {
if (ref.current && !ref.current.contains(e.target)) {
callback();
}
};

useEffect(() => {
window.addEventListener('mousedown', handleClick);
return () => window.removeEventListener('mousedown', handleClick);
}, [ref, callback]);
};

export default useOutsideClick;

any script를 만들고 있지만 처음 만들었을 때가 올바른 방법입니다.

export function DropdownMenu({
menuItem,
direction = 'left',
}: DropdownMenuProps) {
const [isOpen, setIsOpen] = useState(false);

const toggleMenu = () => {
setIsOpen((prev) => !prev);
};

const toggleClose = () => {
setIsOpen(false);
};

const { customRef } = useOusSideClick<HTMLDivElement>(toggleClose);

return (
<DropdownMenuContainer ref={customRef}>
<DropdownOpen type="button" onClick={toggleMenu} isOpen={isOpen}>
<Icon />
</DropdownOpen>
{isOpen && <Menu menuItem={menuItem} direction={direction} />}
</DropdownMenuContainer>
);
}

function useOutsideClick<T extends HTMLElement>(handlerCallback: () => void) {
const customRef = useRef<T>(null);

const handleClick = useCallback(
(e: MouseEvent) => {
if (customRef.current?.contains(e.target as Node) === false) {
handlerCallback();
}
},
[handlerCallback]
);

useEffect(() => {
window.addEventListener('mousedown', handleClick);
return () => {
window.removeEventListener('mousedown', handleClick);
};
}, [handleClick]);

return { customRef };
}

일단 구현하고 추출까지 성공했습니다. 그리고 호출하는 사람이 타입을 정의하도록 제네릭도 추가했습니다. 이제 발톱때만큼 사람 흉내를 내기 시작했습니다.

하지만 아쉬운 부분이 있습니다. 여전히 이 hook을 handler 함수 아래에 위치 시켜야 한다는 점이 치명적인 단점입니다. 또 handler함수도 대입받아야 합니다.

custom hook 추출 리팩토링을 했는데 여전히 부족한 hook입니다. custom hook은 handler 함수를 인자로 받아야 하고 받기 위해서는 handler 영역 아래 호출할 수밖에 없는 이 구조는 코드 스펠의 원인이 됩니다. 즉 여전히 리팩토링 대상입니다.

custom hook: useOutSideClick

최종 형태입니다. dropdown, modal, accordion 등 컴포넌트 로직에 자주 사용하는 로직입니다. 모두 개별적으로 만들지 말고 이 hook 하나 호출하고 적용해서 편해졌습니다.

useOutSideClick.tsx
import { useCallback, useEffect, useRef, useState } from 'react';

type OutSideProviderProps = {
component: JSX.Element;
};

export function useOutsideClick<T extends HTMLElement>() {
const areaRef = useRef<T>(null);

const [isOpen, setIsOpen] = useState(false);

const handleClick = useCallback(
(e: MouseEvent) => {
if (areaRef.current?.contains(e.target as Node) === false) {
setIsOpen(false);
}
},
[setIsOpen]
);

useEffect(() => {
window.addEventListener('mousedown', handleClick);
return () => window.removeEventListener('mousedown', handleClick);
}, [handleClick]);

const handleClose = useCallback(() => {
setIsOpen(false);
}, [setIsOpen]);

const handleOpen = useCallback(() => {
setIsOpen(true);
}, [setIsOpen]);

const handleRevers = useCallback(() => {
setIsOpen((prev) => !prev);
}, [setIsOpen]);

const OutSideProvider = ({ component }: OutSideProviderProps) => {
return <>{isOpen && component}</>;
};

return {
customRef: areaRef,
isOpen,
handleClose,
handleOpen,
handleRevers,
OutSideProvider,
};
}

더 보완된 useOutsideClick입니다. 조건부 랜더링을 활용하는 점도 추상화했습니다. useRef, useState는 내부로 이동시키 handler로 state를 제어합니다.