[React] 외부 클릭 감지 훅 만들기

2025. 5. 4. 20:40·React

모달 안에 있는 드롭다운이 열려있는 상태에서, 모달 내부지만 드롭다운 관련 요소들 외부를 클릭하면 드롭다운이 꺼지고, 모달 외부를 클릭하면 드롭다운과 모달이 함께 꺼지도록 구현하고 싶었다.

그래서 아래와 같은 로직을 이용했다.

  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      const modalContent = document.querySelector('[data-modal-container="true"]'); // 모달 공통 컴포넌트 컨테이너

      // 모달 내부 클릭인지 확인
      if (modalContent && modalContent.contains(event.target as Node)) {
        // 드롭다운이 열려있고, 클릭된 요소가 드롭다운 내부가 아닐 때만 드롭다운 닫기
        if (
          isStatusOpen &&
          !(event.target as Element).closest(`[data-schedule-id="${schedule.id}"]`)
        ) {
          setOpenStatusDropdownId(null);
        }
      }
    };

    document.addEventListener('mousedown', handleClickOutside);
    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
    };
  }, [isStatusOpen, setOpenStatusDropdownId, schedule.id]);

우선 모달의 컨테이너 부분에 data-modal-container="true" 속성을 추가한 다음 querySelector를 이용해서 모달의 컨테이너를 선택한다.

그리고 contains를 이용해 모달 내부를 선택했는지 여부를 판단한다.

만약 모달 내부 클릭이면, 그 안에서도 내가 연 드롭다운 관련 컴포넌트를 클릭했는지, 아닌지를 판단한다.

이 때는 closest 메서드를 이용한다.

❓ closest
event.target.closest() 메서드는 DOM API의 일부로, 특정 요소와 그 조상 요소들 중에서 지정된 CSS 선택자와 일치하는 가장 가까운 조상 요소를 찾는 기능을 한다.

element.closest(selectors);

element:검색을 시작할 요소
selectors: 찾고자 하는 요소와 일치하는 CSS 선택자 문자열
반환값: 주어진 CSS 선택자와 일치하는 가장 가까운 조상 요소 (자기 자신 포함). 일치하는 요소가 없으면 null 반환

드롭다운 관련 컴포넌트 css 선택자는 다음과 같이 설정했다.

        <div className="flex items-center justify-end mt-2 sm:mt-0 sm:min-w-[140px]">
          <div className="relative mr-3 z-20">
            <button
              onClick={toggleStatusDropdown}
              onKeyDown={handleKeyDown}
              className={`px-3 sm:px-4 py-1.5 sm:py-2 border rounded-md text-xs sm:text-sm flex items-center cursor-pointer ${getStatusTextClass(schedule.status)}`}
              aria-label="일정 상태 변경"
              aria-expanded={isStatusOpen}
              aria-haspopup="listbox"
              tabIndex={0}
              data-schedule-id={schedule.id}
            >
              {schedule.status}
              <HiChevronDown className="ml-1 w-3 h-3" />
            </button>

            {isStatusOpen && (
              <div
                className="bg-white border border-gray-200 rounded-md shadow-lg w-28 sm:w-36 z-50"
                role="listbox"
                aria-label="일정 상태 옵션"
                style={{
                  position: 'absolute',
                  right: '0',
                  top: 'calc(100% + 0.5rem)',
                  pointerEvents: 'auto',
                }}
                data-schedule-id={schedule.id}
              >
                {STATUS_OPTIONS.map((status) => (
                  <div
                    key={status}
                    className={`px-3 sm:px-4 py-1.5 sm:py-2 hover:bg-gray-100 cursor-pointer text-xs sm:text-sm ${
                      status === schedule.status ? 'font-medium ' + getStatusTextClass(status) : ''
                    }`}
                    onClick={(e) => handleStatusChange(status, e)}
                    onKeyDown={(e) => handleStatusOptionKeyDown(e, status)}
                    role="option"
                    aria-selected={status === schedule.status}
                    tabIndex={0}
                    data-schedule-id={schedule.id}
                  >
                    {status}
                  </div>
                ))}
              </div>
            )}
          </div>

드롭다운 요소들,이 요소들을 감싼 컨테이너, 드롭다운 버튼 이렇게 3개를 지정해줬다.

이 훅은 재사용할 일이 있을 것 같아 다음과 같이 컴포넌트로 분리했다.

import { useEffect } from 'react';

type ClickOutsideHandler = () => void;

interface UseClickOutsideOptions {
  containerSelector?: string;
  exceptSelector?: string;
  elementId?: string | number;
  isOpen?: boolean;
}

