Skip to content

Latest commit

 

History

History
2031 lines (1703 loc) · 50.6 KB

File metadata and controls

2031 lines (1703 loc) · 50.6 KB

Remix UI - Agent Guide

This guide provides a comprehensive overview of the Remix UI API, its runtime behavior, and practical use cases for building interactive UIs.

Note: Host-element on, css, animate, and connect props were removed. Use mix={[on('event', handler)]}, mix={[css(...)]}, animation mixins, and mix={[ref(...)]} instead.

Getting Started

Creating a Root

To start using Remix UI, create a root and render your top-level component:

import { createRoot, on } from 'remix/ui'
import type { Handle } from 'remix/ui'

function App(handle: Handle) {
  return () => (
    <div>
      <h1>Hello, World!</h1>
    </div>
  )
}

// Create a root attached to a DOM element
let container = document.getElementById('app')!
let root = createRoot(container)

// Render your app
root.render(<App />)

The createRoot function takes a DOM element (or document.body) and returns a root object with a render method. You can call render multiple times to update the app:

function App(handle: Handle) {
  let count = 0

  return () => (
    <div>
      <h1>Count: {count}</h1>
      <button
        mix={[
          on('click', () => {
            count++
            handle.update()
          }),
        ]}
      >
        Increment
      </button>
    </div>
  )
}

let root = createRoot(document.body)
root.render(<App />)

// Later, you can update the app by calling render again
// root.render(<App />)

Root Methods

The root object provides several methods:

  • render(node) - Renders a component tree into the root container
  • flush() - Synchronously flushes all pending updates and tasks
  • dispose() - Removes the component tree and cleans up
let root = createRoot(document.body)

// Render initial app
root.render(<App />)

// Flush any pending updates synchronously
root.flush()

// Later, remove the app
root.dispose()

Component Factory and Runtime Behavior

Component Structure

All components follow a consistent two-phase structure:

  1. Setup Phase - Runs once when the component is first created
  2. Render Phase - Runs on initial render and every update afterward
function MyComponent(handle: Handle<Props>) {
  // Setup phase: runs once
  let state = initializeState(handle.props)

  // Return render function: runs on every update
  return () => {
    return <div>{/* render content */}</div>
  }
}

Runtime Behavior

When a component is rendered:

  1. First Render:

    • The component function is called with handle
    • The returned render function is stored
    • The render function is called after handle.props is populated
    • Any tasks queued via handle.queueTask() are executed after rendering
  2. Subsequent Updates:

    • Only the render function is called
    • Setup phase is skipped, and the closure persists for the lifetime of the component instance
    • handle.props is updated before the render function is called
    • Tasks queued during the update are executed after rendering
  3. Component Removal:

    • handle.signal is aborted
    • Listeners registered with addEventListeners(..., handle.signal, ...) are automatically cleaned up
    • Any queued tasks are executed with an aborted signal

Props On The Handle

Props are available on handle.props. The object is stable, and its values are updated before each render:

function Counter(handle: Handle<{ initialCount: number; label: string }>) {
  let count = handle.props.initialCount

  return () => {
    return (
      <div>
        {handle.props.label}: {count}
      </div>
    )
  }
}

// Usage
let element = <Counter initialCount={10} label="Count" />

Handle API

The Handle object provides the component's interface to the framework:

handle.update()

Schedules a component update and returns a promise that resolves with an AbortSignal after the update completes.

function Counter(handle: Handle) {
  let count = 0

  return () => (
    <button
      mix={[
        on('click', () => {
          count++
          handle.update()
        }),
      ]}
    >
      Count: {count}
    </button>
  )
}

Waiting for the update:

function Player(handle: Handle) {
  let isPlaying = false
  let stopButton: HTMLButtonElement

  return () => (
    <button
      disabled={isPlaying}
      mix={[
        on('click', async () => {
          isPlaying = true
          await handle.update()
          stopButton.focus()
        }),
      ]}
    >
      Play
    </button>
  )
}

handle.queueTask(task)

Schedules a task to run after the next update. The task receives an AbortSignal that's aborted when:

  • The component re-renders (new render cycle starts)
  • The component is removed from the tree

Use queueTask in event handlers when work needs to happen after DOM changes:

function Form(handle: Handle) {
  let showDetails = false
  let detailsSection: HTMLElement

  return () => (
    <form>
      <input
        type="checkbox"
        checked={showDetails}
        mix={[
          on('change', (event) => {
            showDetails = event.currentTarget.checked
            handle.update()
            if (showDetails) {
              // Queue DOM operation after the new section renders
              handle.queueTask(() => {
                detailsSection.scrollIntoView({ behavior: 'smooth' })
              })
            }
          }),
        ]}
      />
      {showDetails && (
        <section mix={[ref((node) => (detailsSection = node))]}>Details content</section>
      )}
    </form>
  )
}

Use queueTask for work that needs to be reactive to prop changes:

When you need to perform async work (like data fetching) that should respond to prop changes, use queueTask in the render function. The signal will be aborted if props change or the component is removed, ensuring only the latest work completes.

❌ Anti-pattern: Don't create states as values to "react to" on the next render with queueTask:

