Tech Sep 15, 2026

React에서 부모의 onClick이 실행되는 이유

목록 안의 삭제 버튼을 눌렀는데 상세 화면까지 열리는 경우가 있습니다. 다음 코드처럼 버튼과 부모 항목에 각각 onClick을 지정하면 두 핸들러가 모두 실행됩니다.

function Item() {
  return (
    <div onClick={() => console.log('상세 화면 열기')}>
      주문 내역
      <button onClick={() => console.log('항목 삭제')}>
        삭제
      </button>
    </div>
  );
}

삭제 버튼을 클릭하면 항목 삭제, 상세 화면 열기 순서로 출력됩니다. 버튼의 핸들러에서 부모의 함수를 호출하지 않았는데도 부모의 onClick이 실행됩니다.

이 글에서는 브라우저의 클릭 이벤트가 React에 전달되고, React가 버튼과 부모의 onClick을 호출하기까지의 과정을 코드로 살펴봅니다. 이어서 stopPropagation()이 어디에서 이 실행을 멈추는지 확인해 보겠습니다.

브라우저가 클릭을 전달하는 순서

React의 코드를 보기 전에 브라우저가 DOM 리스너를 호출하는 순서부터 살펴보겠습니다. 클릭한 것은 버튼인데, 브라우저는 왜 window에서 버튼 방향으로 내려왔다가 다시 window로 올라갈까요?

클릭 대상은 전파가 시작되기 전에 정해집니다

브라우저는 마우스를 누르고 뗀 위치 등을 바탕으로 클릭 대상을 먼저 정합니다. 버튼을 누르고 떼었다면 이 버튼이 이벤트의 타깃이 됩니다. Pointer Events: 클릭 이벤트의 타깃 결정

타깃이 정해지면 브라우저는 버튼의 부모를 따라 document, window까지 이어지는 경로를 만듭니다. 따라서 window에서 호출을 시작하더라도 클릭할 버튼을 찾기 위해 문서 전체를 탐색하는 것은 아닙니다. 이미 정해진 경로에 등록된 리스너를 순서대로 호출합니다. DOM Standard: 이벤트 디스패치

리스너는 세 단계로 호출됩니다

전파가 중단되지 않았다면 하나의 click은 다음 순서로 전달됩니다.

  1. 캡처링: window → document → html → body → 부모 순서로 내려옵니다. 부모가 버튼보다 먼저 처리할 일이 있을 때 캡처 리스너를 사용합니다. DOM Level 2 Events: 캡처링
  2. 타깃 단계: 클릭한 버튼의 리스너를 호출합니다. 버튼에 캡처 리스너와 버블 리스너가 모두 있다면 캡처 리스너부터 호출합니다. 이때 event.eventPhase는 AT_TARGET입니다.
  3. 버블링: 부모 → body → html → document → window 순서로 올라갑니다. 부모에 등록한 일반적인 click 리스너는 이 단계에서 실행됩니다.

부모에 addEventListener('click', handler)로 등록하면 버블 단계에서 실행되고, { capture: true }를 지정하면 캡처 단계에서 실행됩니다. 여기서 살펴본 click은 버블링하는 이벤트이며, 모든 이벤트가 부모까지 올라가는 것은 아닙니다. 버블링 여부는 bubbles 값으로 구분합니다. DOM Standard: 이벤트 전파

같은 클릭을 부모에서도 처리할 수 있습니다

목록 안의 버튼은 항목을 선택하고, 목록은 어떤 항목이 선택됐는지 기록해야 할 수 있습니다. 클릭이 부모까지 전달되면 버튼은 선택만 처리하고, 목록은 여러 버튼의 클릭을 한곳에서 기록할 수 있습니다. DOM Level 2 Events 문서도 이벤트를 타깃에서 처리하는 방식과 상위 요소에서 모아 처리하는 방식을 함께 설명합니다. DOM Level 2 Events: 이벤트 흐름

목록의 버튼마다 리스너를 붙이지 않고 부모에 리스너 하나를 두는 방식을 이벤트 위임(event delegation)이라고 합니다. 처음에 본 React 예제도 이 흐름에서 시작합니다. 삭제 버튼의 click이 루트 컨테이너에 도달하면, React는 JSX에 작성한 버튼과 부모의 onClick을 찾습니다.

