🧠 React 햄버거 메뉴가 닫히지 않는 이유: 이벤트 전파와 상태 큐의 역습
✅ 문제 상황
React에서 햄버거 메뉴 UI를 만들고,
onClick으로 토글 상태를 변경해서 메뉴를 열고 닫는 구조를 만들었음.
그런데 메뉴를 열고 다시 닫으면 계속 열려 있는 버그가 발생함.
✅ 초기 구조
- 햄버거 버튼 클릭 → setToggle(!false)
- useOutsideClick 훅으로 외부 클릭 시 메뉴를 닫음 (setToggle(false))
- 조건부 렌더링으로 toggle === true일 때 메뉴 DOM 표시
-
import { useEffect, useRef, useState } from "react";import { HEADER_MENU } from "../constance/header";import useOutsideClick from "../hooks/useOutsideClick";
function Header() {const mobileRef = useRef<HTMLDivElement | null>(null);const [toggle, setToggle] = useState<boolean>(false);useOutsideClick(mobileRef, () => setToggle(false), toggle);
const handleToggle = () => {console.log("닫기");console.log("열열기");setToggle(!false);};
useEffect(() => {let timer: ReturnType<typeof setTimeout> | null = null;const handleResize = () => {if (timer) clearTimeout(timer);timer = setTimeout(() => {if (window.innerWidth >= 768) {setToggle(false);}}, 100);};window.addEventListener("resize", handleResize);return () => {window.removeEventListener("resize", handleResize);if (timer) clearTimeout(timer);};}, []);
return (<header className="fixed top-0 left-0 right-0 max-w-7xl mx-auto bg-white"><div className="flex items-center justify-between"><div><img src="/asset/logo/logo.svg" alt="logo" /></div><nav><ul className="hidden md:flex gap-4">{HEADER_MENU.map((menu, i) => (<li key={`menu-${menu}-${i}`} className="text-secondary"><span className="text-primary">{i + 1}.</span>{menu}</li>))}</ul><button className="md:hidden px-2" onClick={handleToggle}>{toggle ? "X" : "☰"}</button>{toggle && (<divref={mobileRef}className="fixed top-20 left-0 w-full border-b py-2 items-center bg-white z-50 flex flex-col">{/* 햄버거 클릭 시 열리는 메뉴 */}<ul className="flex flex-col gap-2 cursor-pointer w-full justify-center items-center ">{HEADER_MENU.map((menu, i) => (<li key={`menu-${menu}-${i}`} className="text-secondary"><span className="text-primary">{i + 1}.</span>{menu}</li>))}</ul></div>)}</nav></div></header>);}
export default Header;
❌ 문제 발생 흐름 요약
- 버튼 클릭 시 setToggle(prev => !prev) 실행 → 메뉴 열림
- 외부 클릭 감지를 위해 document.addEventListener('click') 또는 mousedown 사용
- 메뉴를 닫으려고 버튼을 다시 클릭했는데도 메뉴가 닫히지 않고 계속 열려 있음
🔍 원인을 파악하는 데 오래 걸린 이유
이벤트 전파와 React의 상태 처리 타이밍이 꼬였기 때문
- onClick은 클릭 이벤트가 버블링된 이후, 즉 늦게 실행됨
- 반면, document.addEventListener('mousedown')는 브라우저에서 먼저 실행됨
- 즉, 외부 클릭 감지 로직이 먼저 실행되고 → 버튼 클릭이 나중에 실행됨
⚠️ 실제로 벌어진 순서
- 햄버거 버튼 클릭 시 → 이벤트 전파가 document까지 도달
- useOutsideClick 훅에서 외부 클릭으로 간주 → setToggle(false)
- 그 이후 onClick 핸들러 실행 → setToggle(prev => !prev) → 다시 true로 바뀜
❗ 즉, false로 바꾸는 업데이트가 먼저 큐에 들어가고,
그다음에 prev => !prev가 실행되며 결국 true가 되어버림
🧩 큐의 상태
txt
복사편집
1. setToggle(false) // 외부 클릭 감지기 2. setToggle(prev => !prev) // 버튼 클릭 → prev = false → true
결과적으로, 마지막 상태는 true가 되어 메뉴가 닫히지 않고 계속 열려 있는 현상 발생
✅ 해결 방법
- 외부 클릭 감지를 click 대신 mousedown으로 변경함
- 버튼 이벤트는 mouseup 또는 click으로 유지
- 필요 시 stopPropagation()으로 이벤트 전파를 방지하여 중첩 방지
import { useEffect, useRef, useState } from "react";import { HEADER_MENU } from "../constance/header";import useOutsideClick from "../hooks/useOutsideClick";
function Header() {const mobileRef = useRef<HTMLDivElement | null>(null);const [toggle, setToggle] = useState<boolean>(false);useOutsideClick(mobileRef, () => setToggle(false), toggle);
const handleToggle = (e: React.MouseEvent) => {e.stopPropagation();setToggle((prev) => !prev);};
useEffect(() => {let timer: ReturnType<typeof setTimeout> | null = null;const handleResize = () => {if (timer) clearTimeout(timer);timer = setTimeout(() => {if (window.innerWidth >= 768) {setToggle(false);}}, 100);};window.addEventListener("resize", handleResize);return () => {window.removeEventListener("resize", handleResize);if (timer) clearTimeout(timer);};}, []);
return (<header className="fixed top-0 left-0 right-0 max-w-7xl mx-auto bg-white"><div className="flex items-center justify-between"><div><img src="/asset/logo/logo.svg" alt="logo" /></div><nav><ul className="hidden md:flex gap-4">{HEADER_MENU.map((menu, i) => (<li key={`menu-${menu}-${i}`} className="text-secondary"><span className="text-primary">{i + 1}.</span>{menu}</li>))}</ul><button className="md:hidden px-2" onMouseDown={handleToggle}>{toggle ? "X" : "☰"}</button>{toggle && (<divref={mobileRef}className="fixed top-20 left-0 w-full border-b py-2 items-center bg-white z-50 flex flex-col">{/* 햄버거 클릭 시 열리는 메뉴 */}<ul className="flex flex-col gap-2 cursor-pointer w-full justify-center items-center ">{HEADER_MENU.map((menu, i) => (<li key={`menu-${menu}-${i}`} className="text-secondary"><span className="text-primary">{i + 1}.</span>{menu}</li>))}</ul></div>)}</nav></div></header>);}
export default Header;
💡 얻은 교훈
- React의 onClick은 브라우저의 mousedown보다 늦게 실행된다
- 상태 업데이트(setState)는 비동기이며, 동시에 여러 개가 큐에 들어가면 순서에 따라 꼬일 수 있다
- 외부 클릭 감지는 mousedown을 사용해야 안정적이다
- setState(prev => !prev) 같은 함수형 업데이트는 예상치 못한 상태 덮어쓰기를 일으킬 수 있다
'리액트' 카테고리의 다른 글
eslint airbnb 8 설치 (1) | 2025.04.20 |
---|---|
넷플릭스 클론 프로젝트 회고 (0) | 2025.03.15 |
넷플릭스 클론 프로젝트 하면서 어려운 점 회고 (0) | 2025.03.15 |
포켓몬 API 활용한 포켓몬 도감 토이 프로젝트에 대한 회고 (1) | 2025.02.09 |
포켓몬 API 토이 프로젝트를 하면서 문제점 (0) | 2025.02.08 |