mirror of
https://github.com/prdlk/dotfiles.git
synced 2026-11-03 11:37:36 +00:00
- 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
271 lines
7.6 KiB
Markdown
271 lines
7.6 KiB
Markdown
# 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 (`handle` plus render function)
|
|
- Managing component-local state, derived values, or post-render DOM work
|
|
- Using `handle.props`, `handle.update()`, `handle.queueTask()`, `handle.signal`, `handle.id`, or `handle.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:
|
|
|
|
1. **Setup phase** — runs once when the component is created
|
|
2. **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.
|
|
|
|
```tsx
|
|
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`:
|
|
|
|
```tsx
|
|
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.
|
|
|
|
```tsx
|
|
// 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:
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
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.:
|
|
|
|
```tsx
|
|
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 frame
|
|
- `handle.frame.src` — the URL of the containing frame
|
|
- `handle.frames.top` — the root frame (the whole page)
|
|
- `handle.frames.top.reload()` — reload the entire page/frame tree
|
|
- `handle.frames.get(name)` — look up a named frame; returns `FrameHandle | undefined`
|
|
|
|
```tsx
|
|
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.
|
|
|
|
```tsx
|
|
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`:
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
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>
|
|
}
|
|
```
|