Events
Handling events in Pulsar.
Event Handling
Events in Pulsar are explicit, synchronous and owned by components. They represent user intent and are handled through regular Dart methods.
Pulsar does not introduce synthetic events, signal layers or automatic binding mechanisms. An event is simply a DOM Event object passed to a method you define.
Handling Events
Events are handled by defining a method on the component that receives an Event parameter. The method reference is then passed to the corresponding event attribute.
final class Clicker extends Component {
int count = 0;
void handleClick(Event event) {
morph(() => count++);
}
@override
Morphic render() {
return Button().onClick(handleClick)(['Clicked $count times']);
}
}
The handleClick method receives the DOM Event. When the button is clicked, the method runs, state updates via morph(), and the UI re-renders.
Important: Event handlers are passed as method references, not invoked. Use onClick(handleClick), NOT onClick(handleClick()).
Common Event Types
Pulsar exposes a predictable set of DOM-aligned events. These map directly to standard browser events.
Mouse Events
final class MouseExample extends Component {
void handleClick(Event e) {
print('Clicked');
}
void handleDoubleClick(Event e) {
print('Double clicked');
}
void handleMouseEnter(Event e) {
print('Mouse entered');
}
void handleMouseLeave(Event e) {
print('Mouse left');
}
@override
Morphic render() {
return Div()
.onClick(handleClick)
.onDoubleClick(handleDoubleClick)
.onMouseEnter(handleMouseEnter)
.onMouseLeave(handleMouseLeave)
(['Interactive Area']);
}
}
Form Events
final class FormExample extends Component {
String inputValue = '';
void handleInput(Event e) {
final target = e.target as HTMLInputElement;
// NO morph - prevents losing focus
inputValue = target.value;
}
void handleChange(Event e) {
final target = e.target as HTMLInputElement;
morph(() => inputValue = target.value);
}
void handleSubmit(Event e) {
e.preventDefault();
morph(() {
print('Submitted: $inputValue');
inputValue = '';
});
}
@override
Morphic render() {
return Form().onSubmit(handleSubmit)([
Input()
.value(inputValue)
.onInput(handleInput) // Updates without morph
([]),
Button().type(ButtonType.submit)(['Submit']),
]);
}
}
Critical Pattern: Use onInput without morph() to update input values. This prevents losing focus while typing. Use onChange or onSubmit with morph() when you need to trigger re-renders.
Keyboard Events
final class KeyboardExample extends Component {
String lastKey = '';
void handleKeyDown(Event e) {
final keyEvent = e as KeyboardEvent;
if (keyEvent.key == 'Enter') {
morph(() => lastKey = 'Enter pressed');
} else if (keyEvent.key == 'Escape') {
morph(() => lastKey = 'Escape pressed');
} else {
morph(() => lastKey = keyEvent.key);
}
}
@override
Morphic render() {
return Div()([
Input().onKeyDown(handleKeyDown)([]),
P()(['Last key: $lastKey']),
]);
}
}
Working with the Event Object
The Event object passed to your handler is the standard DOM Event. You can access its properties and methods directly.
final class EventDetails extends Component {
void handleClick(Event e) {
// Prevent default behavior
e.preventDefault();
// Stop event propagation
e.stopPropagation();
// Access target element
final target = e.target as HTMLElement;
print('Clicked element: ${target.tagName}');
// Mouse event specific
if (e is MouseEvent) {
print('Click position: ${e.clientX}, ${e.clientY}');
}
morph(() {
// Update state
});
}
@override
Morphic render() {
return Button().onClick(handleClick)(['Click me']);
}
}
Parent-Child Communication
Events flow upward through callbacks. This is the primary mechanism for child-to-parent communication.
final class Parent extends Component {
int count = 0;
void increment(Event event) {
morph(() => count++);
}
void decrement(Event event) {
morph(() => count--);
}
@override
Morphic render() {
return Div().classes("parent")([
H2()(['Count: $count']),
Controls(
onIncrement: increment,
onDecrement: decrement,
),
]);
}
}
final class Controls extends Component {
final void Function(Event) onIncrement;
final void Function(Event) onDecrement;
Controls({
required this.onIncrement,
required this.onDecrement,
});
@override
Morphic render() {
return Div().classes("controls")([
Button().onClick(onDecrement)(['-']),
Button().onClick(onIncrement)(['+']),
]);
}
}
This pattern keeps data flow explicit and unidirectional:
- Parent owns the state
- Child receives callbacks as constructor parameters
- Child calls callbacks when events occur
- Parent handles the event and updates state
Passing Custom Data
To pass custom data with events, use closures or helper methods:
final class ItemList extends Component {
List<String> items = ['Apple', 'Banana', 'Cherry'];
// Helper method that creates a closure
void removeItem(String item) {
morph(() => items.remove(item));
}
List<Morphic> get itemElements =>
items.map((item) => Li()([
Span()([item]),
Button().onClick((_) => removeItem(item))(['Remove']),
])).toList();
@override
Morphic render() {
return Ul()([itemElements]);
}
}
The closure (_) => removeItem(item) captures the item from the loop and passes it to the handler.
Alternative Pattern: Event Target Data
final class DataList extends Component {
void handleClick(Event e) {
final target = e.currentTarget as HTMLElement;
final itemId = target.getAttribute('data-id');
morph(() {
print('Clicked item: $itemId');
});
}
List<Morphic> get items =>
['1', '2', '3'].map((id) =>
Button()
.onClick(handleClick)
.data('id', id)
(['Item $id'])
).toList();
@override
Morphic render() {
return Div()([items]);
}
}
Event Delegation
Event delegation can be achieved by handling events on a parent element:
final class TodoList extends Component {
List<Todo> todos = [];
void handleListClick(Event e) {
final target = e.target as HTMLElement;
// Check if clicked element is a delete button
if (target.classList.contains('delete-btn')) {
final todoId = target.getAttribute('data-id');
if (todoId != null) {
morph(() => todos.removeWhere((t) => t.id == todoId));
}
}
// Check if clicked element is a checkbox
if (target.getAttribute('type') == 'checkbox') {
final todoId = target.getAttribute('data-id');
if (todoId != null) {
final todo = todos.firstWhere((t) => t.id == todoId);
morph(() => todo.completed = !todo.completed);
}
}
}
List<Morphic> get todoElements =>
todos.map((todo) => Li()([
Input()
.type(InputType.checkbox)
.checked(todo.completed)
.data('id', todo.id)
([]),
Span()([todo.text]),
Button()
.classes('delete-btn')
.data('id', todo.id)
(['×']),
])).toList();
@override
Morphic render() {
// Single handler on parent
return Ul().onClick(handleListClick)([todoElements]);
}
}
Event delegation can improve performance with large lists, but in most cases individual handlers are clearer and sufficient.
Available Event Handlers
Pulsar provides a comprehensive set of event handlers covering all standard DOM events. All handlers follow the same signature: void Function(Event).
Mouse Events
onClick— Element is clickedonDoubleClick— Element is double-clickedonMouseDown— Mouse button pressed on elementonMouseUp— Mouse button released on elementonMouseEnter— Mouse enters element (no bubble)onMouseLeave— Mouse leaves element (no bubble)onMouseMove— Mouse moves over elementonMouseOver— Mouse enters element or childonMouseOut— Mouse leaves element or childonContextMenu— Right-click context menu
Form Events
onInput— Input value changes (use without morph for text)onChange— Input loses focus after value changeonSubmit— Form is submittedonFocus— Element receives focusonBlur— Element loses focusonFocusIn— Element or child receives focus (bubbles)onFocusOut— Element or child loses focus (bubbles)onReset— Form is resetonSelect— Text is selected in input
Keyboard Events
onKeyDown— Key is pressed downonKeyUp— Key is releasedonKeyPress— Key is pressed (deprecated, use onKeyDown)
Drag & Drop Events
onDrag— Element is being draggedonDragStart— Drag operation startsonDragEnd— Drag operation endsonDragEnter— Dragged element enters drop targetonDragLeave— Dragged element leaves drop targetonDragOver— Dragged element is over drop targetonDrop— Element is dropped on target
Clipboard Events
onCopy— Content is copiedonCut— Content is cutonPaste— Content is pasted
Media Events
onPlay— Media starts playingonPause— Media is pausedonEnded— Media playback endedonTimeUpdate— Media playback time updatedonVolumeChange— Media volume changedonLoadedData— Media data is loadedonLoadedMetadata— Media metadata is loadedonCanPlay— Media can start playingonCanPlayThrough— Media can play without buffering
Scroll Events
onScroll— Element is scrolledonWheel— Mouse wheel is used
Touch Events (Mobile)
onTouchStart— Touch point is placed on surfaceonTouchMove— Touch point moves along surfaceonTouchEnd— Touch point is removed from surfaceonTouchCancel— Touch is interrupted
Animation & Transition Events
onAnimationStart— CSS animation startsonAnimationEnd— CSS animation endsonAnimationIteration— CSS animation repeatsonTransitionEnd— CSS transition completes
Pointer Events
onPointerDown— Pointer becomes activeonPointerUp— Pointer is releasedonPointerMove— Pointer movesonPointerEnter— Pointer enters elementonPointerLeave— Pointer leaves elementonPointerCancel— Pointer event is cancelled
Pointer Events: Pointer events are a modern unified API for mouse, touch, and pen input. They are recommended over separate mouse and touch events for new applications.
Custom Events
For events not covered by the standard handlers, use the generic on() method:
final class CustomEventExample extends Component {
void handleCustom(Event e) {
print('Custom event fired');
}
@override
Morphic render() {
return Div()
.on('customevent', handleCustom)
.on('myCustomEvent', handleCustom)
(['Custom events']);
}
}
The on() method automatically normalizes event names to the correct format.
The Event Mental Model
An event represents something that happened in the UI. It does not carry business logic or side effects by itself.
- Events originate from user interaction
- They are delivered to a component method
- The component decides what to do
- State changes happen explicitly via morph()
This keeps the control flow obvious: events go in, state mutates, render is re-invoked, and the UI morphs accordingly.
// GOOD - Clear flow
void handleClick(Event e) {
// 1. Event arrives
// 2. Decide what to do
if (shouldUpdate()) {
// 3. Mutate state via morph
morph(() => count++);
}
// 4. render() runs, UI updates
}
// BAD - Hidden logic
void handleClick(Event e) {
updateGlobalState(); // Where does this mutation happen?
triggerEffect(); // What does this do?
// State changed but how?
}
Best Practices
- Name handlers by intent, not by event type — Use handleSubmit, not handleClick.
- Keep event handlers focused — One handler, one responsibility.
- Use onInput without morph for text inputs — Prevents losing focus.
- Pass callbacks for parent-child communication — Explicit data flow.
- Use closures to capture data — Clean way to pass item-specific data.
- Call preventDefault() when needed — Forms, links, etc.
Anti-Patterns
- ❌ Calling handlers instead of passing references: onClick(handleClick())
- ❌ Using morph() in onInput handlers for text inputs
- ❌ Creating anonymous functions everywhere by default
- ❌ Mutating state outside of morph()
- ❌ Embedding business logic inside the event object
- ❌ Using events for non-user-initiated actions
Advanced: Event Control
final class FormWithLink extends Component {
void handleFormSubmit(Event e) {
e.preventDefault(); // Don't reload page
morph(() {
// Handle form submission
});
}
void handleLinkClick(Event e) {
e.preventDefault(); // Don't navigate
e.stopPropagation(); // Don't bubble up
// Handle custom navigation
navigateTo('/custom-route');
}
void handleOuterClick(Event e) {
print('Outer clicked');
}
@override
Morphic render() {
return Div().onClick(handleOuterClick)([
Form().onSubmit(handleFormSubmit)([
Input().name('email')([]),
Button().type(ButtonType.submit)(['Submit']),
]),
A()
.href('/page')
.onClick(handleLinkClick)
(['Custom Link']),
]);
}
}
Key principle: Events express user intent. Components decide behavior. State changes happen explicitly via morph(). Nothing happens implicitly.