React의 캡처링과 버블링 구현

React는 브라우저의 이벤트 전파를 루트 컨테이너에서 받습니다. 캡처 단계와 버블 단계에 각각 리스너를 등록해 두고, 이벤트가 루트에 도달하면 해당 단계의 JSX 핸들러를 찾아 실행합니다.

루트에 캡처와 버블 리스너를 등록합니다

이 흐름은 listenToAllSupportedEvents()에서 시작합니다. 이 함수는 React가 지원하는 네이티브 이벤트를 루트 컨테이너에 등록합니다.

아래 코드는 React DOM v19.2.0 기준입니다. 하이드레이션이 완료된 화면의 일반적인 click 처리를 살펴봅니다. 타입 선언과 관련 없는 분기를 생략한 곳에는 편집 사실을 표시했습니다.

원문 코드: DOMPluginEventSystem.js의 listenToAllSupportedEvents, 432–459행

// listenToAllSupportedEvents 내부 발췌
allNativeEvents.forEach(domEventName => {
  if (domEventName !== 'selectionchange') {
    if (!nonDelegatedEvents.has(domEventName)) {
      listenToNativeEvent(domEventName, false, rootContainerElement);
    }
    listenToNativeEvent(domEventName, true, rootContainerElement);
  }
});

두 번째 인자가 false이면 버블 리스너, true이면 캡처 리스너입니다. click은 루트에 둘 다 등록됩니다. 일부 이벤트를 별도로 처리하는 분기가 있지만, 여기서는 클릭을 받은 다음 어떤 핸들러를 찾는지에 집중하겠습니다.

일반적인 click 처리에서 루트의 캡처 리스너는 onClickCapture를, 버블 리스너는 onClick을 찾아 실행하도록 React 내부 함수로 전달합니다. 버튼의 onClick이 실행될 때는 원본 이벤트가 이미 루트의 버블 리스너에 도달한 상태입니다. React 17의 캡처 처리 변경

버튼에서 부모까지 핸들러를 모읍니다

클릭을 처리하는 SimpleEventPlugin은 click에 대응하는 prop 이름을 onClick으로 정하고, accumulateSinglePhaseListeners()로 핸들러를 찾습니다.

이 함수에서 instance는 지금 확인 중인 Fiber입니다. Fiber는 React가 컴포넌트와 DOM 요소를 관리하는 내부 노드입니다. instance.return은 함수의 return 문이 아니라, 현재 Fiber의 부모 Fiber를 가리키는 필드입니다. 따라서 버튼 Fiber에서 시작한 뒤 instance.return을 대입할 때마다 부모 쪽으로 한 칸 이동합니다.

원문 코드: DOMPluginEventSystem.js의 accumulateSinglePhaseListeners, 712–832행

// accumulateSinglePhaseListeners의 일반적인 click 경로를 발췌·편집
const captureName = reactName !== null ? reactName + 'Capture' : null;
const reactEventName = inCapturePhase ? captureName : reactName;
let listeners = [];
let instance = targetFiber;
let lastHostComponent = null;

while (instance !== null) {
  const {stateNode, tag} = instance;
  if (
    (tag === HostComponent ||
      tag === HostHoistable ||
      tag === HostSingleton) &&
    stateNode !== null
  ) {
    lastHostComponent = stateNode;
    // 실험적 이벤트 핸들 처리 생략
    if (reactEventName !== null) {
      const listener = getListener(instance, reactEventName);
      if (listener != null) {
        listeners.push(
          createDispatchListener(instance, listener, lastHostComponent),
        );
      }
    }
  }
  // 다른 이벤트와 실험적 API의 처리 생략
  if (accumulateTargetOnly) {
    break;
  }
  // return 필드는 부모 Fiber를 가리킵니다.
  instance = instance.return;
}
return listeners;

첫 두 줄은 현재 단계에서 찾을 prop 이름을 정합니다. reactName이 onClick이면 뒤에 Capture를 붙여 onClickCapture를 만듭니다. inCapturePhase가 true이면 이 이름을, false이면 onClick을 사용합니다.