/**
 * 특정 영역 외부 클릭을 감지하는 훅
 * @param onClickOutside 외부 클릭 시 실행할 콜백 함수
 * @param options 설정 옵션
 * @param options.containerSelector 컨테이너 요소 선택자 (이 요소 내부 클릭만 감지)
 * @param options.exceptSelector 제외할 요소 선택자
 * @param options.elementId 제외할 요소의 ID
 * @param options.isOpen 드롭다운/모달 등이 열려있는지 여부
 */

export const useClickOutside = (
  onClickOutside: ClickOutsideHandler,
  options: UseClickOutsideOptions = {},
) => {
  const { containerSelector, exceptSelector, elementId, isOpen } = options;

  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      // 컨테이너가 지정된 경우 컨테이너 내부 클릭인지 확인
      if (containerSelector) {
        const container = document.querySelector(containerSelector);
        // 컨테이너가 없거나 컨테이너 외부 클릭인 경우 무시
        if (!container || !container.contains(event.target as Node)) {
          return;
        }
      }

      // 특정 요소를 제외하고 처리
      if (exceptSelector && elementId) {
        if ((event.target as Element).closest(`${exceptSelector}="${elementId}"`)) {
          return;
        }
      }

      onClickOutside();
    };

    if (isOpen === false) return;

    document.addEventListener('mousedown', handleClickOutside);

    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
    };
  }, [onClickOutside, containerSelector, exceptSelector, elementId, isOpen]);
};

 

  // 외부 클릭 감지 훅 사용
  useClickOutside(() => setOpenStatusDropdownId(null), {
    containerSelector: '[data-modal-container="true"]',
    exceptSelector: '[data-schedule-id',
    elementId: schedule.id,
    isOpen: isStatusOpen,
  });

모달 바깥을 클릭한 경우 이벤트 핸들러는 다음과 같이 구현했다.

  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
        onClose();
      }
    };

    const handleEscKey = (event: KeyboardEvent) => {
      if (event.key === 'Escape') {
        onClose();
      }
    };

    if (isOpen) {
      document.addEventListener('mousedown', handleClickOutside);
      document.addEventListener('keydown', handleEscKey);

      // 모달이 열릴 때 body 스크롤 방지
      document.body.style.overflow = 'hidden';
    }

    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
      document.removeEventListener('keydown', handleEscKey);

      // 모달이 닫힐 때 body 스크롤 복원
      if (isOpen) {
        document.body.style.overflow = '';
      }
    };
  }, [isOpen, onClose]);

모달이 열렸을 때 스크롤이 되면 불편할 수도 있으므로 overflow = 'hidden'으로 스크롤 방지를 해주는 것이 좋다.

 

저작자표시 비영리 변경금지 (새창열림)

'React' 카테고리의 다른 글

[React] useEffect의 클린업 함수  (2) 2025.06.20
[React] 얕은 복사와 불변성  (2) 2025.05.12
[React] 이벤트 핸들러에 함수를 전달하는 방식  (0) 2025.04.28
[React] 리액트의 폴더구조  (0) 2025.03.31
[React] Fragment  (0) 2025.03.31
'React' 카테고리의 다른 글
  • [React] useEffect의 클린업 함수
  • [React] 얕은 복사와 불변성
  • [React] 이벤트 핸들러에 함수를 전달하는 방식
  • [React] 리액트의 폴더구조
버그잡는고양이발
버그잡는고양이발
주니어 개발자입니다!
  • 버그잡는고양이발
    지극히평범한개발블로그
    버그잡는고양이발
  • 전체
    오늘
    어제
    • 분류 전체보기 (381)
      • React (16)
      • Next.js (5)
      • Javascript (5)
      • Typescript (4)
      • Node.js (2)
      • Cs (16)
      • 트러블 슈팅 (5)
      • Html (1)
      • Css (3)
      • Django (0)
      • vue (0)
      • Java (1)
      • Python (0)
      • 독서 (1)
      • 기타 (3)
      • 백준 (192)
      • swea (31)
      • 프로그래머스 (30)
      • 이코테 (4)
      • 99클럽 코테 스터디 (30)
      • ssafy (31)
      • IT기사 (1)
  • 블로그 메뉴

    • 홈
    • 태그
  • 인기 글

  • 태그

    99클럽
    개발자취업
    Til
    코딩테스트준비
    항해99
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
버그잡는고양이발
[React] 외부 클릭 감지 훅 만들기
상단으로

티스토리툴바