Avoid These Mistakes With Svelte Events
Published Mar 14, 2026
Table of Contents
- Event Delegation
- Custom Events Don’t Bubble
- Simplifying Event Listeners With On
- Manual Event Listeners Fire In The Wrong Order
- How Event Delegation Works Under The Hood
- How On Is Implemented
Event Delegation
Most JavaScript frameworks don’t attach event listeners directly on elements. Instead they use a technique called event delegation.
<button onclick={() => console.log('click')}>
Click
</button> If you inspect a button with a click handler in Svelte and look at the event listeners, you’ll notice the button itself has no event listeners — the event listener is actually on the root element.
This works thanks to event bubbling. When you click a button, the event bubbles up through all of its ancestors. So instead of attaching a separate listener to every button (which would be memory inefficient and require manual cleanup), Svelte attaches a single event listener higher up the tree and figures out which element was clicked.
You can check the Svelte docs to see which event types get delegated.
Svelte handles declarative events in the template for you, but you can run into problems when using custom events or manual event listeners because of how event delegation works.
Custom Events Don’t Bubble
Consider an attachment called clickedoutside that fires a custom event when you click outside a node:
<script lang="ts">
function clickedoutside(node: HTMLElement) {
function handler(event: MouseEvent) {
if (!node.contains(event.target as Node)) {
node.dispatchEvent(new CustomEvent('clickedoutside'))
}
}
window.addEventListener('click', handler)
return () => window.removeEventListener('click', handler)
}
</script>
<button
{@attach clickedoutside}
onclickedoutside={() => console.log('clicked outside')}
>
Click
</button> This works fine when you listen for the event on the element itself. But what if you want to listen on a parent element?
<!-- ❌ this won't work -->
<section onclickedoutside={() => console.log('clicked outside')}>
<button {@attach clickedoutside}>Click</button>
</section> Nothing happens, because custom events don’t bubble by default.
The fix is simple — pass { bubbles: true } to your CustomEvent:
new CustomEvent('clickedoutside', { bubbles: true }) Now the event bubbles up the DOM tree and the parent listener catches it correctly.
Simplifying Event Listeners With On
The on function from svelte/events makes it easier to add and clean up event listeners. Instead of manually calling addEventListener and removeEventListener, you can do this:
<script lang="ts">
import { on } from 'svelte/events'
function clickedoutside(node: HTMLElement) {
// `on` returns a cleanup function automatically
return on(window, 'click', (event) => {
if (!node.contains(event.target as Node)) {
node.dispatchEvent(new CustomEvent('clickedoutside', { bubbles: true }))
}
})
}
</script> This also works great inside $effect:
<script lang="ts">
import { on } from 'svelte/events'
$effect(() => {
return on(window, 'click', handler)
})
</script> Manual Event Listeners Fire In The Wrong Order
Consider an attachment that adds a manual click event listener directly to a node:
<script lang="ts">
function event(node: HTMLElement) {
function handler() {
console.log('manual click')
}
node.addEventListener('click', handler)
return () => node.removeEventListener('click', handler)
}
</script>
<section {@attach event}>
<button onclick={() => console.log('click')}>
Click
</button>
</section> When you click the button, you might expect click to log first and manual click second — but the opposite happens. The manual event listener fires first, before the declarative one.
This also breaks stopPropagation.
Even if you add event.stopPropagation() to the declarative handler, the manual listener will still fire:
<!-- ❌ stopPropagation won't prevent the manual listener from firing -->
<section {@attach event}>
<button onclick={(event) => {
event.stopPropagation()
console.log('click')
}}>
Click
</button>
</section> The fix is to use on from svelte/events instead of calling addEventListener directly:
<script lang="ts">
import { on } from 'svelte/events'
function event(node: HTMLElement) {
return on(node, 'click', () => console.log('manual click'))
}
</script>
<section {@attach event}>
<button onclick={(event) => {
event.stopPropagation()
console.log('click')
}}>
Click
</button>
</section> Now events fire in the correct order (click first, manual click second), and stopPropagation works as expected — when propagation is stopped, the manual listener won’t fire at all.
How Event Delegation Works Under The Hood
When Svelte compiles a component, it doesn’t wire up addEventListener on each element. Instead, it stores the event handlers as a property on the element (something like element['events'] = { onclick: ... }), then adds a single listener to both the container and the document:
“The container listener ensures we catch events from within in case the outer content stops propagation of the event.”
When you click a button, that single listener fires a function called handle_event_propagation. It:
- Receives the event and proxies
currentTargetto point to the correct element - Walks up the DOM tree through each parent element
- Looks up the delegated event handlers stored on each element
- Calls them using
.call(element, event)to ensure the correctthiscontext
var delegated = current_target[event.symbol]?.[event.name]
delegated.call(current_target, event) This is why declarative Svelte events “just work” — the ordering and cleanup are all managed for you.
How On Is Implemented
The on function from svelte/events wraps your handler inside a target_handler which calls handle_event_propagation (the same internal function Svelte uses for delegated events) with the element as this.
This is exactly how Svelte ensures manually added listeners fire in the correct order relative to declarative ones:
function on(element, type, handler, options = {}) {
var target_handler = create_event(type, element, handler, options)
return () => element.removeEventListener(type, target_handler, options)
}
function create_event(event_name, dom, handler, options = {}) {
function target_handler(event) {
handle_event_propagation.call(dom, event)
}
dom.addEventListener(event_name, target_handler, options)
return target_handler
} Using the same propagation mechanism, on guarantees that event ordering and stopPropagation behave exactly as you’d expect.