반복문은 각 Fiber에서 DOM 요소와 핸들러를 확인한 뒤 목록에 추가합니다.

  • DOM 요소 확인: Fiber 트리에는 함수 컴포넌트에 해당하는 노드도 있습니다. 위 조건문은 HostComponent(일반적인 <div>, <button>), HostHoistable(<title>처럼 별도로 배치되는 요소), HostSingleton(<html>, <body> 등)에 해당하고 stateNode가 있는 노드에서 핸들러를 찾습니다.
  • 핸들러 확인: getListener()가 현재 props에서 해당 핸들러를 읽습니다. 이 함수는 disabled가 지정된 일부 폼 요소의 특정 마우스 핸들러를 수집하지 않도록 null을 반환하기도 합니다.
  • 목록에 추가: createDispatchListener()는 {instance, listener, currentTarget}을 묶습니다. instance는 해당 Fiber, listener는 호출할 함수, currentTarget은 해당 DOM 요소입니다. Fiber는 전파 중단 여부를 확인할 때, DOM 요소는 핸들러를 호출하기 전에 event.currentTarget을 설정할 때 사용합니다.

이 대입이 반복될수록 버튼에서 부모 방향으로 이동합니다. 그래서 버튼의 핸들러가 먼저, 부모의 핸들러가 그다음에 배열에 들어갑니다. 캡처와 버블 모두 이 순서로 수집하고, 실행할 때 목록을 읽는 방향을 달리합니다.

getListener()가 읽는 것은 DOM 요소에 대응하는 Fiber의 props입니다. 그래서 <Button onClick={handler} />처럼 함수 컴포넌트에 넘긴 prop은 이 반복문에서 수집되지 않습니다. Button 내부에서 그 prop을 실제 DOM 요소에 연결하거나 직접 호출해야 합니다.

부모를 return으로 찾는다는 점은 Portal에서도 드러납니다. Portal의 내용을 DOM의 다른 위치에 렌더링해도 Fiber의 부모 관계는 그대로입니다. 그래서 Portal 내부를 클릭하면 DOM상으로는 조상이 아닌 React 부모의 핸들러까지 실행됩니다. React: Portal의 이벤트 전파

찾은 핸들러를 이벤트 객체와 함께 저장합니다

핸들러 목록을 반환받은 SimpleEventPlugin은 합성 이벤트를 만들고 둘을 함께 dispatchQueue에 넣습니다. 호출할 핸들러가 없다면 이벤트 객체도 만들지 않습니다.

원문 코드: SimpleEventPlugin.js의 핸들러 수집과 큐 등록, 211–229행

// 이벤트 타입 선언을 생략한 발췌
const listeners = accumulateSinglePhaseListeners(
  targetInst,
  reactName,
  nativeEvent.type,
  inCapturePhase,
  accumulateTargetOnly,
  nativeEvent,
);
if (listeners.length > 0) {
  const event = new SyntheticEventCtor(
    reactName,
    reactEventType,
    null,
    nativeEvent,
    nativeEventTarget,
  );
  dispatchQueue.push({event, listeners});
}

합성 이벤트(SyntheticEvent)는 React 핸들러에 전달하는 객체입니다. click에서는 SyntheticMouseEvent 생성자를 사용하며, 브라우저가 만든 원본 이벤트는 nativeEvent에 보관합니다. 이 객체가 뒤에서 stopPropagation()의 중단 상태도 기록합니다.

inCapturePhase는 현재 캡처 단계인지 나타냅니다. React는 캡처와 버블에서 각각 해당 단계의 핸들러를 모읍니다. 타깃만 수집할지 정하는 accumulateTargetOnly는 click에서 false이므로 부모까지 탐색합니다.

모은 핸들러를 차례로 호출합니다

수집이 끝나면 processDispatchQueue()가 큐의 각 항목을 processDispatchQueueItemsInOrder()로 넘깁니다. 배열에는 자식, 부모 순서로 들어 있으므로 캡처에서는 마지막 항목부터 읽어야 부모가 먼저 실행됩니다. 버블에서는 첫 항목부터 읽으면 자식이 먼저 실행됩니다.

원문 코드: DOMPluginEventSystem.js의 processDispatchQueueItemsInOrder, 266–311행

