packages/component/docs/events.md
Event handling with the on() mixin and signal-based interruption management.
Use the on() mixin to attach event listeners to elements:
function Button(handle: Handle) {
let count = 0
return () => (
<button
mix={[
on('click', () => {
count++
handle.update()
}),
]}
>
Clicked {count} times
</button>
)
}
Event handlers receive the event object and an optional AbortSignal:
mix={[on('click', (event) => {
// event is the DOM event
event.preventDefault()
}), on('input', async (event, signal) => {
// signal is aborted when handler is re-entered or component removed
let response = await fetch('/api', { signal })
})]}
Event handlers receive an AbortSignal that's automatically aborted when:
This prevents race conditions when users create events faster than async work completes:
function SearchInput(handle: Handle) {
let results: string[] = []
let loading = false
return () => (
<div>
<input
type="text"
mix={[
on('input', async (event, signal) => {
let query = event.currentTarget.value
loading = true
handle.update()
// Passing signal automatically aborts previous requests
let response = await fetch(`/search?q=${query}`, { signal })
let data = await response.json()
// Manual check for APIs that don't accept a signal
if (signal.aborted) return
results = data.results
loading = false
handle.update()
}),
]}
/>
{loading && <div>Loading...</div>}
{!loading && results.length > 0 && (
<ul>
{results.map((result, i) => (
<li key={i}>{result}</li>
))}
</ul>
)}
</div>
)
}
The signal ensures only the latest search request completes, preventing stale results from overwriting newer ones.
Handle multiple events on the same element:
function InteractiveBox(handle: Handle) {
let state = 'idle'
return () => (
<div
mix={[
on('mouseenter', () => {
state = 'hovered'
handle.update()
}),
on('mouseleave', () => {
state = 'idle'
handle.update()
}),
on('click', () => {
state = 'clicked'
handle.update()
}),
]}
>
State: {state}
</div>
)
}
Common form event patterns:
function Form(handle: Handle) {
return () => (
<form
mix={[
on('submit', (event) => {
event.preventDefault()
let formData = new FormData(event.currentTarget)
// Process form data
}),
]}
>
<input
name="email"
mix={[
on('blur', (event) => {
// Validate on blur
let value = event.currentTarget.value
if (!value.includes('@')) {
event.currentTarget.setCustomValidity('Invalid email')
}
}),
on('input', (event) => {
// Clear validation on input
event.currentTarget.setCustomValidity('')
}),
]}
/>
<button type="submit">Submit</button>
</form>
)
}
Handle keyboard interactions:
function KeyboardNav(handle: Handle) {
let selectedIndex = 0
let items = ['Apple', 'Banana', 'Cherry']
return () => (
<ul
tabIndex={0}
mix={[
on('keydown', (event) => {
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
selectedIndex = Math.min(selectedIndex + 1, items.length - 1)
handle.update()
break
case 'ArrowUp':
event.preventDefault()
selectedIndex = Math.max(selectedIndex - 1, 0)
handle.update()
break
}
}),
]}
>
{items.map((item, i) => (
<li key={i} mix={[css({ backgroundColor: i === selectedIndex ? '#eee' : 'transparent' })]}>
{item}
</li>
))}
</ul>
)
}
Use addEventListeners() for global event targets with automatic cleanup:
function WindowResizeTracker(handle: Handle) {
let width = window.innerWidth
let height = window.innerHeight
// Set up global listeners once in setup
addEventListeners(window, handle.signal, {
resize() {
width = window.innerWidth
height = window.innerHeight
handle.update()
},
})
return () => (
<div>
Window size: {width} x {height}
</div>
)
}
function KeyboardTracker(handle: Handle) {
let keys: string[] = []
addEventListeners(document, handle.signal, {
keydown(event) {
keys.push(event.key)
handle.update()
},
})
return () => <div>Keys: {keys.join(', ')}</div>
}
For interactive elements, prefer press events over click. Press events provide better cross-device behavior:
// ❌ Avoid: click doesn't handle all interaction modes well
<button mix={[on('click', () => { doAction() })]}>Action</button>
// ✅ Prefer: press handles mouse, touch, and keyboard uniformly
<button mix={[pressEvents(), on('press', () => { doAction() })]}>Action</button>
Use click only when you specifically need mouse-click behavior (e.g., detecting right-clicks or modifier keys).
Do as much work as possible in event handlers. Use the event handler scope for transient state:
// ✅ Good: Do work in handler, only store what renders need
function SearchResults(handle: Handle) {
let results: string[] = [] // Needed for rendering
let loading = false // Needed for rendering loading state
return () => (
<div>
<input
mix={[
on('input', async (event, signal) => {
let query = event.currentTarget.value
// Do work in handler scope
loading = true
handle.update()
let response = await fetch(`/search?q=${query}`, { signal })
let data = await response.json()
if (signal.aborted) return
// Only store what's needed for rendering
results = data.results
loading = false
handle.update()
}),
]}
/>
{loading && <div>Loading...</div>}
{results.map((result, i) => (
<div key={i}>{result}</div>
))}
</div>
)
}
For async work, always check the signal or pass it to APIs that support it:
mix={[on('click', async (event, signal) => {
// Option 1: Pass signal to fetch
let response = await fetch('/api', { signal })
// Option 2: Manual check after await
let data = await someAsyncOperation()
if (signal.aborted) return
// Safe to update state
handle.update()
})]}
addEventListeners() for global listeners