@remix-run/interaction
v0.3.0
Published
Like components but for events
Readme
interaction
Enhanced events and custom interactions for any EventTarget.
Features
- Declarative Bindings - Event bindings with plain objects
- Semantic Interactions - Reusable "interactions" like
longPressandarrowDown - Async Support - Listeners with reentry protection via AbortSignal
- Type Safety - Type-safe listeners and custom
EventTargetsubclasses withTypedEventTarget
Installation
npm install @remix-run/interactionGetting Started
Adding event listeners
Use on(target, listeners) to add one or more listeners. Each listener receives (event, signal) where signal is aborted on reentry.
import { on } from '@remix-run/interaction'
let inputElement = document.createElement('input')
on(inputElement, {
input: (event, signal) => {
console.log('current value', event.currentTarget.value)
},
})Listeners can be arrays. They run in order and preserve normal DOM semantics (including stopImmediatePropagation).
import { on } from '@remix-run/interaction'
on(inputElement, {
input: [
(event) => {
console.log('first')
},
{
capture: true,
listener(event) {
// capture phase
},
},
{
once: true,
listener(event) {
console.log('only once')
},
},
],
})Built-in Interactions
Builtin interactions are higher‑level, semantic event types (e.g., press, longPress, arrow keys) exported as string constants. Consume them just like native events by using computed keys in your listener map. When you bind one, the necessary underlying host events are set up automatically.
import { on } from '@remix-run/interaction'
import { press, longPress } from '@remix-run/interaction/press'
on(listItem, {
[press](event) {
navigateTo(listItem.href)
},
[longPress](event) {
event.preventDefault() // prevents `press`
showActions()
},
})Import builtins from their modules (for example, @remix-run/interaction/press, @remix-run/interaction/keys). Some interactions may coordinate with others (for example, calling event.preventDefault() in one listener can prevent a related interaction from firing).
You can also create your own interactions.
Async listeners and reentry protection
The signal is aborted when the same listener is re-entered (for example, a user types quickly and triggers input repeatedly). Pass it to async APIs or check it manually to avoid stale work.
on(inputElement, {
async input(event, signal) {
showSearchSpinner()
// Abortable fetch
let res = await fetch(`/search?q=${event.currentTarget.value}`, { signal })
let results = await res.json()
updateResults(results)
},
})For APIs that don't accept a signal:
on(inputElement, {
async input(event, signal) {
showSearchSpinner()
let results = await someSearch(event.currentTarget.value)
if (signal.aborted) return
updateResults(results)
},
})Event listener options
All DOM AddEventListenerOptions are supported via descriptors:
import { on } from '@remix-run/interaction'
on(button, {
click: {
capture: true,
listener(event) {
console.log('capture phase')
},
},
focus: {
once: true,
listener(event) {
console.log('focused once')
},
},
})Updating listeners efficiently
Use createContainer when you need to update listeners in place (e.g., in a component system). The container diffs and updates existing bindings without unnecessary removeEventListener/addEventListener churn.
import { createContainer } from '@remix-run/interaction'
let container = createContainer(form)
let formData = new FormData()
container.set({
change(event) {
formData = new FormData(event.currentTarget)
},
async submit(event, signal) {
event.preventDefault()
await fetch('/save', { method: 'POST', body: formData, signal })
},
})
// later – only the minimal necessary changes are rebound
container.set({
change(event) {
console.log('different listener')
},
submit(event, signal) {
console.log('different listener')
},
})Disposing listeners
on returns a dispose function. Containers expose dispose(). You can also pass an external AbortSignal.
import { on, createContainer } from '@remix-run/interaction'
// Using the function returned from on()
let dispose = on(button, { click: () => {} })
dispose()
// Containers
let container = createContainer(window)
container.set({ resize: () => {} })
container.dispose()
// Use a signal
let eventsController = new AbortController()
let container = createContainer(window, {
signal: eventsController.signal,
})
container.set({ resize: () => {} })
eventsController.abort()Stop propagation semantics
All DOM semantics are preserved.
on(button, {
click: [
(event) => {
event.stopImmediatePropagation()
},
() => {
// not called
},
],
})Custom Interactions
Define semantic interactions that can dispatch custom events and be reused declaratively.
import { defineInteraction, on, type Interaction } from '@remix-run/interaction'
// Provide type safety for consumers
declare global {
interface HTMLElementEventMap {
[keydownEnter]: KeyboardEvent
}
}
function KeydownEnter(this: Interaction) {
if (!(this.target instanceof HTMLElement)) return
this.on(this.target, {
keydown(event) {
if (event.key === 'Enter') {
this.target.dispatchEvent(new KeyboardEvent(keydownEnter, { key: 'Enter' }))
}
},
})
}
// define the interaction type and setup function
const keydownEnter = defineInteraction('keydown:enter', KeydownEnter)
// usage
let button = document.createElement('button')
on(button, {
[keydownEnter](event) {
console.log('Enter key pressed')
},
})Notes:
- An interaction is initialized at most once per target, even if multiple listeners bind the same interaction type.
Typed Event Targets
Use TypedEventTarget<eventMap> to get type-safe addEventListener and integrate with this library's on helpers.
import { TypedEventTarget, on } from '@remix-run/interaction'
interface DrummerEventMap {
kick: DrummerEvent
snare: DrummerEvent
hat: DrummerEvent
}
class DrummerEvent extends Event {
constructor(type: DrummerEvent['type']) {
super(type)
}
}
class Drummer extends TypedEventTarget<DrummerEventMap> {
kick() {
// ...
this.dispatchEvent(new DrummerEvent('kick'))
}
}
let drummer = new Drummer()
// native API is typed
drummer.addEventListener('kick', (event) => {
// event is DrummerEvent
})
// type safe with on()
on(drummer, {
kick: (event) => {
// event is Dispatched<DrummerEvent, Drummer>
},
})Demos
To run the demos:
pnpm run demosThe demos directory contains working demos:
demos/async- Async listeners with abort signaldemos/basic- Basic event handlingdemos/form- Form event handlingdemos/keys- Keyboard interactionsdemos/popover- Popover interactionsdemos/press- Press and long press interactions
License
See LICENSE