// 타입 선언과 개발 모드 분기를 생략한 실행 루프
let previousInstance;
if (inCapturePhase) {
  for (let i = dispatchListeners.length - 1; i >= 0; i--) {
    const {instance, currentTarget, listener} = dispatchListeners[i];
    if (instance !== previousInstance && event.isPropagationStopped()) {
      return;
    }
    executeDispatch(event, listener, currentTarget);
    previousInstance = instance;
  }
} else {
  for (let i = 0; i < dispatchListeners.length; i++) {
    const {instance, currentTarget, listener} = dispatchListeners[i];
    if (instance !== previousInstance && event.isPropagationStopped()) {
      return;
    }
    executeDispatch(event, listener, currentTarget);
    previousInstance = instance;
  }
}

캡처 반복문은 마지막 인덱스부터 값을 줄여 가고, 버블 반복문은 0부터 늘려 갑니다. 그 결과 캡처 핸들러는 부모부터, 버블 핸들러는 자식부터 호출됩니다.

중단 조건에는 instance !== previousInstance도 포함됩니다. 한 Fiber에서 여러 리스너를 수집할 수 있으므로, React는 다른 Fiber의 핸들러로 넘어갈 때 전파 중단 여부를 확인합니다. 이 조건으로는 같은 Fiber에 속한 나머지 리스너를 중단하지 않습니다.

반복문에서 호출하는 executeDispatch()는 이벤트의 currentTarget을 설정하고 핸들러를 실행합니다.

원문 코드: DOMPluginEventSystem.js의 executeDispatch, 252–264행

// executeDispatch 내부 발췌
event.currentTarget = currentTarget;
try {
  listener(event);
} catch (error) {
  reportGlobalError(error);
}
event.currentTarget = null;

목록의 핸들러들은 같은 합성 이벤트 객체를 전달받습니다. 다만 currentTarget은 호출 직전에 이번 핸들러의 DOM 요소로 바뀝니다. 부모의 핸들러에서 event.currentTarget이 부모 요소를 가리키는 이유입니다. 호출이 끝나면 다시 null로 되돌리므로, 이벤트 객체를 밖으로 넘겨 나중에 읽으면 currentTarget은 null입니다.

핸들러에서 예외가 발생하면 catch에서 reportGlobalError()로 보고합니다. 예외를 여기서 처리하므로 바깥 반복문은 다음 핸들러를 계속 실행할 수 있습니다.

아래 시각화는 버튼에서 부모로 이동하며 핸들러를 모으고 호출하는 과정을 보여줍니다. 캡처와 버블을 선택하면 같은 순서로 수집한 핸들러를 각각 어떤 순서로 호출하는지 비교할 수 있습니다. 앞의 코드를 설명하기 위한 모형이며, React를 직접 실행한 결과는 아닙니다.

React가 부모 핸들러를 찾는 과정

단계를 넘겨 보세요. 버블과 캡처를 전환하면 배열은 그대로인데 호출 번호만 뒤바뀝니다.

  1. 타깃에서 찾기
  2. 부모까지 수집
  3. 순서대로 호출

<div>
listeners
  1. 0 handleDelete
  2. 1 handleParent
React v19.2.0의 click 처리 설명용 모형입니다. 전파 중단이 없는 경우를 보여주며, 실제 React 실행 결과는 아닙니다.

stopPropagation()은 React의 실행 루프도 중단합니다

루트가 클릭 이벤트를 받으면 React는 수집한 목록에서 버튼과 부모의 onClick을 차례로 호출합니다. 따라서 부모 핸들러를 막으려면 이 목록의 실행도 중단해야 합니다. 합성 이벤트의 stopPropagation()에는 이를 위한 처리가 들어 있습니다.

처음의 삭제 버튼이 상세 화면까지 열지 않도록, 버튼의 onClick에 다음 코드를 추가합니다.

<button
  onClick={(event) => {
    event.stopPropagation();
    console.log('항목 삭제');
  }}
>
  삭제
</button>

stopPropagation()은 이후 이벤트 전파를 중단하는 DOM API로, 2000년 11월 DOM Level 2 Events 권고안에도 정의되어 있습니다. 이미 실행된 리스너를 되돌리거나 링크 이동 같은 기본 동작을 취소하지는 않습니다. 기본 동작 취소에는 preventDefault()를 사용합니다. W3C DOM Level 2 Events

