Documentation Index
Fetch the complete documentation index at: https://mintlify.com/remix-run/remix/llms.txt
Use this file to discover all available pages before exploring further.
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>
)
}
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>
)
}
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>
)
}
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