바깥 클릭 감지를 useEffect 없이 ref callback 하나로 만든다.

React 19 이상에서만 된다.

function useClickOutside(handler) {
  const handlerRef = useRef(handler)
  handlerRef.current = handler // stale closure 방지

  return useCallback((node) => {
    const handleClick = (e) => {
      if (!node.contains(e.target)) handlerRef.current()
    }

    document.addEventListener('mousedown', handleClick)
    return () => document.removeEventListener('mousedown', handleClick)
  }, [])
}

// 사용
function Modal({ onClose }) {
  const ref = useClickOutside(onClose)
  return <div ref={ref}>...</div>
}

모달을 열기 → 닫기 → 열기 → 닫기 → 열기 한 뒤 바깥을 클릭한다. React 19: 열 때 ref(node) 가 mousedown 리스너를 추가하고, 닫을 때 반환한 cleanup 이 제거해 남는 리스너가 1 → 0 → 1 → 0 → 1 이다. 바깥 클릭에 handler 가 한 번만 불린다. React 18 이하: 반환한 cleanup 은 무시되고, 닫을 때 같은 콜백이 ref(null) 로 다시 불려 본문이 또 돈다 — node 가 null 인 리스너가 하나 더 붙는다. 남는 리스너가 1 → 2 → 3 → 4 → 5 로 쌓이고, 바깥 클릭에 handler 가 세 번 불리고 null.contains 에서 TypeError 가 두 번 난다. 18 이하는 리스너를 별도 ref 에 저장하거나 useEffect 를 쓴다.

참고