DetectOutsideClick() 구현과 useRef, useEffect 의 관계

wgnator·2022년 8월 17일
2

사용자가 어떠한 엘레먼트 바깥쪽을 클릭하는 감지하는 기능을 어떻게 구현해야 할까. 예를 들어 선택 엘레먼트(select-option)를 사용중이다 밖을 클릭했을때 드롭다운을 닫아주는 기능 말이다.

여러가지 방법이 있겠지만, 최근 나는 window.eventListener 에 마우스 클릭을 감지하여 그 엘레먼트의 밖인지 아닌지를 판단하는 방법으로 하게 되었다. 이유는, 만약 해당 엘레멘트의 상위 엘레멘트에서 onclick을 감지하는 방식으로 구현한다면 그 엘레먼트가 하나의 컴포넌트로 분리하여 재사용 할 경우, 컴포넌트 밖에서 그 기능을 따로 구현해 주어야 해서 컴포넌트의 독립성이 떨어지고 결합도가 증가하기 때문이다.

따라서, 이러한 기능을 필요로 하는 컴포넌트에서 독립적으로 가져와 사용할 수 있는 다음과 같은 커스텀 훅을 만들어 보았다.

import React, { useEffect } from "react";

export default function useDetectOutsideClick(targetElements: React.RefObject<HTMLElement | null>[], nextAction: () => void) {
  useEffect(() => {
    const hasClickedOutsideElement = (event: MouseEvent, targetElement: HTMLElement | null) => {
      return !!(targetElement && !targetElement.contains(<Node>event?.target));
    };
    const clickedOutsideElementListener = (event: MouseEvent) => {
      const hasClickedOutsideAllElements = targetElements.map((ref) => hasClickedOutsideElement(event, ref.current)).includes(false) ? false : true;
      if (hasClickedOutsideAllElements) nextAction();
    };
    window.addEventListener("click", clickedOutsideElementListener);
    return () => window.removeEventListener("click", clickedOutsideElementListener);
  }, []);
}

이 훅을 만들면서 한가지 난관이 있었는데, 처음에는 당연히 로직적인 함수는 밖에다 선언하고 useEffect 안에는 event listener 등록만 하면 된다고 생각했는데, 자꾸 ref로 들어가는 인자가 비어있는 오류가 생겼다. 문제는 hasClickedOutsideElement의 targetElement 가 RefObject 라는 점이었다.

함수형 컴포넌트는 매 렌더시마다 순차적으로 훅을 호출한다. 여기서 알아야 할 점은 useRef 로 등록한 RefObject를 특정 엘레먼트의 ref로 사용하는 경우, 그 엘레멘트와의 연결고리가 함수 호출이 끝날 때 반환되는 JSX에서 생성이 된다는 점, 따라서 그 전까지는 ref.current 에는 빈 초기값이 들어있다는 점이다. 그래서, 컴포넌트 내부에서 이 훅을 호출할 시, ref.current 는 비어있게 된다. 따라서, 이 ref.current 를 사용하는 모든 함수는 렌더링 후에 호출되는 useEffect 안에 정의해서 사용하여야 한다.

사용:

export default function SelectBox({ children }: { children: ReactElement[] }) {
  const [isSelecting, setIsSelecting] = useState(false);
  const containerRef = useRef<HTMLDivElement>(null);

  useDetectOutsideClick([containerRef], () => setIsSelecting(false));

  return (
    <Container
      ref={containerRef}
      onClick={(event) => {
        event.stopPropagation();
        setIsSelecting(!isSelecting);
      }}
    >
    ...)
}

이 훅을 이용하니 프로젝트 내의 많은 컴포넌트 내부가 훨씬 간결해졌다.

또 한가지 깨달은 점은, useRef는 보통 두가지 쓰임새(리렌더를 일으키지 않는 지속되는 변수사용 / 특정 엘레멘트에 대한 직접 접근을 위해 ref로 사용) 가 있는데, 이에 대한 타입선언이 다르다는 것이다.

다음은 리액트 코드 index.d.ts 의 내부이다:

/**
     * `useRef` returns a mutable ref object whose `.current` property is initialized to the passed argument
     * (`initialValue`). The returned object will persist for the full lifetime of the component.
     *
     * Note that `useRef()` is useful for more than the `ref` attribute. It’s handy for keeping any mutable
     * value around similar to how you’d use instance fields in classes.
     *
     * @version 16.8.0
     * @see https://reactjs.org/docs/hooks-reference.html#useref
     */
    function useRef<T>(initialValue: T): MutableRefObject<T>;
    // convenience overload for refs given as a ref prop as they typically start with a null value
    /**
     * `useRef` returns a mutable ref object whose `.current` property is initialized to the passed argument
     * (`initialValue`). The returned object will persist for the full lifetime of the component.
     *
     * Note that `useRef()` is useful for more than the `ref` attribute. It’s handy for keeping any mutable
     * value around similar to how you’d use instance fields in classes.
     *
     * Usage note: if you need the result of useRef to be directly mutable, include `| null` in the type
     * of the generic argument.
     *
     * @version 16.8.0
     * @see https://reactjs.org/docs/hooks-reference.html#useref
     */
    function useRef<T>(initialValue: T|null): RefObject<T>;
    // convenience overload for potentially undefined initialValue / call with 0 arguments
    // has a default to stop it from defaulting to {} instead
    /**
     * `useRef` returns a mutable ref object whose `.current` property is initialized to the passed argument
     * (`initialValue`). The returned object will persist for the full lifetime of the component.
     *
     * Note that `useRef()` is useful for more than the `ref` attribute. It’s handy for keeping any mutable
     * value around similar to how you’d use instance fields in classes.
     *
     * @version 16.8.0
     * @see https://reactjs.org/docs/hooks-reference.html#useref
     */
    function useRef<T = undefined>(): MutableRefObject<T | undefined>;

보다시피 처음 useRef를 호출할때 넘겨주는 초기 값이 특정 값인지, undefined 인지, null 인지에 따라서 뒤에 타입 선언이 달라지는 것을 볼 수 있다. 내용을 살펴보니, 일반적으로 엘레먼트와 연결하는 ref prop에 사용할 경우는 RefObject<T | null> 이어서, 초기 값을 useRef(null) 로 사용하는 것을 기본으로 하고 있고, 지속되는 값으로 사용할 경우는 MutablaRefObject로 사용하며 초기값은 undefined 도 허용하니 따로 없이도 사용 가능하다.

profile
A journey in frontend world

2개의 댓글

comment-user-thumbnail
2022년 11월 4일

초반만 읽어봤지만 onBlur 로는 해결되지 않는 문제인건가? ㅎㅎ https://reactjs.org/docs/events.html#onblur

답글 달기
comment-user-thumbnail
2022년 11월 29일

감사합니다

답글 달기