// ❌ Avoid: Creating state just to react to it in queueTask
function BadExample(handle: Handle) {
  let shouldLoad = false // Unnecessary state

  return () => (
    <div>
      <button
        mix={[
          on('click', () => {
            shouldLoad = true // Setting state just to trigger queueTask
            handle.update()
            handle.queueTask(() => {
              if (shouldLoad) {
                // Do work
              }
            })
          }),
        ]}
      >
        Load
      </button>
    </div>
  )
}

// ✅ Prefer: Do the work directly in the event handler or queueTask
function GoodExample(handle: Handle) {
  return () => (
    <div>
      <button
        mix={[
          on('click', () => {
            handle.queueTask(() => {
              // Do work directly - no intermediate state needed
            })
          }),
        ]}
      >
        Load
      </button>
    </div>
  )
}

Pattern: await handle.update() when showing loading state before async work:

When you need to show loading UI before async work starts, set loading state, call await handle.update(), and use the returned signal for async APIs.

function GoodAsyncExample(handle: Handle) {
  let data: string[] = []
  let loading = false

  async function load() {
    loading = true
    let signal = await handle.update()
    let response = await fetch('/api/data', { signal })
    if (signal.aborted) return

    data = await response.json()
    loading = false
    handle.update()
  }

  return () => <button mix={[on('click', load)]}>{loading ? 'Loading...' : 'Load data'}</button>
}

Signals in events and tasks are how you manage interruptions and disconnects:

Both event handlers and queueTask receive AbortSignal parameters that are automatically aborted when:

  • The component is removed from the tree
  • For event handlers: The handler is re-entered (user triggers another event)
  • For queueTask: The component re-renders (props changed)

Always check signal.aborted or pass the signal to async APIs (like fetch) to handle interruptions gracefully.

handle.signal

An AbortSignal that's aborted when the component is disconnected. Useful for cleanup operations.

function Clock(handle: Handle) {
  let interval = setInterval(() => {
    if (handle.signal.aborted) {
      clearInterval(interval)
      return
    }
    handle.update()
  }, 1000)

  return () => <span>{new Date().toString()}</span>
}

Or using event listeners:

function Clock(handle: Handle) {
  let interval = setInterval(handle.update, 1000)
  handle.signal.addEventListener('abort', () => clearInterval(interval))

  return () => <span>{new Date().toString()}</span>
}

addEventListeners(target, handle.signal, listeners)

Listen to an EventTarget with automatic cleanup when the component disconnects. Ideal for global event targets like document and window.

import { addEventListeners, type Handle } from 'remix/ui'

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>
}

handle.id

Stable identifier per component instance. Useful for HTML APIs like htmlFor, aria-owns, etc.

function LabeledInput(handle: Handle) {
  return () => (
    <div>
      <label htmlFor={handle.id}>Name</label>
      <input id={handle.id} type="text" />
    </div>
  )
}

handle.context

Context API for ancestor/descendant communication. Use handle.context.set() to provide values and handle.context.get() to consume them.

Important: handle.context.set() does not cause any updates - it simply stores a value. If you need the component tree to update when context changes, call handle.update() after setting the context, or use an EventTarget on context for descendants to subscribe to changes (see the TypedEventTarget example in the Context section).

Context lookup is keyed by component identity. handle.context.get(Component) reads the nearest ancestor instance whose component function is exactly Component, so nested instances of the same provider shadow outer instances while different component types remain independent.

function App(handle: Handle<Record<string, never>, { theme: string }>) {
  handle.context.set({ theme: 'dark' })

  return () => (
    <div>
      <Header />
      <Content />
    </div>
  )
}

function Header(handle: Handle) {
  let { theme } = handle.context.get(App)
  return () => (
    <header
      mix={[
        css({
          backgroundColor: theme === 'dark' ? '#000' : '#fff',
        }),
      ]}
    >
      Header
    </header>
  )
}

Rendering and Composition

Basic Rendering

The simplest component just returns JSX:

function Greeting(handle: Handle<{ name: string }>) {
  return () => <div>Hello, {handle.props.name}!</div>
}

let el = <Greeting name="World" />

Prop Passing

Props flow from parent to child through JSX attributes:

function Parent() {
  return () => <Child message="Hello from parent" count={42} />
}

function Child(handle: Handle<{ message: string; count: number }>) {
  return () => (
    <div>
      <p>{handle.props.message}</p>
      <p>Count: {handle.props.count}</p>
    </div>
  )
}

Stateful Updates

State is managed with plain JavaScript variables. Call handle.update() to trigger a re-render:

function Counter(handle: Handle) {
  let count = 0

  return () => (
    <div>
      <span>Count: {count}</span>
      <button
        mix={[
          on('click', () => {
            count++
            handle.update()
          }),
        ]}
      >
        Increment
      </button>
    </div>
  )
}

State Management Best Practices

Use Minimal Component State

Only store state that's needed for rendering. Derive computed values instead of storing them, and avoid storing input state that you don't need.

Derive computed values:

// ❌ Avoid: Storing computed values
function TodoList(handle: Handle) {
  let todos: string[] = []
  let completedCount = 0 // Unnecessary state

  return () => (
    <div>
      {todos.map((todo, i) => (
        <div key={i}>{todo}</div>
      ))}
      <div>Completed: {completedCount}</div>
    </div>
  )
}

