useOnClickOutside에서 click 이벤트 적용(feat. 이벤트 3단계 흐름)

김준엽·2023년 3월 2일
2

React

목록 보기
11/11

한 영역이외 부분에 클릭을 하면 이벤트 핸들러가 실행되는 유용한 리액트 hook useOnClickOutside가 있다. 이 hook에 click 이벤트를 적용하니 제대로 동작하지 않는다.

문제의 원인과 해결방법은 무엇이고 그 문제를 해결하기 위해 필요한 개념인 이벤트 3단계 흐름을 살펴보겠다.

이벤트 3단계 흐름

표준 DOM 이벤트에서 정의한 이벤트 흐름엔 3가지 단계가 있다.

  1. 캡처링 단계 - 이벤트가 하위 요소로 전파되는 단계
  2. target 단계 - 이벤트가 실제 타킷 요소에 전달되는 단계
  3. 버블링 단계 - 이벤트가 상위 요소로 전파되는 단계

<td>를 클릭하면 이벤트가 최상위 요소에서 시작해 아래로 전파하고(캡처링 단계), 이벤트가 target 요소에 도착해 실행된 후(타깃 단계), 다시 위로 전파한다.(버블링 단계). 이런 과정을 통해 요소에 할당된 이벤트 핸들러가 호출된다.

요소에 이벤트 핸들러를 부착하는 일반적인 방법은 아래와 같다.

// 버블링 이벤트 핸들러 설정
elem.addEventListener(...)


// 캡처링 이벤트 핸들러 설정
elem.addEventListener(..., {capture: true})
// 아니면, 아래 같이 {capture: true} 대신, true를 써줘도 됩니다.
elem.addEventListener(..., true)

캡처링과 버블링 단계의 핸들러는 target단계에서 트리거된다.

간단한 예시를 통해 캡처링과 버블링이 실제로 일어나는지 보겠다.

<p>를 클릭하면 다음과 같은 순서로 이벤트를 전달한다.

  1. HTML → BODY → FORM → DIV (캡처링 단계, 첫 번째 리스너)
  2. P (target 단계, 캡쳐링과 버블링 둘 다에 리스너를 설정했기 때문에 두 번 호출된다.)
  3. DIV → FORM → BODY → HTML (버블링 단계, 두 번째 리스너)

useOnClickOutside에서 click 이벤트 적용

문제 발생

일반적인 useOnClickOutside hooks는 다음과 같다.

import { RefObject, useEffect } from "react";

export default function useOnClickOutside<T extends HTMLElement = HTMLElement>(
  ref: RefObject<T>,
  handler: (event: Event) => void
) {
  useEffect(() => {
    const listener = (event: Event) => {
      if (!ref.current || ref.current.contains(event.target as Node)) {
        return;
      }
      handler(event);
    };

    document.addEventListener("mousedown", listener);
    document.addEventListener("touchstart", listener);
    return () => {
      document.removeEventListener("mousedown", listener);
      document.removeEventListener("touchstart", listener);
    };
  }, [ref, handler]);
}

여기서 mousedown이벤트보다는 click이벤트가 보편적이니깐 click이벤트를 변경하고 싶었다. 하지만 원하는 데로 동작하지 않는다.

example

콘솔을 보면 "modal"로그는 뜨지만 모달이 보이지 않는다. 왜 일까?

원인과 해결

원인

  1. 모달 버튼을 누르면 모달이 등장한다.(isVisible = true)
  2. 그 후에 버블링에 따라 document에 있는 이벤트 핸들러가 실행된다.
  3. 그때 ref.current는 모달 컴포넌트로 존재한다. event.target는 모달 버튼이라서 if문을 만족하지 못하여 handler를 실행한다.
  4. 그러면 isVisible = false가 된다.
// useOnClickOutside.tsx(위의 codesandbox 코드 참조)
...
const listener = (event: Event) => {
  if (!ref.current || ref.current.contains(event.target as Node)) {
    return;
  }
  handler(event);
};
...

순간 보였다가 닫히게 된다.

해결

  • click이벤트를 안쓰고 mousedown이나 mouseup이벤트를 사용한다. -> 부적합

mousedown이벤트는 마우스를 누르는 순간 이벤트가 발생하고 mouseup이벤트는 마우스를 떼는 순간 이벤트가 발생한다. mouseup 이벤트가 click이랑 유사한 이벤트랑 생각할 수 있지만, 다른 곳에서 마우스를 누르고 mouseup 이벤트가 부착된 요소에서 마우스를 떼면 핸들러가 실행되기 때문에 다른 사용성을 가질 수 있다.

  • event.stopPropagation()를 사용해서 이벤트 전파를 멈춘다. -> 부적합

event.stopPropagation()를 모달 버튼 클릭 이벤트 핸들러 함수에 추가하면 해결된다. 이벤트 전파를 막으면 사용자가 페이지에서 어떤 이벤트를 했는 사용자 행동패턴을 분석하는 서비스를 이용하면 문제가 생긴다. 그리고 매번 target 요소의 이벤트 핸들러에 event.stopPropagation()를 적는 번거로움이 있다.

  • 캡처링 이벤트 핸들러 사용 -> 적합
// useOnClickOutside.tsx(위의 codesandbox 코드 참조)
...
document.addEventListener("click", listener, true);
return () => {
  document.removeEventListener("click", listener, true);
};
...

캡처링 이벤트 핸들러로 바꾸주면 된다. 흐름은 다음과 같다.

  1. 모달 버튼을 누른다.
  2. 캡처링에 따라 먼저 document에 있는 이벤트 핸들러가 실행된다.
  3. 지금은 ref.current는 null이라서 if문을 만족해서 핸들러는 실행되지 않는다.
  4. 이벤트가 전파되어서 target 요소의 이벤트 핸들러가 실행된다. 그래서 지금 모달이 등장한다.(target 단계)

이 방법이 제일 적합한 것 같다. 이벤트 3단계 흐름을 제대로 이해하면 짤 수 있는 코드다. 위의 codesandebox에서 코드를 한 번 바꿔보길 바란다.

profile
프론트엔드 개발자

0개의 댓글