본문 바로가기

리액트

React 햄버거 메뉴가 닫히지 않는 이유: 이벤트 전파와 상태 큐의 역습

🧠 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 && (
                            <div
                                ref={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;

❌ 문제 발생 흐름 요약

  1. 버튼 클릭 시 setToggle(prev => !prev) 실행 → 메뉴 열림
  2. 외부 클릭 감지를 위해 document.addEventListener('click') 또는 mousedown 사용
  3. 메뉴를 닫으려고 버튼을 다시 클릭했는데도 메뉴가 닫히지 않고 계속 열려 있음

🔍 원인을 파악하는 데 오래 걸린 이유

이벤트 전파와 React의 상태 처리 타이밍이 꼬였기 때문

  • onClick은 클릭 이벤트가 버블링된 이후, 즉 늦게 실행
  • 반면, document.addEventListener('mousedown')는 브라우저에서 먼저 실행됨
  • 즉, 외부 클릭 감지 로직이 먼저 실행되고 → 버튼 클릭이 나중에 실행됨

⚠️ 실제로 벌어진 순서

  1. 햄버거 버튼 클릭 시 → 이벤트 전파가 document까지 도달
  2. useOutsideClick 훅에서 외부 클릭으로 간주 → setToggle(false)
  3. 그 이후 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 && (
                            <div
                                ref={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) 같은 함수형 업데이트는 예상치 못한 상태 덮어쓰기를 일으킬 수 있다