// ✅ Prefer: Derive computed values in render
function TodoList(handle: Handle) {
  let todos: Array<{ text: string; completed: boolean }> = []

  return () => {
    // Derive computed value in render
    let completedCount = todos.filter((t) => t.completed).length

    return (
      <div>
        {todos.map((todo, i) => (
          <div key={i}>{todo.text}</div>
        ))}
        <div>Completed: {completedCount}</div>
      </div>
    )
  }
}

Don't store input state you don't need:

// ❌ Avoid: Storing input value when you only need it on submit
function SearchForm(handle: Handle) {
  let query = '' // Unnecessary state

  return () => (
    <form
      mix={[
        on('submit', (event) => {
          event.preventDefault()
          let formData = new FormData(event.currentTarget)
          let query = formData.get('query') as string
          // Use query for search
        }),
      ]}
    >
      <input name="query" />
      <button type="submit">Search</button>
    </form>
  )
}

// ✅ Prefer: Read input value directly from the form
function SearchForm(handle: Handle) {
  return () => (
    <form
      mix={[
        on('submit', (event) => {
          event.preventDefault()
          let formData = new FormData(event.currentTarget)
          let query = formData.get('query') as string
          // Use query for search - no component state needed
        }),
      ]}
    >
      <input name="query" />
      <button type="submit">Search</button>
    </form>
  )
}

Do Work in Event Handlers

Do as much work as possible in event handlers with minimal component state. Use the event handler scope for transient event state, and only capture to component state if it's used for rendering.

Use event handler scope for transient state:

// ❌ Avoid: Storing transient state in component
function FormValidator(handle: Handle) {
  let validationError: string | null = null // Only needed during validation

  return () => (
    <form
      mix={[
        on('submit', (event) => {
          event.preventDefault()
          let formData = new FormData(event.currentTarget)
          let email = formData.get('email') as string

          // Validation logic
          if (!email.includes('@')) {
            validationError = 'Invalid email'
            handle.update()
            return
          }

          // Submit form
          validationError = null
          handle.update()
        }),
      ]}
    >
      {validationError && <div>{validationError}</div>}
      <input name="email" />
      <button type="submit">Submit</button>
    </form>
  )
}

// ✅ Prefer: Keep transient state in event handler scope
function FormValidator(handle: Handle) {
  let validationError: string | null = null // Only stored if needed for rendering

  return () => (
    <form
      mix={[
        on('submit', (event) => {
          event.preventDefault()
          let formData = new FormData(event.currentTarget)
          let email = formData.get('email') as string

          // Validation logic - keep transient state in handler scope
          if (!email.includes('@')) {
            validationError = 'Invalid email' // Only store if rendering needs it
            handle.update()
            return
          }

          // Submit form - clear error if it exists
          if (validationError) {
            validationError = null
            handle.update()
          }
        }),
      ]}
    >
      {validationError && <div>{validationError}</div>}
      <input name="email" />
      <button type="submit">Submit</button>
    </form>
  )
}

Only store state needed for rendering:

// ✅ Good: Store state that affects rendering
function Toggle(handle: Handle) {
  let isOpen = false // Needed for rendering conditional content

  return () => (
    <div>
      <button
        mix={[
          on('click', () => {
            isOpen = !isOpen
            handle.update()
          }),
        ]}
      >
        Toggle
      </button>
      {isOpen && <div>Content</div>}
    </div>
  )
}

// ✅ 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>
  )
}

css(...) Mixin with Pseudo-Selectors and Descendant Selectors

The css(...) mixin provides stylesheet-like styling with support for pseudo-selectors, pseudo-elements, attribute selectors, descendant selectors, and media queries. It follows modern CSS nesting selector rules. Use & to reference the current element in pseudo-selectors and attribute selectors.

Basic css(...) Usage

function Button() {
  return () => (
    <button
      mix={[
        css({
          color: 'white',
          backgroundColor: 'blue',
          padding: '12px 24px',
          borderRadius: '4px',
          border: 'none',
          cursor: 'pointer',
        }),
      ]}
    >
      Click me
    </button>
  )
}

Performance: css(...) vs style

The css(...) mixin produces static styles that are inserted into the document as CSS rules, while the style prop applies styles directly to the element. For dynamic styles that change frequently, use the style prop for better performance:

// ❌ Avoid: Putting dynamic values into css(...)
function ProgressBar(handle: Handle) {
  let progress = 0

  return () => <div style={{ width: `${progress}%`, backgroundColor: 'blue' }}>{progress}%</div>
}

// ✅ Prefer: Keep static rules in css(...) and dynamic values in style
function ProgressBar(handle: Handle) {
  let progress = 0

  return () => (
    <div
      mix={[
        css({
          backgroundColor: 'blue', // Static styles in css(...)
        }),
      ]}
      style={{
        width: `${progress}%`, // Dynamic styles in style prop
      }}
    >
      {progress}%
    </div>
  )
}

Use css(...) for:

  • Static styles that don't change
  • Styles that need pseudo-selectors (:hover, :focus, etc.)
  • Styles that need media queries

