references/component-model.md
references/component-model.mdBrowse 12 files
7,537 bytes
Token encoding: o200k_base
Snapshot 8beb6cd
Component Model
What This Covers
How a Remix Component is shaped and how its state, lifecycle, and updates behave. Read this when the task involves:
- Writing a component (
handleplus render function) - Managing component-local state, derived values, or post-render DOM work
- Using
handle.props,handle.update(),handle.queueTask(),handle.signal,handle.id, orhandle.context - Listening to global events with cleanup tied to the component lifecycle
For host-element behavior (event handlers, styles, refs, animations), see
mixins-styling-events.md. For browser hydration, frames, and navigation, see
hydration-frames-navigation.md.
Phases
A component has two phases:
- Setup phase — runs once when the component is created
- Render phase — returned function runs on initial render and every update
import { on, type Handle } from 'remix/ui'
function Counter(handle: Handle<{ initialCount?: number; label: string }>) {
let count = handle.props.initialCount ?? 0
return () => (
<button
mix={on('click', () => {
count++
handle.update()
})}
>
{handle.props.label}: {count}
</button>
)
}
Props
Components receive all JSX props through handle.props. The object identity is
stable for the component lifetime, and its values are updated before each
render. Put initialization inputs on normal JSX props and read them from
handle.props:
function Timer(handle: Handle<{ initialSeconds: number; paused?: boolean }>) {
let seconds = handle.props.initialSeconds
return () => <div>Time remaining: {seconds}s</div>
}
// Usage: <Timer initialSeconds={60} paused={false} />
Because handle.props is stable, destructuring let { props } = handle is safe
when helpers need to read current values later. Destructuring individual prop
values is only a snapshot; prefer handle.props.name inside callbacks and
render output when values can change.
State Rules
- Keep state in setup scope as plain JavaScript variables.
- Store only what affects rendering. Derive computed values in render.
- Do not mirror input state unless you truly need controlled behavior.
- Do work in event handlers, not in render. Use the handler scope for transient state.
// Derive computed values in render
function TodoList(handle: Handle) {
let todos: Array<{ text: string; completed: boolean }> = []
return () => {
let completedCount = todos.filter((t) => t.completed).length
return <div>Completed: {completedCount}</div>
}
}
Handle API
handle.update()
Schedules a rerender. Returns a promise that resolves with an AbortSignal
after the update completes. Await it when you need the updated DOM before
follow-up work:
on('click', async () => {
isPlaying = true
let signal = await handle.update()
// DOM is now updated, safe to focus or measure
stopButton.focus()
})
handle.queueTask(task)
Schedules a task to run after the next update. The task receives an
AbortSignal that aborts when the component re-renders or is removed. Use for
post-render DOM work, reactive data loading, or hydration-sensitive setup:
let data = null
let requestedUrl: string | null = null
// Post-render DOM work in an event handler
on('click', () => {
showDetails = true
handle.update()
handle.queueTask(() => {
detailsSection.scrollIntoView({ behavior: 'smooth' })
})
})
// Reactive data loading keyed by props.url
return () => {
if (requestedUrl !== handle.props.url) {
let nextUrl = handle.props.url
requestedUrl = nextUrl
data = null
handle.queueTask(async (signal) => {
let response = await fetch(nextUrl, { signal })
let json = await response.json()
if (signal.aborted || requestedUrl !== nextUrl) return
data = json
handle.update()
})
}
return <div>{data ?? 'Loading...'}</div>
}
Avoid creating intermediate state just to trigger queueTask. Do the work
directly in the handler or the queued task.
handle.signal
An AbortSignal aborted when the component disconnects. Use for cleanup. Listen
with native target.addEventListener(type, listener, { signal }) — there is no
addEventListeners() helper.
function Clock(handle: Handle) {
let interval = setInterval(handle.update, 1000)
handle.signal.addEventListener('abort', () => clearInterval(interval))
return () => <span>{new Date().toString()}</span>
}
handle.id
Stable identifier per component instance. Useful for htmlFor, aria-owns,
etc.:
function LabeledInput(handle: Handle) {
return () => (
<div>
<label htmlFor={handle.id}>Name</label>
<input id={handle.id} type="text" />
</div>
)
}
handle.frame and handle.frames
Frame-aware behavior for client entries rendered inside frames:
handle.frame.reload()— reload the containing framehandle.frame.src— the URL of the containing framehandle.frames.top— the root frame (the whole page)handle.frames.top.reload()— reload the entire page/frame treehandle.frames.get(name)— look up a named frame; returnsFrameHandle | undefined
function RefreshButton(handle: Handle) {
return () => (
<button mix={on('click', () => handle.frame.reload())}>Refresh</button>
)
}
handle.context
Context for ancestor/descendant communication. See the context section below.
Context
Use handle.context.set() to provide values and handle.context.get(Provider)
to consume them. set() does not trigger updates — call handle.update()
if the tree needs to rerender.
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
</button>
{handle.props.children}
</div>
)
}
function ThemedContent(handle: Handle) {
let { theme } = handle.context.get(ThemeProvider)
return () => <div>Current theme: {theme}</div>
}
For granular updates without re-rendering the full subtree, use
TypedEventTarget:
import { TypedEventTarget } 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', () =>
theme.setValue(theme.value === 'light' ? 'dark' : 'light'),
)}
>
Toggle
</button>
{handle.props.children}
</div>
)
}
function ThemedContent(handle: Handle) {
let theme = handle.context.get(ThemeProvider)
theme.addEventListener(
'change',
() => {
handle.update()
},
{ signal: handle.signal },
)
return () => <div>Theme: {theme.value}</div>
}
Global Events
Use native addEventListener with { signal: handle.signal } so listeners
clean up when the component disconnects:
import { type Handle } from 'remix/ui'
function ResizeTracker(handle: Handle) {
let width = window.innerWidth
window.addEventListener(
'resize',
() => {
width = window.innerWidth
handle.update()
},
{ signal: handle.signal },
)
return () => <div>{width}</div>
}