Chapter 23: Events Are Not Callbacks
A callback is a direct relationship.
This child calls that function. This button invokes that handler. This component receives onSave and, when something happens, calls onSave. The parent knows what should happen; the child knows when to ask the parent to do it. The relationship is one-to-one, top-down, lexically clear, and entirely contained inside the framework’s mental model.
Callbacks are useful. They’re simple. They make parent-child communication explicit in component systems. React’s prop-callback pattern is the dominant frontend communication idiom for a reason — it’s tractable, it composes, and the resulting code is easy to read.
Browser events aren’t merely callbacks.
Events are a native communication system with propagation, delegation, cancellation, composition, default behavior, and context. They move through the DOM tree. They can be observed by ancestors. They can cross shadow boundaries when composed. They can describe semantic facts rather than only low-level inputs. The DOM event system has been one of the platform’s most-developed pieces since DOM Level 2 standardized it in 1998, and it has architectural affordances most application code never uses.
This chapter argues that the affordances are worth using. Modern frontend often narrows events down to callback props. Kitsune reopens the broader model.
The DOM Event Model
Section titled “The DOM Event Model”The platform’s event system has three phases.
When the user clicks an element, the click event starts at the root of the document and travels downward toward the target. This is the capture phase. Listeners attached with addEventListener(type, handler, true) — or { capture: true } — fire during capture. Most application code doesn’t use capture, but the option exists.
The event reaches the target element. Listeners attached directly to that element fire. This is the target phase.
The event then bubbles upward from the target through every ancestor, all the way back to the document root. Listeners attached without the capture flag fire during this bubble phase. The event object’s currentTarget property reflects which element’s listener is currently firing; the target property remains the original element where the event started.
This three-phase model was specified in the DOM Level 2 Events spec in 2000, and addEventListener has been the canonical event-binding API since IE9 (2011) made it cross-browser. The model is two and a half decades old. It’s the substrate of every browser interaction.
What the model gives application code is the ability to observe interactions from a distance. A listener attached high in the DOM tree can observe every event of a given type that happens inside its subtree. The listener doesn’t need to know about each element individually. The element doesn’t need to know about the listener. The relationship is established by containment, not by direct reference.
This is the affordance the rest of this chapter builds on.
Custom Events Can Be Semantic
Section titled “Custom Events Can Be Semantic”The browser’s built-in events describe physical or low-level interactions. click, input, submit, keydown, focus, blur, mousemove, scroll. These are useful — they’re how the user’s actions reach application code — but they’re at the wrong level of abstraction for application architecture. Click is a fact about the input device. Profile saved is a fact about the application.
The platform’s answer is the CustomEvent constructor:
this.dispatchEvent(new CustomEvent('profile:saved', { bubbles: true, composed: true, detail: { profileId: 'me' }}))A custom event has a name ('profile:saved'), optional configuration (bubbles, composed, cancelable), and an optional detail property that carries arbitrary structured data. The event fires from a specific element and travels through the DOM the same way native events do.
This means a component can announce a semantic event — the profile was saved — without knowing which other parts of the application care. The announcement is a fact about the world. Any listener anywhere in the ancestor chain can observe it.
Kitsune standardizes this pattern by defining a single event name (meta:event) for application-level events. The event’s detail carries the application’s semantic information:
this.dispatchEvent(new CustomEvent('meta:event', { bubbles: true, composed: true, detail: { type: 'profile.saved', payload: { profileId: 'me' } }}))A boundary above the component listens for meta:event once, enriches the event with surrounding context (surface, feature, entity), and passes the enriched event into the runtime. Modules subscribed to the runtime receive the fact. None of them imported the component. The component didn’t import any of them.
The same pattern works for commands — something should happen — using a parallel meta:command event. Chapter 37 (Events, Commands, and Causality) develops this further. The point for now is that custom events let the application speak its own vocabulary, not just the browser’s input vocabulary.
Composed Events and Shadow DOM
Section titled “Composed Events and Shadow DOM”A complication is worth naming directly, because it matters for any application using web components.
A custom element can render its internal markup inside a shadow root — an encapsulated DOM subtree that isn’t accessible from the document’s main tree through standard queries. Shadow DOM is one of the platform’s tools for component encapsulation. The internals stay private. The component’s public surface is its tag name, its attributes, and its emitted events.
Events fired inside a shadow root don’t, by default, escape it. The shadow boundary stops propagation. If a <button> inside a custom element’s shadow DOM emits a click event, a listener attached to the document won’t see it unless the event is marked as composed.
The platform’s solution is the composed flag on CustomEvent. An event created with { composed: true } crosses shadow boundaries when it bubbles. The event’s composedPath() method returns the full sequence of nodes the event traveled through, including those inside shadow roots — though the event’s target, when read from outside the shadow tree, is retargeted to the shadow host (the custom element itself), so the internal structure remains encapsulated.
This is the right design. Encapsulation is preserved — the application can’t reach into the shadow DOM through standard queries to manipulate internals. Communication is preserved — events flow up freely if marked composed. The component decides what’s public (composed events) and what’s private (its internal DOM). The application sees only the public surface.
For Kitsune, this means a custom element can emit meta:event and have a boundary listener observe it regardless of whether the source element lives in light DOM or shadow DOM. The boundary doesn’t care. The composed event reaches it either way. The pattern works uniformly across plain HTML, framework-rendered DOM, and custom elements with shadow DOM — which is exactly the cross-authoring-style flexibility the architecture needs.
Event Delegation as Architecture
Section titled “Event Delegation as Architecture”Event delegation has been a JavaScript pattern since at least jQuery’s .on(selector, handler) form in the late 2000s. The platform’s version of the same pattern is addEventListener plus event.target.closest(selector):
boundary.addEventListener('click', (event) => { const action = event.target.closest('[data-meta-event]') if (!action) return
runtime.emit({ type: action.dataset.metaEvent })})The listener is attached once, to a high-level container element. Every click anywhere inside that container bubbles up to the listener. The listener inspects the event target, walks up the DOM tree looking for an element with the metadata it cares about, and acts on it.
This is more than a performance optimization (though it is one — a hundred buttons can share one listener instead of installing a hundred). It’s an architectural pattern.
The listener doesn’t know about the buttons. The buttons don’t know about the listener. The communication happens through the platform’s event system and a small metadata convention. A new button added to the DOM later — by the framework, by a server render, by direct manipulation — automatically participates. The listener observes events fired by elements that didn’t exist when the listener was attached. The contract is attach attribute, fire event, the boundary above will handle it.
This is what Kitsune’s metadata boundary is doing. A single delegated listener at the boundary observes every metadata-decorated event inside the region. It enriches the events with context drawn from ancestor metadata. It dispatches the enriched events into the runtime. The components below don’t import the runtime. The modules above don’t import the components. The DOM is the integration layer.
For anyone who has worked with publish-subscribe systems on the server — message buses, event streams, internal pub-sub libraries — the pattern will be familiar. The DOM event system is a publish-subscribe bus, with bubbling-based topic routing and the DOM tree as the routing structure. The browser shipped this affordance in 2000. The application architecture lift is treating it as the architecture it already is.
Events Are Facts
Section titled “Events Are Facts”One of the central distinctions Kitsune draws is between events and commands.
An event is a statement about the world. This happened. profile.saved. checkout.started. dialog.closed. form.validation_failed. Events are facts. They describe state changes that have already occurred. They don’t tell any module what to do; they announce what happened, and modules subscribed to the runtime can decide for themselves whether they care.
A command is a request. This should happen. dialog.open. cart.add-item. notification.show. Commands have handlers. Some module is responsible for executing a command. The command doesn’t decide for itself how it gets handled; the runtime routes it to the appropriate handler.
This split — events are facts, commands are requests — is borrowed from CQRS (Command Query Responsibility Segregation), a pattern Greg Young and Udi Dahan articulated in the backend world in the late 2000s. The split keeps causality legible. If you’re trying to understand why something happened, you trace back through commands and the events they produced. If you’re trying to understand what could happen next, you look at which modules are subscribed to which events.
The DOM event system supports both shapes. Events can be cancelable — a listener can call event.preventDefault() to suppress the default behavior, which is useful for commands (a click on a link that should be handled as a command, not a navigation). Events without cancelable are facts — they happened, listeners can observe them, but nothing about the event is reversible from a listener.
In Kitsune’s metadata protocol, meta-event declares a fact, and meta-command declares a request. The boundary listens for both. Events go to the runtime’s event bus and are distributed to all subscribed modules. Commands go to the runtime’s command dispatcher and are routed to the appropriate handler. The same DOM event infrastructure carries both, with a small protocol convention distinguishing them.
Callbacks Lose Information
Section titled “Callbacks Lose Information”Here’s the architectural cost of the callback-prop style, made specific.
A typical React button might look like this:
<Button onClick={handleCheckout}>Start checkout</Button>The <Button> receives onClick. When it’s activated, it calls onClick. The parent component knows what handleCheckout does. The button doesn’t.
This works, and it’s expressive. The cost shows up when multiple parts of the application want to observe the same interaction. Suppose analytics needs to know about the checkout start. Suppose the audit module needs to know. Suppose the observability module needs to add a breadcrumb. Suppose a feature-experiments module needs to count exposures. Suppose a debug overlay needs to display the event.
Each of these consumers needs to be wired into the handleCheckout function. The function grows. Or the parent component receives more callbacks and passes them all to <Button>. Or a global event-emitter is imported into handleCheckout and the consumers subscribe to it. Or the team builds a custom analytics hook and uses it inside every callback. The patterns get baroque quickly, because the callback shape is one-to-one and the requirement is one-to-many.
The DOM event model has been one-to-many from the beginning. Multiple listeners can attach to the same event type on the same element. Multiple listeners can attach to different elements along the bubble path. Any of them can observe the event without the dispatcher knowing. The architecture is the broadcast.
A button that emits meta:event with type checkout.started doesn’t care who’s listening. Analytics listens. Audit listens. Observability listens. The experiments module listens. The debug overlay listens. None of them is wired into the button’s local callback. The component announces a fact; the modules observe it; the runtime coordinates.
This is the move from direct consequence to observable fact. Callbacks encode direct consequence — the dispatcher knows what should happen. Events encode observable facts — the dispatcher announces what happened, and the architecture above it decides what to do.
The Cocoa Parallel, Once Again
Section titled “The Cocoa Parallel, Once Again”Anyone from a desktop application background will recognize this pattern as the NotificationCenter.
In NeXTSTEP and Cocoa, the NSNotificationCenter (later renamed NotificationCenter in Swift) is a pub-sub bus that lets one part of an application announce a notification (a name and an optional userInfo dictionary) and other parts subscribe to it without direct coupling. The pattern has been part of the Mac and iOS development model since the 1990s.
The DOM event model with custom events is structurally the same. dispatchEvent(new CustomEvent('something', { detail: { ... } })) is the notification dispatch. addEventListener('something', handler) is the subscription. The bubble-through-the-DOM-tree routing is the analog of NotificationCenter’s posting-to-a-name pattern — the routing logic differs, but the architectural shape is the same.
Cocoa developers have been using this pattern for thirty years, and it’s one of the most durable architectural moves in their toolkit. The DOM has the same capability. Frontend has, mostly, been routing around it. The chapter’s argument is that the architecture is already there. We can use it.
Cancellation and Default Behavior
Section titled “Cancellation and Default Behavior”The DOM event model also includes cancellation, which is one of the platform’s more subtle affordances.
Many native events are cancelable. A listener can call event.preventDefault() to suppress the browser’s default behavior. A submit event on a form, canceled, stops the form from being submitted (the application can handle the submission itself). A click event on a link, canceled, stops the navigation. A keydown event for a character, canceled, stops the character from being inserted. The cancellation pattern lets the application interpose itself between the user’s action and the browser’s default response.
stopPropagation() is a related but different control. It stops the event from continuing to bubble. Other listeners higher in the tree won’t see the event. This is useful for nested interactive elements — a click on a button inside a card shouldn’t necessarily also be treated as a click on the card itself, depending on the application’s intent.
These controls give the platform’s event model a level of subtlety that callback-prop systems usually don’t replicate. A React onClick callback fires after the click has already happened, with no relationship to the browser’s default behavior. To prevent the default, the callback has to call event.preventDefault() on the synthetic event React passes in. The capability is present but harder to reason about than it is with native events.
Kitsune should be careful with cancellation. Not every native event should be intercepted; the goal isn’t to hijack the browser’s default behavior. The goal is to listen at meaningful boundaries and enhance the flow. When an event describes application meaning, emit a semantic event alongside the native one. When an action requests behavior, dispatch a command. When native behavior is already correct, preserve it. The chapter on forms (Ch 24) goes deeper on the cancellation question for the specific case of form submissions.
What This Means for Modern Frontend
Section titled “What This Means for Modern Frontend”Events are a foundation for decoupled architecture.
Callbacks remain useful inside component systems, particularly for parent-child communication where the parent legitimately should know about the child’s interactions. But browser-native applications can use events to let components speak outward without knowing their consumers. The combination — callbacks for local, events for application-level — gives the architecture the right shape for both kinds of communication.
A web-native architecture should use native events for input interaction, custom events for semantic component communication, event delegation for boundaries, runtime events for application facts, and diagnostics that make the event flow visible during development. This is how Kitsune separates meaning (declared by components) from consequence (handled by modules) — the same split this book has been building toward since the Stylesheet Ecosystem chapter introduced decoration-vs-replacement.
What Comes Next
Section titled “What Comes Next”The next chapter takes the form as the canonical example of a platform construct that’s been treated as a render artifact when it’s actually a transaction boundary. Forms have a lifecycle, a validation model, a submission protocol, and a participating-elements relationship that the platform implements natively. Modern frontend often replaces the form with controlled state and a custom submission handler. The chapter argues for taking the native form seriously — particularly with form-associated custom elements and ElementInternals, which let custom components participate in forms as first-class members.
Exercise: Build a Semantic Custom Event
Section titled “Exercise: Build a Semantic Custom Event”Create a custom element that renders a profile card:
<profile-card profile-id="me"></profile-card>Inside the element, render a save button. When the button is clicked, dispatch a custom event:
this.dispatchEvent(new CustomEvent('meta:event', { bubbles: true, composed: true, detail: { type: 'profile.saved', payload: { profileId: this.getAttribute('profile-id') } }}))Outside the component, attach one listener to a parent element:
boundary.addEventListener('meta:event', (event) => { console.log(event.detail)})Then experiment:
- Did the component import the listener? (It shouldn’t have.)
- Did the parent need to know anything about the component’s internals?
- What changes if you set
composed: false? (The listener stops seeing the event, because the shadow boundary blocks it.) - What changes if you set
bubbles: false? (The listener still works if attached directly to the element, but a listener on an ancestor stops seeing the event.) - What context could a boundary at a higher level add to the event before forwarding it onward? (Surface, feature, entity, route.)
- Which modules might subscribe to
profile.savedin a real application? (Analytics, audit, observability, draft cleanup, notification, experiments.) - How does this pattern compare to passing
onSaveas a prop? What’s harder, and what’s easier?
The point is to feel the difference between callback wiring (one source, one consumer, direct coupling) and browser-native communication (one source, many possible observers, decoupled through the platform’s event machinery). The architectural lift is small; the architectural payoff compounds with every consumer that subscribes without the source needing to know about them.