Use the style prop for:

  • Dynamic styles that change based on state or props
  • Computed values that update frequently

Pseudo-Selectors

Use & to reference the current element in pseudo-selectors:

function Button() {
  return () => (
    <button
      mix={[
        css({
          color: 'white',
          backgroundColor: 'blue',
          padding: '12px 24px',
          borderRadius: '4px',
          border: 'none',
          cursor: 'pointer',
          '&:hover': {
            backgroundColor: 'darkblue',
            transform: 'translateY(-1px)',
          },
          '&:active': {
            backgroundColor: 'navy',
            transform: 'translateY(0)',
          },
          '&:focus': {
            outline: '2px solid yellow',
            outlineOffset: '2px',
          },
          '&:disabled': {
            opacity: 0.5,
            cursor: 'not-allowed',
          },
        }),
      ]}
    >
      Click me
    </button>
  )
}

Pseudo-Elements

Use &::before and &::after for pseudo-elements:

function Badge(handle: Handle<{ count: number }>) {
  return () => (
    <div
      mix={[
        css({
          position: 'relative',
          display: 'inline-block',
          '&::before': {
            content: '""',
            position: 'absolute',
            top: '-4px',
            right: '-4px',
            width: '8px',
            height: '8px',
            backgroundColor: 'red',
            borderRadius: '50%',
          },
        }),
      ]}
    >
      {handle.props.count > 0 && <span>{handle.props.count}</span>}
    </div>
  )
}

Attribute Selectors

Use &[attribute] for attribute selectors:

function Input(handle: Handle<{ required?: boolean }>) {
  return () => (
    <input
      required={handle.props.required}
      mix={[
        css({
          padding: '8px',
          border: '1px solid #ccc',
          borderRadius: '4px',
          '&[required]': {
            borderColor: 'red',
          },
          '&[aria-invalid="true"]': {
            borderColor: 'red',
            outline: '2px solid red',
          },
        }),
      ]}
    />
  )
}

Descendant Selectors

Use class names or element selectors directly for descendant selectors:

function Card(handle: Handle<{ children: RemixNode }>) {
  return () => (
    <div
      mix={[
        css({
          padding: '20px',
          border: '1px solid #ddd',
          borderRadius: '8px',
          backgroundColor: 'white',
          boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
          // Style descendants
          '& h2': {
            marginTop: 0,
            fontSize: '24px',
            fontWeight: 'bold',
          },
          '& p': {
            color: '#666',
            lineHeight: 1.6,
          },
          '& .icon': {
            width: '24px',
            height: '24px',
            marginRight: '8px',
          },
          '& button': {
            marginTop: '16px',
          },
        }),
      ]}
    >
      {handle.props.children}
    </div>
  )
}

When to Use Nested Selectors

Use nested selectors when parent state affects children. Don't nest when you can style the element directly.

This is preferable to creating JavaScript state and passing it around. Instead of managing hover/focus state in JavaScript and passing it as props, use CSS nested selectors to let the browser handle state transitions declaratively.

Use nested selectors when:

  1. Parent state affects children - Parent hover/focus/state changes child styling (prefer this over JavaScript state management)
  2. Styling descendant elements - Avoid duplicating styles on every child or creating new components just for styling. Style children from the parent component instead.

Don't nest when:

  • Styling the element's own pseudo-states (hover, focus, etc.)
  • The element controls its own styling

Example: Parent hover affects children (use nested selectors, not JavaScript state):

// ❌ Avoid: Managing hover state in JavaScript
function CardWithJSState(handle: Handle<{ children: RemixNode }>) {
  let isHovered = false

  return () => (
    <div
      mix={[
        on('mouseenter', () => {
          isHovered = true
          handle.update()
        }),
        on('mouseleave', () => {
          isHovered = false
          handle.update()
        }),
      ]}
      style={{
        border: `1px solid ${isHovered ? 'blue' : '#ddd'}`,
      }}
    >
      <div className="title" style={{ color: isHovered ? 'blue' : '#333' }}>
        Title
      </div>
    </div>
  )
}

// ✅ Prefer: CSS nested selectors handle state declaratively
function Card(handle: Handle<{ children: RemixNode }>) {
  return () => (
    <div
      mix={[
        css({
          border: '1px solid #ddd',
          borderRadius: '8px',
          padding: '20px',
          // Parent hover affects children - use nested selector
          '&:hover': {
            borderColor: 'blue',
            // Child text changes color on parent hover
            '& .title': {
              color: 'blue',
            },
            '& .description': {
              opacity: 1,
            },
          },
          '& .title': {
            fontSize: '20px',
            fontWeight: 'bold',
            color: '#333',
          },
          '& .description': {
            opacity: 0.7,
            marginTop: '8px',
          },
        }),
      ]}
    >
      <div className="title">Title</div>
    </div>
  )
}

Example: Element's own hover (style directly, no nesting needed):

function Button() {
  return () => (
    <button
      mix={[
        css({
          backgroundColor: 'blue',
          color: 'white',
          padding: '12px 24px',
          borderRadius: '4px',
          border: 'none',
          cursor: 'pointer',
          // Element's own hover - style directly, no nesting needed
          '&:hover': {
            backgroundColor: 'darkblue',
          },
          '&:active': {
            transform: 'scale(0.98)',
          },
        }),
      ]}
    >
      Click me
    </button>
  )
}

