Skip to main content

Component Rendering

Remix components follow a two-phase rendering model: setup once, then render on every update.

Component Lifecycle

First Render

  1. Component function is called with handle and setup prop
  2. Setup phase runs once to initialize state
  3. Returned render function is stored
  4. Render function is called with props
  5. Queued tasks execute after rendering

Subsequent Updates

  1. Only the render function is called (setup is skipped)
  2. Props are passed to the render function
  3. The setup prop is excluded from props
  4. Queued tasks execute after rendering

Component Removal

  1. handle.signal is aborted
  2. Event listeners registered via handle.on() are cleaned up
  3. Queued tasks execute with an aborted signal

Basic Rendering

The simplest component returns JSX:
function Greeting() {
  return (props: { name: string }) => (
    <div>Hello, {props.name}!</div>
  )
}

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

Rendering with State

Use handle.update() to trigger re-renders:
import type { Handle } from 'remix/component'
import { on } from 'remix/component'

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

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

Prop Passing

Props flow from parent to child through JSX attributes:
function Parent() {
  return () => (
    <Child message="Hello from parent" count={42} />
  )
}

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

Conditional Rendering

Use JavaScript expressions for conditional rendering:
function Toggle(handle: Handle) {
  let isOpen = false

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

Lists and Keys

Use the key prop to identify list items:
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>
  )
}

Why Keys Matter

Keys enable efficient diffing and preserve:
  • DOM nodes - Elements with matching keys are reused, not recreated
  • Component state - Component instances persist across reorders
  • Focus and selection - Input focus stays with the same element
  • Form values - Input 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>
  )
}

Key Guidelines

// 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 Children

Components can compose other components via children:
import type { RemixNode } from 'remix/component'

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

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

Fragment for Grouping

Use Fragment to group elements without adding DOM nodes:
import { Fragment } from 'remix/component'

function List() {
  return () => (
    <Fragment>
      <li>Item 1</li>
      <li>Item 2</li>
      <li>Item 3</li>
    </Fragment>
  )
}

// Or use the shorthand syntax
function List() {
  return () => (
    <>
      <li>Item 1</li>
      <li>Item 2</li>
      <li>Item 3</li>
    </>
  )
}

Async Updates

Wait for updates to complete before performing DOM operations:
function Player(handle: Handle) {
  let isPlaying = false
  let playButton: HTMLButtonElement
  let stopButton: HTMLButtonElement

  return () => (
    <div>
      <button
        disabled={isPlaying}
        mix={[
          ref((node) => (playButton = node)),
          on('click', async () => {
            isPlaying = true
            await handle.update()
            // Focus the enabled button after update completes
            stopButton.focus()
          }),
        ]}
      >
        Play
      </button>
      <button
        disabled={!isPlaying}
        mix={[
          ref((node) => (stopButton = node)),
          on('click', async () => {
            isPlaying = false
            await handle.update()
            // Focus the enabled button after update completes
            playButton.focus()
          }),
        ]}
      >
        Stop
      </button>
    </div>
  )
}

Queued Tasks

Use handle.queueTask() for work that needs to happen after rendering:
import { ref, on } from 'remix/component'

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

  return () => (
    <form>
      <label>
        <input
          type="checkbox"
          checked={showDetails}
          mix={[on('change', (event) => {
            showDetails = event.currentTarget.checked
            handle.update()
            if (showDetails) {
              // Scroll after the section renders
              handle.queueTask(() => {
                detailsSection.scrollIntoView({ behavior: 'smooth' })
              })
            }
          })]}
        />
        Show details
      </label>
      {showDetails && (
        <section
          css={{ marginTop: '2rem', padding: '1rem' }}
          mix={[ref((node) => (detailsSection = node))]}
        >
          <h2>Additional Details</h2>
        </section>
      )}
    </form>
  )
}

Context for Indirect Composition

Use context to share values without prop drilling:
import type { Handle, RemixNode } from 'remix/component'

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

  handle.context.set({ theme })

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

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

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

TypedEventTarget for Efficient Updates

For better performance, use TypedEventTarget to avoid updating the entire subtree:
import { TypedEventTarget } from 'remix/component'

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<Theme>) {
  let theme = new Theme()
  handle.context.set(theme)

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

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

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

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

Next Steps

Build docs developers (and LLMs) love