본문 바로가기
REACT

[React + TypeScript] 커스텀 훅을 이용해 드롭다운 구현해보기

by 혜o_n 2025. 3. 20.

새로운 프로젝트를 시작했다!! 😊

오늘은 드롭다운 기능을 구현해보려고 한다

 

드롭다운 기능을 생각해보니 구현되어하는 기능은..

 

1. 드롭다운 영역 클릭 시 드롭다운의 메뉴가 열려야 함

2. 드롭다운 영역의 바깥 부분을 클릭하면 드롭다운이 닫혀야 함

3. 드롭다운 메뉴를 클릭하면 드롭다운이 닫혀야 함

 

내가 맡은 부분에는 드롭다운이 사용되는 곳이 한 부분밖에 없었지만

다른 팀원의 부분에서는 많이 사용되었다.

 

그래서 코드의 중복을 막고자 커스텀 훅(Custom Hook)으로 구현해보고자 한다!

 

최종 모습

🖱️코드

주 기능은 특정 요소의 바깥 부분의 클릭을 감지하고 모달을 닫는 것이기 때문에 useDetectClose()라고 할 것이다!

먼저, useDetectClose()로 어떤 요소(ref)의 이벤트를 감지할 것인지, 초기 상태(initialState)를 어떻게 둘 것인지를 인자로 받는다.

const useDetectClose = (ref: RefObject<HTMLElement>, initialState: boolean) => {
...
}

 

 

드롭다운의 열고 닫음을 관리할 상태인 isOpenuseState를 이용해서 만든다.

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

 

 

요소의 바깥 부분을 클릭을 감지하면 모달이 닫히도록 한다!

const onClick = (e: MouseEvent) => {
  if(ref.current && !ref.current.contains(e.target as Node)){
    setIsOpen(false);
  }
}

☑️ MouseEvent를 명시해서 TypeScript에서 올바르게 처리되도록 함

☑️ e.target은 EventTarget 타입이기 때문에 Node로 type assertion

(contains()Node 또는 HTMLElement에서 제공하는 함수이기 때문에 EventTarget에서 더 구체적인 타입으로 변환

HTMLElement ⊂ Node ⊂ EventTarget 이므로, HTMLElement로 할 경우 TextNode, CommentNode 등을 포함하지 않아 오류가 생길 수 있다! 따라서 Node로 타입을 단언해준다)

 

 

마지막으로 useEffect를 이용해 EventListener를 추가하고 정리해준다.

  useEffect(() => {
    const onClick = (e: MouseEvent) => {
      if(ref.current && !ref.current.contains(e.target as Node)){
        setIsOpen(false);
      }
    }

    if(isOpen){
      window.addEventListener("click", onClick);
    }

    return () => {
      window.removeEventListener("click", onClick);
    };
  }, [isOpen, ref]);

☑️ isOpen이 true일 때 window에 클릭 이벤트 리스너를 추가

☑️ 클린업 함수를 사용해 isOpen이 false이면 EventListener를 제거해 불필요한 이벤트 호출을 방지

 

 

상태와 상태 변경 함수를 반환!

return [isOpen, setIsOpen] as const;

☑️ 커스텀 훅을 사용하는 곳에서 isOpen과 setIsOpen을 사용할 수 있도록 return

☑️ as const를 통해 튜플로 반환해 안정적으로 사용할 수 있도록 한다.

🅰️ 전체 코드

import { RefObject, useEffect, useState } from "react";

// ref 요소 외부 클릭 시 false로 상태 변경하는 custom hook
const useDetectClose = (ref: RefObject<HTMLElement>, initialState: boolean) => {
  const [isOpen, setIsOpen] = useState(initialState);

  useEffect(() => {
    const onClick = (e: MouseEvent) => {
      if(ref.current && !ref.current.contains(e.target as Node)){
        setIsOpen(false);
      }
    }

    if(isOpen){
      window.addEventListener("click", onClick);
    }

    return () => {
      window.removeEventListener("click", onClick);
    };
  }, [isOpen, ref]);

  return [isOpen, setIsOpen] as const;
}

export default useDetectClose;

 

🤍사용 예제 (최종 모습의 코드)🤍

(모달 부분만 가지고 왔습니다!)

import React, { useRef, useState } from 'react';
import { FiChevronDown, FiChevronUp, FiSearch } from "react-icons/fi";
import useDetectClose from '../../hooks/useDetectClose';

const SearchBar = () => {
  ...

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const dropdownRef = useRef<HTMLDivElement>(null!);
  const [isDropOpen, setIsDropOpen] = useDetectClose(dropdownRef, false);
  const [dropMenu, setDropMenu] = useState('입출금명 + 비고');
  const dropMenuList:string[] = ['입출금명 + 비고', '입출금명', '비고', '금액'];
  const setDrop = (dropMenu: string) => {
    setDropMenu(dropMenu);
    setIsDropOpen(!isDropOpen);
  }

  return (
    <div className='flex gap-2 items-center w-full justify-end'>
      <div ref={dropdownRef} className='relative'>
        {/* 드롭다운 */}
        <button
          onClick={() => setIsDropOpen(!isDropOpen)}
          className="border rounded-md px-3 py-1 border-gray w-32 max-w-36 text-12 flex justify-between items-center"
        >
          {dropMenu}
          {!isDropOpen ? <FiChevronDown className="text-gray-500" /> : <FiChevronUp className="text-gray-500" />}
        </button>
        {isDropOpen && (
          <ul className="absolute left-0 mt-1 bg-white border border-gray rounded-md w-32 max-w-36 shadow-md z-10 text-12 top-full">
            {dropMenuList.map((value, idx) => (
              <li
                key={idx}
                onClick={() => setDrop(value)}
                className="px-3 py-2 hover:bg-gray-100 cursor-pointer"
              >
                {value}
              </li>
            ))}
          </ul>
        )}

      </div>
      ...
    </div>
  );
}

export default SearchBar;

'REACT' 카테고리의 다른 글

[AXIOS] Request params serializer (파라미터 직렬화)  (0) 2025.04.11

댓글