React 핸들러의 event는 합성 이벤트입니다. 이 객체의 stopPropagation() 구현을 보면 네이티브 메서드를 호출한 뒤, React가 확인할 중단 상태도 설정합니다.

원문 코드: SyntheticEvent.js의 stopPropagation

stopPropagation: function () {
  const event = this.nativeEvent;
  if (!event) {
    return;
  }

  if (event.stopPropagation) {
    event.stopPropagation();
  } else if (typeof event.cancelBubble !== 'unknown') {
    // 원본의 레거시 브라우저 관련 설명 주석 생략
    event.cancelBubble = true;
  }

  this.isPropagationStopped = functionThatReturnsTrue;
},

this.nativeEvent는 브라우저에서 받은 원본 이벤트입니다. 원본의 stopPropagation()을 호출하면 이후 DOM 전파가 멈춥니다. 이어서 isPropagationStopped를 functionThatReturnsTrue로 바꿔, React가 중단 여부를 확인할 때 true를 반환하도록 합니다.

이 값은 앞서 본 실행 루프에서 사용합니다. React는 다른 Fiber의 핸들러로 넘어가기 전에 중단 여부를 확인합니다.

원문 코드: 핸들러 실행 전 중단 여부 확인

if (instance !== previousInstance && event.isPropagationStopped()) {
  return;
}

버튼 핸들러에서 전파를 중단했다면, 반복문은 부모 핸들러를 호출하기 전에 return합니다. 브라우저의 전파를 막아도 이미 수집한 목록에서 부모 핸들러가 없어지지는 않으므로, React의 반복문에서도 중단 상태를 확인해야 합니다.

그래서 원본 이벤트에 직접 호출하면 결과가 달라집니다.

// React의 반복문과 DOM 전파를 모두 멈춥니다.
event.stopPropagation();

// DOM 전파만 멈춥니다. 부모의 React 핸들러는 그대로 실행됩니다.
event.nativeEvent.stopPropagation();

event.nativeEvent.stopPropagation()만 호출하면 합성 이벤트의 isPropagationStopped()는 바뀌지 않습니다. 따라서 현재 실행 중인 React 핸들러 목록의 처리는 계속됩니다. 버튼의 onClick에서 부모의 onClick을 막으려면 event.stopPropagation()을 호출해야 합니다.

React 17에서 리스너를 루트로 옮긴 이유

합성 이벤트에서 원본의 stopPropagation()까지 호출하더라도, 이벤트가 이미 지나온 경로는 되돌릴 수 없습니다. 이 때문에 React가 이벤트를 어디서 받는지가 중요합니다.

React 16 이하에서는 대부분의 이벤트를 document에서 위임받았습니다. React 핸들러가 실행될 때는 네이티브 이벤트가 이미 document에 도착한 뒤였습니다.

버튼 → 부모 → React 루트 → document
                           └ React 핸들러 실행

이 시점에 stopPropagation()을 호출해도 같은 document에 등록한 다른 네이티브 리스너는 실행됩니다. React 트리가 중첩된 경우에도 안쪽 트리에서 중단한 이벤트를 바깥쪽 트리가 받는 문제가 있었습니다.

React 17에서는 위임 위치를 각 루트 컨테이너로 변경했습니다.

버튼 → 부모 → React 루트 → document
              └ React 핸들러 실행
                전파를 중단하면 document에 도달하지 않음

루트에서 stopPropagation()을 호출하면 이벤트가 document의 버블 리스너에 도달하기 전에 멈춥니다. 중첩된 React 트리도 양쪽이 React 17 이상이면 안쪽 루트에서 바깥으로 올라가는 전파를 막을 수 있습니다. 앞에서 본 rootContainerElement에 리스너를 등록하는 코드는 이때 바뀐 방식입니다. React 17: 이벤트 위임 변경

document의 캡처 리스너는 여전히 루트보다 먼저 실행됩니다. 루트의 React 핸들러에서 전파를 중단해도 이미 실행된 캡처 리스너의 결과는 남습니다.

참고 자료

React 소스 발췌: Copyright (c) Meta Platforms, Inc. and affiliates. MIT License.