Event Handling
Remix uses real DOM events with theon() mixin. Event listeners automatically receive an AbortSignal for managing interruptions and cleanup.
Basic Event Handling
Use theon() mixin to attach event listeners:
import type { Handle } from 'remix/component'
import { on } from 'remix/component'
function Counter(handle: Handle) {
let count = 0
return () => (
<button
mix={[
on('click', () => {
count++
handle.update()
}),
]}
>
Count: {count}
</button>
)
}
Event Handler Signature
Event handlers receive the event and anAbortSignal:
function MyComponent(handle: Handle) {
return () => (
<input
mix={[
on('input', (event, signal) => {
// event: InputEvent
// signal: AbortSignal
let value = event.currentTarget.value
// Use value...
}),
]}
/>
)
}
AbortSignal for Interruptions
The signal is aborted when:- The handler is re-entered (user triggers another event)
- The component is removed from the tree
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()
// Pass signal to abort previous requests
let response = await fetch(`/search?q=${query}`, { signal })
let data = await response.json()
// Check signal for APIs that don't accept it
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>
)
}
Multiple Events
Attach multiple event listeners to the same element:function Input(handle: Handle) {
let value = ''
let isFocused = false
return () => (
<input
type="text"
value={value}
mix={[
on('input', (event) => {
value = event.currentTarget.value
handle.update()
}),
on('focus', () => {
isFocused = true
handle.update()
}),
on('blur', () => {
isFocused = false
handle.update()
}),
]}
css={{
border: `2px solid ${isFocused ? 'blue' : '#ccc'}`,
}}
/>
)
}
Form Events
Handle form submissions:function LoginForm(handle: Handle) {
let error: string | null = null
return () => (
<form
mix={[
on('submit', async (event, signal) => {
event.preventDefault()
let formData = new FormData(event.currentTarget)
let email = formData.get('email') as string
let password = formData.get('password') as string
error = null
handle.update()
let response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
signal,
})
if (signal.aborted) return
if (!response.ok) {
error = 'Login failed'
handle.update()
return
}
// Handle success...
}),
]}
>
{error && <div css={{ color: 'red' }}>{error}</div>}
<input name="email" type="email" required />
<input name="password" type="password" required />
<button type="submit">Login</button>
</form>
)
}
Keyboard Events
Handle keyboard interactions:function TodoInput(handle: Handle) {
let todos: string[] = []
return () => (
<div>
<input
type="text"
placeholder="Add todo..."
mix={[
on('keydown', (event) => {
if (event.key === 'Enter') {
let input = event.currentTarget as HTMLInputElement
if (input.value.trim()) {
todos.push(input.value)
input.value = ''
handle.update()
}
}
}),
]}
/>
<ul>
{todos.map((todo, i) => (
<li key={i}>{todo}</li>
))}
</ul>
</div>
)
}
Global Event Listeners
Usehandle.on() for document and window events with automatic cleanup:
function KeyboardTracker(handle: Handle) {
let keys: string[] = []
handle.on(document, {
keydown(event) {
keys.push(event.key)
handle.update()
},
})
return () => <div>Keys pressed: {keys.join(', ')}</div>
}
Window Resize Events
function WindowSize(handle: Handle) {
let width = window.innerWidth
let height = window.innerHeight
handle.on(window, {
resize() {
width = window.innerWidth
height = window.innerHeight
handle.update()
},
})
return () => (
<div>
Window: {width} × {height}
</div>
)
}
Event Delegation
Handle events on parent elements:function TodoList(handle: Handle) {
let todos = [
{ id: '1', text: 'Buy milk', completed: false },
{ id: '2', text: 'Walk dog', completed: false },
]
return () => (
<ul
mix={[
on('click', (event) => {
let target = event.target as HTMLElement
if (target.tagName === 'BUTTON') {
let todoId = target.dataset.id
let todo = todos.find((t) => t.id === todoId)
if (todo) {
todo.completed = !todo.completed
handle.update()
}
}
}),
]}
>
{todos.map((todo) => (
<li key={todo.id}>
<span
css={{
textDecoration: todo.completed ? 'line-through' : 'none',
}}
>
{todo.text}
</span>
<button data-id={todo.id}>
{todo.completed ? 'Undo' : 'Complete'}
</button>
</li>
))}
</ul>
)
}
Custom Event Types
Use TypeScript for type-safe event handlers:import type { Dispatched } from 'remix/component'
function FileInput(handle: Handle) {
return () => (
<input
type="file"
mix={[
on('change', (event: Dispatched<'change', HTMLInputElement>) => {
let files = event.currentTarget.files
if (files && files.length > 0) {
// Handle file upload...
}
}),
]}
/>
)
}
Ref for DOM Access
Use theref() mixin to get DOM node references:
import { ref, on } from 'remix/component'
function Form(handle: Handle) {
let inputRef: HTMLInputElement
return () => (
<form>
<input
type="text"
mix={[ref((node) => (inputRef = node))]}
/>
<button
mix={[
on('click', () => {
inputRef.focus()
}),
]}
>
Focus Input
</button>
</form>
)
}
Ref with Cleanup
The ref callback receives a signal for cleanup:import { ref } from 'remix/component'
function ResizeTracker(handle: Handle) {
let dimensions = { width: 0, height: 0 }
return () => (
<div
mix={[
ref((node, signal) => {
// Set up observer
let observer = new ResizeObserver((entries) => {
let entry = entries[0]
if (entry) {
dimensions.width = Math.round(entry.contentRect.width)
dimensions.height = Math.round(entry.contentRect.height)
handle.update()
}
})
observer.observe(node)
// Clean up when element is removed
signal.addEventListener('abort', () => {
observer.disconnect()
})
}),
]}
>
Size: {dimensions.width} × {dimensions.height}
</div>
)
}
Focus and Scroll Management
Usehandle.queueTask() for DOM operations after rendering:
import { ref, on } from 'remix/component'
function Modal(handle: Handle) {
let isOpen = false
let closeButton: HTMLButtonElement
let openButton: HTMLButtonElement
return () => (
<div>
<button
mix={[
ref((node) => (openButton = node)),
on('click', () => {
isOpen = true
handle.update()
// Focus after modal renders
handle.queueTask(() => {
closeButton.focus()
})
}),
]}
>
Open Modal
</button>
{isOpen && (
<div role="dialog">
<button
mix={[
ref((node) => (closeButton = node)),
on('click', () => {
isOpen = false
handle.update()
// Focus after modal closes
handle.queueTask(() => {
openButton.focus()
})
}),
]}
>
Close
</button>
</div>
)}
</div>
)
}
Press Events
Use thepressEvents mixin for press interactions:
import { pressEvents } from 'remix/component'
import type { PressEvent } from 'remix/component'
function PressButton(handle: Handle) {
let pressed = false
return () => (
<div
mix={[
pressEvents({
onPress(event: PressEvent) {
pressed = true
handle.update()
setTimeout(() => {
pressed = false
handle.update()
}, 200)
},
}),
]}
css={{
padding: '20px',
backgroundColor: pressed ? 'blue' : '#eee',
color: pressed ? 'white' : '#333',
borderRadius: '8px',
cursor: 'pointer',
}}
>
Press me
</div>
)
}
Keys Events
Use thekeysEvents mixin for keyboard shortcuts:
import { keysEvents } from 'remix/component'
function KeyboardShortcuts(handle: Handle) {
handle.on(document, {
...keysEvents({
'cmd+k': (event) => {
event.preventDefault()
// Open command palette
},
'cmd+s': (event) => {
event.preventDefault()
// Save
},
escape: () => {
// Close modal
},
}),
})
return () => <div>Press Cmd+K or Cmd+S</div>
}
Next Steps
- Component Overview - Return to component basics
- State Management - Learn state patterns