Skip to main content

Event Handling

Remix uses real DOM events with the on() mixin. Event listeners automatically receive an AbortSignal for managing interruptions and cleanup.

Basic Event Handling

Use the on() 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 an AbortSignal:
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
Use the signal to cancel async work and prevent race conditions:
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

Use handle.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>
}
Listeners are automatically removed when the component disconnects.

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 the ref() 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

Use handle.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 the pressEvents 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 the keysEvents 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

Build docs developers (and LLMs) love