Example: Navigation with links (descendant styling is appropriate):

function Navigation() {
  return () => (
    <nav
      mix={[
        css({
          display: 'flex',
          gap: '16px',
          // Styling descendant links - appropriate use of nesting
          '& a': {
            color: 'blue',
            textDecoration: 'none',
            padding: '8px 16px',
            borderRadius: '4px',
            // Link's own hover state - this is fine nested under '& a'
            '&:hover': {
              backgroundColor: '#f0f0f0',
              color: 'darkblue',
            },
            '&[aria-current="page"]': {
              backgroundColor: 'blue',
              color: 'white',
            },
          },
        }),
      ]}
    >
      <a href="/">Home</a>
      <a href="/about">About</a>
      <a href="/contact">Contact</a>
    </nav>
  )
}

Media Queries

Use @media for responsive design:

function ResponsiveGrid(handle: Handle<{ children: RemixNode }>) {
  return () => (
    <div
      mix={[
        css({
          display: 'grid',
          gap: '16px',
          gridTemplateColumns: '1fr',
          '@media (min-width: 768px)': {
            gridTemplateColumns: 'repeat(2, 1fr)',
          },
          '@media (min-width: 1024px)': {
            gridTemplateColumns: 'repeat(3, 1fr)',
          },
        }),
      ]}
    >
      {handle.props.children}
    </div>
  )
}

Combining All Features

Here's a comprehensive example demonstrating parent-state-affecting-children and media queries, with styles applied directly to elements:

function ProductCard(handle: Handle<{ title: string; price: number; image: string }>) {
  return () => (
    <div
      mix={[
        css({
          border: '1px solid #ddd',
          borderRadius: '8px',
          overflow: 'hidden',
          transition: 'transform 0.2s, box-shadow 0.2s',
          // Parent hover affects the card itself
          '&:hover': {
            transform: 'translateY(-4px)',
            boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
            // Parent hover affects children - appropriate use of nesting
            '& .title': {
              color: 'blue',
            },
            '& button': {
              backgroundColor: 'darkblue',
            },
          },
          '@media (max-width: 768px)': {
            '&:hover': {
              transform: 'translateY(-2px)',
            },
          },
        }),
      ]}
    >
      <img
        src={handle.props.image}
        alt={handle.props.title}
        mix={[
          css({
            width: '100%',
            height: '200px',
            objectFit: 'cover',
            '@media (max-width: 768px)': {
              height: '150px',
            },
          }),
        ]}
      />
      <div
        className="content"
        mix={[
          css({
            padding: '16px',
            '@media (max-width: 768px)': {
              padding: '12px',
            },
          }),
        ]}
      >
        <h3
          className="title"
          mix={[
            css({
              fontSize: '18px',
              fontWeight: 'bold',
              marginTop: 0,
              marginBottom: '8px',
              transition: 'color 0.2s',
            }),
          ]}
        >
          {handle.props.title}
        </h3>
        <div
          className="price"
          mix={[
            css({
              fontSize: '20px',
              color: 'green',
              fontWeight: 'bold',
            }),
          ]}
        >
          ${handle.props.price}
        </div>
        <button
          mix={[
            css({
              width: '100%',
              padding: '12px',
              backgroundColor: 'blue',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer',
              transition: 'background-color 0.2s',
              '&:active': {
                transform: 'scale(0.98)',
              },
            }),
          ]}
        >
          Add to Cart
        </button>
      </div>
    </div>
  )
}

This example demonstrates:

  • Parent hover affecting children: Card hover changes title color and button background (only nested selector needed)
  • Styles on elements themselves: Each element (img, .content, .title, .price, button) has its own css(...) mixin
  • Element's own states: Button's :active state styled directly on the button
  • Media queries: Responsive adjustments applied directly to elements that need them

Ref Mixin

Use the ref(...) mixin to get a reference to the DOM node after it's rendered. This is useful for DOM operations like focusing elements, scrolling, measuring dimensions, or setting up observers.

function Form(handle: Handle) {
  let inputRef: HTMLInputElement

  return () => (
    <form>
      <input type="text" mix={[ref((node) => (inputRef = node))]} />
      <button
        mix={[
          on('click', () => {
            // Focus the input from elsewhere in the form
            inputRef.focus()
          }),
        ]}
      >
        Focus Input
      </button>
    </form>
  )
}

The ref callback receives an AbortSignal as its second parameter, which is aborted when the element is removed from the DOM. Use this for cleanup operations:

