event.target vs currentTarget — 아이콘 선 위를 누를 때만 터지던 버그
data-* 를 읽을 땐 currentTarget. SVG 는 칠해진 선만 클릭을 받는다
리스너를 붙인 요소의 data-* 를 읽는 코드라면 event.currentTarget 을 쓴다. event.target 을 쓰면 그 요소 안에 자식이 하나라도 있는 순간, 클릭한 픽셀에 따라 결과가 달라진다. 이 글을 읽으면 이 둘을 헷갈리지 않고, 비동기 안에서 currentTarget 이 null 이 되는 함정도 피할 수 있다.
실제로 터진 코드
일괄 삭제 버튼이 이렇게 생겼었다.
<button data-action="click->bulk#delete" data-folder-uuid="abc123">
<svg fill="none" stroke="currentColor"><path d="m14.74 9-.346 9…"/></svg>
삭제
</button>
delete(event) {
const folderUuid = event.target.dataset.folderUuid // 버그
fetch(`/bulk/delete/new?folder_uuid=${folderUuid}`)
}
휴지통 아이콘의 선 위를 정확히 누르면 event.target 이 <path> 가 된다. <path> 에는 data-folder-uuid 가 없으니 undefined. 서버는 폴더 지정 없이 불려서 엉뚱한 키로 선택 상태를 찾았고, 「0건을 삭제하시겠습니까?」 모달을 띄웠다.
같은 버튼인데 누른 픽셀에 따라 동작이 갈렸다. 재현이 잘 안 돼서 꽤 오래 살아남은 버그다.
둘은 뭐가 다른가
event.target 은 이벤트가 실제로 일어난 가장 안쪽 요소다. 전파되는 동안 바뀌지 않는다. event.currentTarget 은 지금 실행 중인 리스너가 붙어 있는 요소다. 이벤트가 버블링으로 올라가면서 단계마다 바뀐다.
<button> 에 리스너를 달고 안쪽 <path> 를 누르면 target 은 <path>, currentTarget 은 <button> 이다.
헷갈림의 절반은 층이 섞여서 생긴다. event 는 「무슨 일이 일어났나」 를 담은 객체고, dataset · classList 는 DOM 요소 쪽 프로퍼티다. event.dataset 은 undefined 다. target 과 currentTarget 은 이벤트가 들고 있는, 요소로 가는 포인터 두 개일 뿐이다.
왜 하필 SVG 아이콘에서
SVG 도형의 pointer-events 기본값은 visiblePainted 다. 실제로 칠해진 부분만 클릭을 받는다. fill="none" 인 아이콘이면 두께 1.5px 남짓한 선만 히트 대상이다. 그래서 선 위는 <path>, 선 사이 빈 곳은 <svg> 나 <button> 으로 갈린다. 어디가 무엇이 되는지는 브라우저와 마크업에 따라 다르니 직접 찍어 보는 게 빠르다.
document.querySelector('button').addEventListener('click', (e) => {
console.log(e.target.tagName, e.currentTarget.tagName)
})
아이콘 여기저기를 눌러 보면 target 만 path · svg · BUTTON 으로 바뀌고 currentTarget 은 늘 BUTTON 이다.
우리 코드에는 <svg> 에도 data-folder-uuid 가 복사돼 있었다. 누군가 같은 버그를 보고 한 겹만 더 붙인 흔적이다. 그러면 대부분의 클릭은 고쳐져서 「고쳤다」 고 믿게 된다. 선 위 클릭은 여전히 뚫리고, 확률만 낮아져서 더 잡기 어려워졌을 뿐이다. 속성을 복사하는 방향 자체가 틀렸다.
함정 — currentTarget 은 핸들러가 끝나면 null
target 은 이벤트 객체에 남지만 currentTarget 은 디스패치가 끝나면 null 로 돌아간다. setTimeout 이나 await 뒤에서 읽으면 TypeError 가 난다.
async delete(event) {
const button = event.currentTarget // 동기 시점에 붙잡아 둔다
await fetch('/x')
console.log(event.currentTarget) // null
console.log(button.dataset.folderUuid) // 'abc123'
}
const button = event.currentTarget 로 한 번 받아 두는 건 취향이 아니라 수명 관리다.
반대로 target 이 맞는 경우 — 이벤트 위임
부모 하나에 리스너를 달고 자식 여러 개를 처리할 때는 currentTarget 이 늘 부모라 쓸모가 없다. 이때는 target 에서 시작해 closest() 로 올라간다.
list.addEventListener('click', (event) => {
const row = event.target.closest('[data-contract-uuid]')
if (!row || !list.contains(row)) return
open(row.dataset.contractUuid)
})
target 을 그대로 쓰지 않고 closest() 를 거치는 게 요점이다. 「안쪽 요소가 잡히는」 문제를 여기서도 똑같이 만나기 때문이다.
| 하고 싶은 것 | 쓸 것 |
|---|---|
| 리스너 단 요소의 data-* 읽기, 버튼 disabled 처리 | event.currentTarget |
| 부모 하나로 자식 여러 개 처리 (위임) | event.target.closest('[data-x]') |
| Stimulus 에서 값 넘기기 | data-bulk-folder-uuid-param → event.params.folderUuid |
Stimulus 는 data-action 이 붙은 요소가 곧 currentTarget 이다. event.params 는 숫자 · 불리언 · JSON 으로 타입까지 바꿔 줘서 새 코드라면 가장 깔끔하다. 기존 코드가 data-folder-uuid 식으로 통일돼 있으면 굳이 섞지 않는다.
버튼 안에 아이콘이 있고 target 으로 읽는 코드가 보이면 거의 확실히 같은 버그다. 이런 「가끔만 재현되는」 프런트 버그를 쫓고 있다면 negabaro 에 같이 봐 달라고 해도 된다.
핵심 포인트
리스너를 단 요소의 data-* 는 event.currentTarget 으로 읽는다
SVG 아이콘은 칠해진 선만 클릭을 받아서 target 이 <path> 로 바뀐다
await · setTimeout 전에 currentTarget 을 변수로 붙잡아 둔다 — 끝나면 null
이벤트 위임에서는 target.closest() 로 올라간다