Skip to main content

State Management

Remix components manage state with plain JavaScript variables. Call handle.update() to trigger re-renders.

Basic State

State is stored in the setup scope and persists for the component’s lifetime:
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>
  )
}

Initializing State from Setup Prop

Use the setup prop to initialize state:
function Counter(handle: Handle, setup: number) {
  let count = setup // Initialize from setup prop

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

// Usage
<Counter setup={10} label="Total" />

Best Practices

1. Use Minimal Component State

Only store state needed for rendering. Derive computed values instead of storing them:
// Good: Derive computed values
function TodoList(handle: Handle) {
  let todos: Array<{ text: string; completed: boolean }> = []

  return () => {
    // Derive in render, don't store
    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>
    )
  }
}

// Bad: 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>
  )
}

2. Do Work in Event Handlers

Keep transient state in event handler scope. Only store in component state if needed for rendering:
// Good: Transient state in event handler
function FormValidator(handle: Handle) {
  let validationError: string | null = null

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

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

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

3. Don’t Store Input State You Don’t Need

Read input values directly from the DOM when possible:
// Good: Read value when needed
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>
  )
}

// Bad: Storing input value unnecessarily
function SearchForm(handle: Handle) {
  let query = '' // Unnecessary state

  return () => (
    <form
      mix={[on('submit', (event) => {
        event.preventDefault()
        // Use query...
      })]}
    >
      <input
        name="query"
        value={query}
        mix={[on('input', (event) => {
          query = event.currentTarget.value
          handle.update()
        })]}
      />
      <button type="submit">Search</button>
    </form>
  )
}

Controlled vs Uncontrolled Inputs

Uncontrolled Inputs

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 - no component state
          let query = event.currentTarget.value
          // Fetch results...
        })]}
      />
    </div>
  )
}

Controlled Inputs

Use when the value can be set programmatically:
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>
  )
}

Async State and Loading

Use await handle.update() to show loading states:
function DataLoader(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>
  )
}

Data Loading Patterns

Event Handler Pattern

Load data in response to user events:
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()

          // Signal automatically aborts previous requests
          let response = await fetch(`/search?q=${query}`, { signal })
          let data = await response.json()
          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>
  )
}

Reactive Loading with queueTask

Load data that responds to prop changes:
function DataLoader(handle: Handle) {
  let data: any = null
  let loading = false
  let error: Error | null = null

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

      let response = await fetch(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>
  }
}

Initial Data Loading

Load data once in the setup scope:
interface User {
  name: string
  email: string
}

function UserProfile(handle: Handle, setup: { userId: string }) {
  let user: User | null = null
  let loading = true

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

    user = data
    loading = false
    handle.update()
  })

  return (props: { showEmail?: boolean }) => {
    if (loading) return <div>Loading user...</div>

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

Managing Cleanup with Signals

Use handle.signal for cleanup when the component disconnects:
function Clock(handle: Handle) {
  let interval = setInterval(() => {
    if (handle.signal.aborted) {
      clearInterval(interval)
      return
    }
    handle.update()
  }, 1000)

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

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

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

Global Event Listeners

Use handle.on() for automatic cleanup:
function KeyboardTracker(handle: Handle) {
  let keys: string[] = []

  handle.on(document, {
    keydown(event) {
      keys.push(event.key)
      handle.update()
    },
  })

  return () => <div>Keys: {keys.join(', ')}</div>
}

Context for Shared State

Share state between components without prop drilling:
import type { Handle, RemixNode } from 'remix/component'

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

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

function Header(handle: Handle) {
  let { theme } = handle.context.get(App)

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

Next Steps

  • Styling - Style components with the CSS prop
  • Events - Handle user interactions

Build docs developers (and LLMs) love