function ResizeTracker(handle: Handle) {
  let dimensions = { width: 0, height: 0 }

  return () => (
    <div
      mix={[
        ref((node, signal) => {
          // Set up ResizeObserver
          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>
  )
}

The ref callback is called only once when the element is first rendered, not on every update.

Key Prop

Use the key prop to uniquely identify elements in lists. Keys enable efficient diffing and preserve DOM nodes and component state when lists are reordered, filtered, or updated.

function TodoList(handle: Handle) {
  let todos = [
    { id: '1', text: 'Buy milk' },
    { id: '2', text: 'Walk dog' },
    { id: '3', text: 'Write code' },
  ]

  return () => (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  )
}

When you reorder, add, or remove items, keys ensure:

  • DOM nodes are reused - Elements with matching keys are moved, not recreated
  • Component state is preserved - Component instances persist across reorders
  • Focus and selection are maintained - Input focus stays with the same element
  • Input values are preserved - Form values remain with their elements
function ReorderableList(handle: Handle) {
  let items = [
    { id: 'a', label: 'Item A' },
    { id: 'b', label: 'Item B' },
    { id: 'c', label: 'Item C' },
  ]

  function reverse() {
    items = [...items].reverse()
    handle.update()
  }

  return () => (
    <div>
      <button mix={[on('click', reverse)]}>Reverse List</button>
      {items.map((item) => (
        <div key={item.id}>
          <input type="text" defaultValue={item.label} />
        </div>
      ))}
    </div>
  )
}

Even when the list order changes, each input maintains its value and focus state because the key prop identifies which DOM node corresponds to which item.

Keys can be any type (string, number, bigint, object, symbol), but should be stable and unique within the list:

// Good: stable, unique IDs
{
  items.map((item) => <Item key={item.id} item={item} />)
}

// Good: index can work if list never reorders
{
  items.map((item, index) => <Item key={index} item={item} />)
}

// Bad: don't use random values or values that change
{
  items.map((item) => <Item key={Math.random()} item={item} />)
}

Composition Through props.children

Components can compose other components via children:

function Layout(handle: Handle<{ children: RemixNode }>) {
  return () => (
    <div
      mix={[
        css({
          padding: '20px',
          maxWidth: '1200px',
          margin: '0 auto',
        }),
      ]}
    >
      <header>My App</header>
      <main>{handle.props.children}</main>
      <footer>© 2024</footer>
    </div>
  )
}

function App() {
  return () => (
    <Layout>
      <h1>Welcome</h1>
      <p>Content goes here</p>
    </Layout>
  )
}

Context for Indirect Composition

Context enables components to communicate without direct prop passing:

Basic Context

function ThemeProvider(handle: Handle<{ children?: RemixNode }, { theme: 'light' | 'dark' }>) {
  let theme: 'light' | 'dark' = 'light'

  handle.context.set({ theme })

  return () => (
    <div>
      <button
        mix={[
          on('click', () => {
            theme = theme === 'light' ? 'dark' : 'light'
            handle.context.set({ theme })
            handle.update()
          }),
        ]}
      >
        Toggle Theme
      </button>
      {handle.props.children}
    </div>
  )
}

function ThemedContent(handle: Handle) {
  let { theme } = handle.context.get(ThemeProvider)

  return () => (
    <div
      mix={[
        css({
          backgroundColor: theme === 'dark' ? '#000' : '#fff',
        }),
      ]}
    >
      Current theme: {theme}
    </div>
  )
}

Note: handle.context.set() does not cause any updates - it simply stores a value. If you want the component tree to update when context changes, you must call handle.update() after setting the context (as shown above), or use an EventTarget on context for descendants to subscribe to changes (as shown in the TypedEventTarget example below).

Context lookup is keyed by component identity. handle.context.get(Component) reads the nearest ancestor instance whose component function is exactly Component.

TypedEventTarget for Granular Updates

For better performance, use TypedEventTarget to avoid updating the entire subtree:

import { TypedEventTarget, addEventListeners } from 'remix/ui'

class Theme extends TypedEventTarget<{ change: Event }> {
  #value: 'light' | 'dark' = 'light'

  get value() {
    return this.#value
  }

  setValue(value: 'light' | 'dark') {
    this.#value = value
    this.dispatchEvent(new Event('change'))
  }
}

function ThemeProvider(handle: Handle<{ children?: RemixNode }, Theme>) {
  let theme = new Theme()
  handle.context.set(theme)

  return () => (
    <div>
      <button
        mix={[
          on('click', () => {
            // No update needed - consumers subscribe to changes
            theme.setValue(theme.value === 'light' ? 'dark' : 'light')
          }),
        ]}
      >
        Toggle Theme
      </button>
      {handle.props.children}
    </div>
  )
}

function ThemedContent(handle: Handle) {
  let theme = handle.context.get(ThemeProvider)

  // Subscribe to granular updates
  addEventListeners(theme, handle.signal, {
    change() {
      handle.update()
    },
  })

  return () => (
    <div
      mix={[
        css({
          backgroundColor: theme.value === 'dark' ? '#000' : '#fff',
        }),
      ]}
    >
      Current theme: {theme.value}
    </div>
  )
}

Common Patterns and Use Cases

Setup Scope Use Cases

The setup scope is perfect for one-time initialization:

Initializing Instances

function CacheExample(handle: Handle<{ cacheSize: number; key: string; value: any }>) {
  // Initialize cache once
  let cache = new Map<string, any>()
  let maxSize = handle.props.cacheSize

  return () => {
    // Use cache in render
    if (cache.has(handle.props.key)) {
      return <div>Cached: {cache.get(handle.props.key)}</div>
    }
    cache.set(handle.props.key, handle.props.value)
    if (cache.size > maxSize) {
      let firstKey = cache.keys().next().value
      cache.delete(firstKey)
    }
    return <div>New: {handle.props.value}</div>
  }
}

Third-Party SDKs

function Analytics(handle: Handle<{ apiKey: string; event: string; data?: any }>) {
  // Initialize SDK once
  let analytics = new AnalyticsSDK(handle.props.apiKey)

  // Cleanup on disconnect
  handle.signal.addEventListener('abort', () => {
    analytics.disconnect()
  })

  return () => {
    // SDK is ready to use
    return <div>Tracking: {handle.props.event}</div>
  }
}

EventEmitters

import { TypedEventTarget } from 'remix/ui'

class DataEvent extends Event {
  constructor(public value: string) {
    super('data')
  }
}

class DataEmitter extends TypedEventTarget<{ data: DataEvent }> {
  emitData(value: string) {
    this.dispatchEvent(new DataEvent(value))
  }
}

function EventListener(handle: Handle<{ emitter: DataEmitter }>) {
  // Set up listeners once with automatic cleanup
  addEventListeners(handle.props.emitter, handle.signal, {
    data(event) {
      // Handle data
      handle.update()
    },
  })

  return () => <div>Listening for events...</div>
}

Window/Document Event Handling

function WindowResizeTracker(handle: Handle) {
  let width = window.innerWidth
  let height = window.innerHeight

  // Set up global listeners once
  addEventListeners(window, handle.signal, {
    resize() {
      width = window.innerWidth
      height = window.innerHeight
      handle.update()
    },
  })

  return () => (
    <div>
      Window size: {width} × {height}
    </div>
  )
}

Initializing State from Props

function Timer(handle: Handle<{ initialSeconds: number; paused?: boolean }>) {
  // Initialize from props
  let seconds = handle.props.initialSeconds
  let interval: number | null = null

  function start() {
    if (interval) return
    interval = setInterval(() => {
      seconds--
      if (seconds <= 0) {
        stop()
      }
      handle.update()
    }, 1000)
  }

  function stop() {
    if (interval) {
      clearInterval(interval)
      interval = null
    }
  }

  // Cleanup on disconnect
  handle.signal.addEventListener('abort', stop)

  return () => {
    if (!handle.props.paused && !interval) {
      start()
    } else if (handle.props.paused && interval) {
      stop()
    }

    return <div>Time remaining: {seconds}s</div>
  }
}

Focus and Scroll Management

Use handle.queueTask() in event handlers for DOM operations that need to happen after the DOM has changed from the next update. This is the pattern for operations like focusing elements, scrolling, or measuring dimensions after conditional rendering.

Focus Management

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()
            // Queue focus operation 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()
                // Queue focus operation after modal closes
                handle.queueTask(() => {
                  openButton.focus()
                })
              }),
            ]}
          >
            Close
          </button>
        </div>
      )}
    </div>
  )
}

