useOutSideClick

예전에 포트폴리오 프로젝트를 진행할 때 동료가 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 하나 호출하고 적용해서 편해졌습니다.

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를 제어합니다.