새로운 프로젝트를 시작했다!! 😊
오늘은 드롭다운 기능을 구현해보려고 한다
드롭다운 기능을 생각해보니 구현되어하는 기능은..
1. 드롭다운 영역 클릭 시 드롭다운의 메뉴가 열려야 함
2. 드롭다운 영역의 바깥 부분을 클릭하면 드롭다운이 닫혀야 함
3. 드롭다운 메뉴를 클릭하면 드롭다운이 닫혀야 함
내가 맡은 부분에는 드롭다운이 사용되는 곳이 한 부분밖에 없었지만
다른 팀원의 부분에서는 많이 사용되었다.
그래서 코드의 중복을 막고자 커스텀 훅(Custom Hook)으로 구현해보고자 한다!
✅ 최종 모습
🖱️코드
주 기능은 특정 요소의 바깥 부분의 클릭을 감지하고 모달을 닫는 것이기 때문에 useDetectClose()
라고 할 것이다!
먼저, useDetectClose()
로 어떤 요소(ref)의 이벤트를 감지할 것인지, 초기 상태(initialState)를 어떻게 둘 것인지를 인자로 받는다.
const useDetectClose = (ref: RefObject<HTMLElement>, initialState: boolean) => {
...
}
드롭다운의 열고 닫음을 관리할 상태인 isOpen
을 useState
를 이용해서 만든다.
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 |
---|
댓글