Scroll Management

function ScrollableList(handle: Handle) {
  let items: string[] = []
  let newItemInput: HTMLInputElement
  let listContainer: HTMLElement

  return () => (
    <div>
      <input
        mix={[
          ref((node) => (newItemInput = node)),
          on('keydown', (event) => {
            if (event.key === 'Enter') {
              let text = event.currentTarget.value
              if (text.trim()) {
                items.push(text)
                event.currentTarget.value = ''
                handle.update()
                // Queue scroll operation after new item renders
                handle.queueTask(() => {
                  listContainer.scrollTop = listContainer.scrollHeight
                })
              }
            }
          }),
        ]}
      />
      <div
        mix={[
          ref((node) => (listContainer = node)),
          css({
            maxHeight: '300px',
            overflowY: 'auto',
          }),
        ]}
      >
        {items.map((item, i) => (
          <div key={i}>{item}</div>
        ))}
      </div>
    </div>
  )
}

Key pattern: Do the work in the event handler (update state, call handle.update()), then use queueTask to perform DOM operations that depend on the updated DOM. Don't create intermediate state just to react to it in queueTask.

Controlled vs Uncontrolled Inputs

Only control an input's value when something besides the user's interaction with that input can also control its state. Otherwise, let the DOM manage the input's value and read from it when needed. This follows the principle of using minimal component state - don't store input state you don't need.

Uncontrolled Input (use when only the user controls the value):

function SearchInput(handle: Handle) {
  let results: string[] = []

  return () => (
    <div>
      <input
        type="text"
        mix={[
          on('input', async (event, signal) => {
            // Read value directly from the input - no component state needed
            let query = event.currentTarget.value
            // ... use query for search
          }),
        ]}
      />
    </div>
  )
}

Key principle: Don't store the input value in component state unless you need to:

  • Set it programmatically (controlled input)
  • Use it for rendering (e.g., showing character count)
  • Transform/validate it before it appears in the input

Controlled Input (use when programmatic control is needed):

