- 16 cross-shell utilities converted to sh/bash chezmoi executables: ai c2p cp-appid extract_agents fab gdc gh-sonr-org-migrate ghg-clone ghn gho git-summary launch_omp_livediff_overlay lc-elir mdcp mdp pj (ghg-clone not named ghg: unrelated cargo binary owns that name) - cwd/history helpers stay per-shell: yy c cd clip-last + ghg/ghn/gho cd-wrappers in bash/functions/ and zsh/functions/, loaded by rc files - fish keeps only shell-state functions; converted ones now hit PATH - cp-appid ignore gating moved to .local/bin path - dot_agents/skills: 82 skills materialized from dotstate storage so dot_claude skill symlinks resolve on fresh bootstrap; dotstate profile/symlinks.json released .agents/skills .bashrc .zshrc
7.6 KiB
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 zero-argument function runs on initial render and every update
The component shape is function Component(handle: Handle<Props>) { return () => ... }. Props are available as handle.props in setup scope and are updated before every render.
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:
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, 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', () => theme.setValue(theme.value === 'light' ? 'dark' : 'light'))}>
Toggle
</button>
{handle.props.children}
</div>
)
}
function ThemedContent(handle: Handle) {
let theme = handle.context.get(ThemeProvider)
addEventListeners(theme, handle.signal, {
change() {
handle.update()
},
})
return () => <div>Theme: {theme.value}</div>
}
Global Events
Use addEventListeners(target, handle.signal, listeners) to listen to global targets with automatic cleanup when the component disconnects:
import { addEventListeners, type Handle } from 'remix/ui'
function ResizeTracker(handle: Handle) {
let width = window.innerWidth
addEventListeners(window, handle.signal, {
resize() {
width = window.innerWidth
handle.update()
},
})
return () => <div>{width}</div>
}