function SlugForm(handle: Handle) {
  let slug = ''
  let generatedSlug = ''

  return () => (
    <form>
      <label>
        <input
          type="checkbox"
          mix={[
            on('change', (event) => {
              if (event.currentTarget.checked) {
                generatedSlug = crypto.randomUUID().slice(0, 8)
              } else {
                generatedSlug = ''
              }
              handle.update()
            }),
          ]}
        />
        Auto-generate slug
      </label>
      <label>
        Slug
        <input
          type="text"
          value={generatedSlug || slug}
          disabled={!!generatedSlug}
          mix={[
            on('input', (event) => {
              slug = event.currentTarget.value
              handle.update()
            }),
          ]}
        />
      </label>
    </form>
  )
}

Use controlled inputs when:

  • The value can be set programmatically (auto-generated fields, reset buttons, external state)
  • The input can be disabled and its value changed by other interactions (like the slug field above)
  • You need to validate or transform input before it appears
  • You need to prevent certain values from being entered

Use uncontrolled inputs when:

  • Only the user can change the value through direct interaction with that input
  • You just need to read the value on events (submit, blur, etc.)

Data Loading and Updates

Signals: Managing Interruptions and Disconnects

Signals in events and tasks are how you manage interruptions and disconnects. Both event handlers and queueTask receive AbortSignal parameters that are automatically aborted when:

  • The component is removed from the tree
  • For event handlers: The handler is re-entered (user triggers another event before the previous one completes)
  • For queueTask: The component re-renders (props changed, triggering a new render cycle)

Always check signal.aborted or pass the signal to async APIs (like fetch) to handle interruptions gracefully.

Using Event Handler Signals for Race Conditions

Event handlers receive an AbortSignal that's aborted when the handler is re-entered or the component is removed. Use this to prevent race conditions when the user is creating events faster than the 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 event handler signal is aborted when:

  • The user triggers another input event (new search query)
  • The component is removed

This ensures only the latest search request completes, preventing stale results from overwriting newer ones.

Using queueTask for Reactive Data Loading

Use handle.queueTask() in the render function for reactive data loading that responds to prop changes. The signal will be aborted if props change or the component is removed:

function DataLoader(handle: Handle<{ url: string }>) {
  let data: any = null
  let loading = false
  let error: Error | null = null

  return () => {
    // Queue data loading task that responds to prop changes
    handle.queueTask(async (signal) => {
      loading = true
      error = null
      handle.update()

      let response = await fetch(handle.props.url, { signal })
      let json = await response.json()
      if (signal.aborted) return
      data = json
      loading = false
      handle.update()
    })

    if (loading) return <div>Loading...</div>
    if (error) return <div>Error: {error.message}</div>
    if (!data) return <div>No data</div>

    return <div>{JSON.stringify(data)}</div>
  }
}

The render signal is aborted when:

  • The component re-renders (props changed, e.g., url prop changed)
  • The component is removed

This ensures only the latest data loading request completes. If the url prop changes while a request is in flight, the previous request is automatically cancelled.

Using Setup Scope for Initial Data

Load initial data in the setup scope:

function UserProfile(handle: Handle<{ userId: string; showEmail?: boolean }>) {
  let user: User | null = null
  let loading = true

  // Load initial data in setup scope using queueTask
  handle.queueTask(async (signal) => {
    let response = await fetch(`/api/users/${handle.props.userId}`, { signal })
    let data = await response.json()
    if (signal.aborted) return
    user = data
    loading = false
    handle.update()
  })

  return () => {
    if (loading) return <div>Loading user...</div>

    return (
      <div>
        <h1>{user.name}</h1>
        {handle.props.showEmail && <p>{user.email}</p>}
      </div>
    )
  }
}

Note that by fetching this data in the setup scope any parent updates that change handle.props.userId will have no effect.

Testing

When writing tests, use root.flush() to synchronously execute all pending updates and tasks. This ensures the DOM and component state are fully synchronized before making assertions.

The main use case is flushing after events that call handle.update(). Since updates are asynchronous, you need to flush to ensure the DOM reflects the changes:

function Counter(handle: Handle) {
  let count = 0

  return () => (
    <button
      mix={[
        on('click', () => {
          count++
          handle.update()
        }),
      ]}
    >
      Count: {count}
    </button>
  )
}

// In your test
let container = document.createElement('div')
let root = createRoot(container)

root.render(<Counter />)
root.flush() // Ensure initial render completes

let button = container.querySelector('button')
button.click() // Triggers handle.update()
root.flush() // Flush to apply the update

expect(container.textContent).toBe('Count: 1')

You should also flush after the initial root.render() to ensure event listeners are attached and the DOM is ready for interaction.

Summary

  • Components have two phases: setup (runs once) and render (runs after setup and on updates)
  • State is managed with plain JavaScript variables
  • Updates are explicit via handle.update()
  • Setup phase can initialize stable state from initial handle.props values
  • Context enables indirect composition without prop drilling
  • TypedEventTarget provides granular updates for better performance
  • State management best practices:
    • Use minimal component state - derive computed values, don't store input state you don't need
    • Do as much work as possible in event handlers - use event handler scope for transient state, only capture to component state if used for rendering
  • queueTask patterns:
    • Use in event handlers when work needs to happen after DOM changes from the next update
    • Use in render function for work that needs to be reactive to prop changes
    • Don't create states as values to "react to" on the next render with queueTask
  • AbortSignals in events and tasks manage interruptions and disconnects - always check signal.aborted or pass to async APIs