Component API Reference
Complete reference for @remix-run/component - a reactive component system for building interactive UIs.
Installation
Core Concepts
The Component package provides a two-phase component model:
- Setup phase - Runs once when component is created
- Render phase - Runs initially and on every update
Components use explicit state management with plain JavaScript variables and opt-in reactivity via handle.update().
Root Management
createRoot()
Creates a root for rendering components into a DOM container.
function createRoot(
container: HTMLElement,
options?: VirtualRootOptions
): VirtualRoot
The DOM element to render into. All content will be replaced.
Optional configuration for the root.Custom frame handle for the root frame.
Custom scheduler for managing updates.
Custom style manager for CSS handling.
Root instance with methods for rendering and lifecycle management.render
(element: RemixNode) => void
Renders a component tree into the root. Can be called multiple times to update the app.
Synchronously flushes all pending updates and tasks. Essential for testing.
Removes the component tree and cleans up all resources.
Example
import { createRoot } from 'remix/component'
let container = document.getElementById('app')!
let root = createRoot(container)
root.render(<App />)
// Later, update the app
root.render(<App key="updated" />)
// Clean up
root.dispose()
Testing Example
import { createRoot } from 'remix/component'
let container = document.createElement('div')
let root = createRoot(container)
root.render(<Counter />)
root.flush() // Ensure initial render completes
let button = container.querySelector('button')
button.click()
root.flush() // Flush to apply updates
expect(container.textContent).toBe('Count: 1')
createRangeRoot()
Creates a root that renders into a range between two DOM nodes (start and end markers).
function createRangeRoot(
[start, end]: [Node, Node],
options?: VirtualRootOptions
): VirtualRoot
Start boundary node (typically a Comment node).
End boundary node (typically a Comment node). Must share the same parent as start.
Optional configuration (same as createRoot).
Example
import { createRangeRoot } from 'remix/component'
let start = document.createComment('start')
let end = document.createComment('end')
document.body.append(start, end)
let root = createRangeRoot([start, end])
root.render(<App />)
Component Factory
Component Type
Components follow a factory pattern with two phases: setup and render.
type Component<Context = NoContext, Setup = undefined, Props = ElementProps> = (
handle: Handle<Context>,
setup: Setup,
) => (props: Props) => RemixNode
Component handle providing access to lifecycle, context, and updates.
Setup prop passed during component initialization (excluded from render props).
RenderFn
(props: Props) => RemixNode
Function returned by setup phase, called on initial render and updates.
Example
import type { Handle } from 'remix/component'
// Simple component (no setup)
function Greeting(handle: Handle) {
return (props: { name: string }) => (
<div>Hello, {props.name}!</div>
)
}
// With setup prop
function Counter(handle: Handle, setup: number) {
let count = setup // Initialize from setup prop
return (props: { label: string }) => (
<div>
<p>{props.label}: {count}</p>
<button mix={[on('click', () => {
count++
handle.update()
})]}>
Increment
</button>
</div>
)
}
// Usage
<Counter setup={10} label="Count" />
Handle API
The Handle object provides the component’s interface to the framework.
Handle Type
interface Handle<C = Record<string, never>> {
id: string
context: Context<C>
update(): Promise<AbortSignal>
queueTask(task: Task): void
frame: FrameHandle
frames: {
readonly top: FrameHandle
get(name: string): FrameHandle | undefined
}
signal: AbortSignal
}
handle.id
Stable identifier per component instance. Useful for HTML APIs like htmlFor, aria-owns, etc.
Unique identifier that persists for the component’s lifetime.
Example
function LabeledInput(handle: Handle) {
return () => (
<div>
<label htmlFor={handle.id}>Name</label>
<input id={handle.id} type="text" />
</div>
)
}
handle.update()
Schedules a component update and returns a promise that resolves with an AbortSignal after the update completes.
update(): Promise<AbortSignal>
Promise that resolves after the update completes. The signal is aborted when the component re-renders or is removed.
Basic Example
function Counter(handle: Handle) {
let count = 0
return () => (
<button mix={[on('click', () => {
count++
handle.update()
})]}>
Count: {count}
</button>
)
}
Async Example with Signal
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>
)
}
handle.queueTask()
Schedules a task to run after the next update. The task receives an AbortSignal that’s aborted when the component re-renders or is removed.
queueTask(task: Task): void
type Task = (signal: AbortSignal) => void
task
(signal: AbortSignal) => void
required
Function to execute after the next update. Receives an AbortSignal that’s aborted on re-render or removal.
Use in Event Handlers
Queue DOM operations that need to happen after the next update:
function Modal(handle: Handle) {
let isOpen = false
let closeButton: HTMLButtonElement
return () => (
<div>
<button mix={[on('click', () => {
isOpen = true
handle.update()
// Queue focus after modal renders
handle.queueTask(() => {
closeButton.focus()
})
})]}>
Open Modal
</button>
{isOpen && (
<div role="dialog">
<button mix={[ref(node => closeButton = node)]}>
Close
</button>
</div>
)}
</div>
)
}
Use in Render Function
Queue reactive work that responds to prop changes:
function DataLoader(handle: Handle) {
let data: any = null
let loading = false
return (props: { url: string }) => {
// Task is aborted if props.url changes or component is removed
handle.queueTask(async (signal) => {
loading = true
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 (!data) return <div>No data</div>
return <div>{JSON.stringify(data)}</div>
}
}
handle.signal
An AbortSignal that’s aborted when the component is disconnected from the tree.
Signal that’s aborted when the component is removed. Useful for cleanup operations.
Example with setInterval
function Clock(handle: Handle) {
let interval = setInterval(() => {
if (handle.signal.aborted) {
clearInterval(interval)
return
}
handle.update()
}, 1000)
return () => <span>{new Date().toString()}</span>
}
Example with addEventListener
function Clock(handle: Handle) {
let interval = setInterval(handle.update, 1000)
handle.signal.addEventListener('abort', () => {
clearInterval(interval)
})
return () => <span>{new Date().toString()}</span>
}
handle.context
Context API for ancestor/descendant communication without prop drilling.
interface Context<C> {
set(values: C): void
get<ComponentType>(component: ComponentType): ContextFrom<ComponentType>
get(component: ElementType | symbol): unknown | undefined
}
context.set()
Stores values in the component’s context. Does not trigger updates automatically.
Values to store in context. Must match the Context type parameter.
context.get()
Retrieves context values from an ancestor component.
get<ComponentType>(component: ComponentType): ContextFrom<ComponentType>
The component type to retrieve context from. Type is inferred from the component’s Handle type parameter.
ContextFrom<ComponentType>
Context values provided by the ancestor component.
Basic Context Example
function ThemeProvider(handle: Handle<{ theme: 'light' | 'dark' }>) {
let theme: 'light' | 'dark' = 'light'
handle.context.set({ theme })
return (props: { children: RemixNode }) => (
<div>
<button mix={[on('click', () => {
theme = theme === 'light' ? 'dark' : 'light'
handle.context.set({ theme })
handle.update() // Must update to re-render tree
})]}>
Toggle Theme
</button>
{props.children}
</div>
)
}
function ThemedContent(handle: Handle) {
let { theme } = handle.context.get(ThemeProvider)
return () => (
<div css={{ backgroundColor: theme === 'dark' ? '#000' : '#fff' }}>
Current theme: {theme}
</div>
)
}
Granular Updates with TypedEventTarget
For better performance, use TypedEventTarget to avoid updating the entire subtree:
import { TypedEventTarget } from 'remix/component'
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<Theme>) {
let theme = new Theme()
handle.context.set(theme)
return (props: { children: RemixNode }) => (
<div>
<button mix={[on('click', () => {
// No handle.update() needed - consumers subscribe to changes
theme.setValue(theme.value === 'light' ? 'dark' : 'light')
})]}>
Toggle Theme
</button>
{props.children}
</div>
)
}
function ThemedContent(handle: Handle) {
let theme = handle.context.get(ThemeProvider)
// Subscribe to granular updates
handle.on(theme, {
change() {
handle.update()
},
})
return () => (
<div css={{ backgroundColor: theme.value === 'dark' ? '#000' : '#fff' }}>
Current theme: {theme.value}
</div>
)
}
handle.frame
Reference to the component’s closest frame.
The closest ancestor Frame or the root frame.
handle.frames
Access to named frames in the current runtime tree.
The root frame for the current runtime tree.
get
(name: string) => FrameHandle | undefined
Retrieves a frame by name.
Mixins
Mixins provide reusable element enhancements through the mix prop.
createMixin()
Creates a custom mixin descriptor.
function createMixin<
node extends EventTarget = Element,
args extends unknown[] = [],
props extends ElementProps = ElementProps,
>(type: MixinType<node, args, props>): MixinDescriptor<node, args, props>
type MixinType<node, args, props> = (
handle: MixinHandle<node, props>,
type: string,
) => ((...args: [...args, currentProps: props]) => void | RemixElement) | void
type
MixinType<node, args, props>
required
Mixin setup function that receives a handle and returns an optional runner function.
MixinHandle
The mixin handle provides lifecycle events and utilities:
Stable identifier for the element.
Function for returning elements from mixins.
Signal aborted when element is removed.
update
() => Promise<AbortSignal>
Schedules an update for the parent component.
queueTask
(task: (node, signal) => void) => void
Queues a task to run after the next update.
addEventListener
(type: string, listener: Function, options?) => void
Listens to mixin lifecycle events: insert, reclaimed, remove, beforeRemove, beforeUpdate, commit.
Lifecycle Events
Fired when the element is inserted into the DOM.
Fired when an existing DOM node is reclaimed (reused) during reconciliation.
Fired when the element is removed from the DOM.
Fired before removal. Use event.persistNode() to delay removal for animations.persistNode
(teardown: (signal) => void) => void
Register a teardown function to delay removal. The signal is aborted if removal is cancelled.
Fired before each update during the reconciliation phase.
Fired after each update when DOM mutations are complete.
import { createMixin } from 'remix/component'
let tooltip = createMixin<HTMLElement, [text: string]>((handle) => {
let tooltipEl: HTMLDivElement
handle.addEventListener('insert', (event) => {
tooltipEl = document.createElement('div')
tooltipEl.className = 'tooltip'
document.body.appendChild(tooltipEl)
let node = event.node
node.addEventListener('mouseenter', () => {
tooltipEl.style.display = 'block'
})
node.addEventListener('mouseleave', () => {
tooltipEl.style.display = 'none'
})
})
handle.addEventListener('remove', () => {
tooltipEl?.remove()
})
return (text) => {
if (tooltipEl) {
tooltipEl.textContent = text
}
return handle.element
}
})
// Usage
<button mix={[tooltip('Click me!')]}>Hover</button>
on()
Attaches event listeners to elements with automatic cleanup.
function on<target extends Element, type extends EventType<target>>(
type: type,
handler: (event: Event, signal: AbortSignal) => void | Promise<void>,
captureBoolean?: boolean,
): MixinDescriptor
Event type (e.g., ‘click’, ‘input’, ‘submit’).
handler
(event, signal) => void | Promise<void>
required
Event handler function. Receives the event and an AbortSignal that’s aborted when the handler is re-entered or the component is removed.
Whether to use capture phase (default: false).
Example
import { on } from 'remix/component'
function SearchInput(handle: Handle) {
let results: string[] = []
let loading = false
return () => (
<input
type="text"
mix={[on('input', async (event, signal) => {
let query = event.currentTarget.value
loading = true
handle.update()
// Signal is aborted if user types again
let response = await fetch(`/search?q=${query}`, { signal })
if (signal.aborted) return
results = await response.json()
loading = false
handle.update()
})]}
/>
)
}
ref()
Gets a reference to the DOM node after rendering.
function ref<node extends EventTarget>(
callback: (node: node, signal: AbortSignal) => void
): MixinDescriptor
type RefCallback<node extends EventTarget> = (node: node, signal: AbortSignal) => void
callback
(node, signal) => void
required
Function called once when the element is first rendered. Receives the DOM node and an AbortSignal that’s aborted when the element is removed.
Example: Focus Management
import { ref } from 'remix/component'
function Form(handle: Handle) {
let inputRef: HTMLInputElement
return () => (
<form>
<input type="text" mix={[ref(node => inputRef = node)]} />
<button mix={[on('click', () => inputRef.focus())]}>
Focus Input
</button>
</form>
)
}
Example: Cleanup with Signal
import { ref } from 'remix/component'
function ResizeTracker(handle: Handle) {
let dimensions = { width: 0, height: 0 }
return () => (
<div mix={[ref((node, signal) => {
let observer = new ResizeObserver((entries) => {
let entry = entries[0]
if (entry) {
dimensions.width = Math.round(entry.contentRect.width)
dimensions.height = Math.round(entry.contentRect.height)
handle.update()
}
})
observer.observe(node)
// Clean up when element is removed
signal.addEventListener('abort', () => {
observer.disconnect()
})
})]}>
Size: {dimensions.width} × {dimensions.height}
</div>
)
}
css()
Applies CSS styles with support for pseudo-selectors, media queries, and descendant selectors.
function css(styles: CSSProperties): MixinDescriptor
CSS styles object with support for nested selectors, pseudo-selectors, and media queries.
Basic Example
import { css } from 'remix/component'
function Button() {
return () => (
<button mix={[css({
color: 'white',
backgroundColor: 'blue',
padding: '12px 24px',
borderRadius: '4px',
border: 'none',
cursor: 'pointer',
})]}>
Click me
</button>
)
}
Pseudo-selectors
<button mix={[css({
backgroundColor: 'blue',
'&:hover': {
backgroundColor: 'darkblue',
transform: 'translateY(-1px)',
},
'&:active': {
backgroundColor: 'navy',
transform: 'translateY(0)',
},
'&:disabled': {
opacity: 0.5,
cursor: 'not-allowed',
},
})]}>
Click me
</button>
<div mix={[css({
display: 'grid',
gridTemplateColumns: '1fr',
gap: '16px',
'@media (min-width: 768px)': {
gridTemplateColumns: 'repeat(2, 1fr)',
},
'@media (min-width: 1024px)': {
gridTemplateColumns: 'repeat(3, 1fr)',
},
})]}>
{/* content */}
</div>
Descendant Selectors
<nav mix={[css({
display: 'flex',
gap: '16px',
'& a': {
color: 'blue',
textDecoration: 'none',
padding: '8px 16px',
'&:hover': {
backgroundColor: '#f0f0f0',
},
'&[aria-current="page"]': {
backgroundColor: 'blue',
color: 'white',
},
},
})]}>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
Use css for static styles and pseudo-selectors. Use style prop for dynamic values:
function ProgressBar(handle: Handle) {
let progress = 0
return () => (
<div
mix={[css({
backgroundColor: 'blue', // Static
})]}
style={{
width: `${progress}%`, // Dynamic
}}
>
{progress}%
</div>
)
}
animateEntrance()
Animates element entrance with Web Animations API.
function animateEntrance(
keyframes: Keyframe[],
options?: KeyframeAnimationOptions
): MixinDescriptor
Array of keyframes for the animation.
Animation options (duration, easing, etc.).
Example
import { animateEntrance, spring } from 'remix/component'
<div mix={[animateEntrance(
[
{ opacity: 0, transform: 'translateY(-20px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
{ ...spring('bouncy') }
)]}>
Animated content
</div>
animateExit()
Animates element exit before removal.
function animateExit(
keyframes: Keyframe[],
options?: KeyframeAnimationOptions
): MixinDescriptor
Example
import { animateExit, tween, easings } from 'remix/component'
{showModal && (
<div mix={[animateExit(
[
{ opacity: 1, transform: 'scale(1)' },
{ opacity: 0, transform: 'scale(0.9)' },
],
{ ...tween({ from: 0, to: 1, duration: 200, curve: easings.easeOut }) }
)]}>
Modal content
</div>
)}
animateLayout()
Animates layout changes (position, size) when elements move or resize.
function animateLayout(options?: KeyframeAnimationOptions): MixinDescriptor
Animation options for layout transitions.
Example
import { animateLayout, spring } from 'remix/component'
function ReorderableList(handle: Handle) {
let items = ['A', 'B', 'C']
return () => (
<div>
{items.map((item) => (
<div key={item} mix={[animateLayout({ ...spring('smooth') })]}>
{item}
</div>
))}
</div>
)
}
Built-in Components
Fragment
Renders children without a wrapper element.
<Fragment>{children}</Fragment>
// or
<>{children}</>
Example
function List() {
return () => (
<>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</>
)
}
Frame
Renders nested Remix applications within frames.
<Frame src={string} name?: string fallback?: RemixNode />
URL or path to the frame content.
Optional frame name for identification.
Content to show while frame is loading.
Animation Utilities
spring()
Creates a spring-based animation iterator with physics-based motion.
function spring(preset: SpringPreset, overrides?: SpringOptions): SpringIterator
function spring(options?: SpringOptions): SpringIterator
type SpringPreset = 'smooth' | 'snappy' | 'bouncy'
interface SpringOptions {
duration?: number
bounce?: number
velocity?: number
}
interface SpringIterator extends IterableIterator<number> {
duration: number
easing: string
toString(): string
}
preset
'smooth' | 'snappy' | 'bouncy'
Preset spring configuration.
smooth: duration 400ms, bounce -0.3 (overdamped)
snappy: duration 200ms, bounce 0 (critically damped)
bouncy: duration 400ms, bounce 0.3 (underdamped)
Perceptual duration in milliseconds (default: 300).
Bounce amount: -1 to ~0.95. Negative = overdamped, 0 = critical, positive = bouncy.
Initial velocity in units per second.
Time when spring settles to rest (milliseconds).
CSS linear() easing function.
Returns “duration ms easing” for CSS transitions.
CSS Transition Example
import { spring } from 'remix/component'
let s = spring('bouncy')
element.style.transition = `transform ${s}`
element.style.transform = 'translateX(100px)'
WAAPI Example
import { spring } from 'remix/component'
element.animate(
[
{ transform: 'translateX(0)' },
{ transform: 'translateX(100px)' },
],
{ ...spring('smooth') }
)
JavaScript Animation Example
import { spring } from 'remix/component'
for (let position of spring({ duration: 500, bounce: 0.2 })) {
element.style.transform = `translateX(${position * 100}px)`
}
tween()
Creates a tween-based animation iterator with easing curves.
function tween(options: TweenOptions): TweenIterator
interface TweenOptions {
from: number
to: number
duration: number
curve: BezierCurve
}
interface BezierCurve {
x1: number
y1: number
x2: number
y2: number
}
const easings = {
linear: { x1: 0, y1: 0, x2: 1, y2: 1 },
ease: { x1: 0.25, y1: 0.1, x2: 0.25, y2: 1 },
easeIn: { x1: 0.42, y1: 0, x2: 1, y2: 1 },
easeOut: { x1: 0, y1: 0, x2: 0.58, y2: 1 },
easeInOut: { x1: 0.42, y1: 0, x2: 0.58, y2: 1 },
}
Starting value (typically 0).
Ending value (typically 1).
Duration in milliseconds.
Cubic bezier curve for easing.
Example
import { tween, easings } from 'remix/component'
element.animate(
[
{ opacity: 0, transform: 'scale(0.9)' },
{ opacity: 1, transform: 'scale(1)' },
],
{ ...tween({ from: 0, to: 1, duration: 300, curve: easings.easeOut }) }
)
Utility Types and Classes
TypedEventTarget
A strongly-typed EventTarget subclass for custom events.
class TypedEventTarget<eventMap> extends EventTarget {
addEventListener<type extends keyof eventMap>(
type: type,
listener: (event: eventMap[type]) => void,
options?: AddEventListenerOptions,
): void
removeEventListener<type extends keyof eventMap>(
type: type,
listener: (event: eventMap[type]) => void,
options?: EventListenerOptions,
): void
}
Example
import { TypedEventTarget } from 'remix/component'
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<Theme>) {
let theme = new Theme()
handle.context.set(theme)
return (props: { children: RemixNode }) => (
<div>{props.children}</div>
)
}
function Consumer(handle: Handle) {
let theme = handle.context.get(ThemeProvider)
handle.on(theme, {
change() {
handle.update()
},
})
return () => <div>Theme: {theme.value}</div>
}
RemixNode
Type representing any renderable content.
type Renderable = RemixElement | string | number | bigint | boolean | null | undefined
type RemixNode = Renderable | RemixNode[]
Use for component children props:
function Layout() {
return (props: { children: RemixNode }) => (
<div>
<header>My App</header>
<main>{props.children}</main>
</div>
)
}
Props<T>
Extracts props for a specific HTML element type.
type Props<T extends keyof JSX.IntrinsicElements> = JSX.IntrinsicElements[T]
Example
interface MyButtonProps extends Props<"button"> {
size: "sm" | "md" | "lg"
}
function MyButton() {
return (props: MyButtonProps) => (
<button className={`btn-${props.size}`} {...props}>
{props.children}
</button>
)
}
JSX Configuration
TypeScript Config
Configure tsconfig.json for Remix JSX:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "remix/component"
}
}
Props Interface
All host elements accept these standard props:
Unique identifier for list reconciliation.
Array of mixin descriptors for element enhancements.
Inline styles (use for dynamic values).
All standard HTML attributes are also supported.
Best Practices
State Management
Use minimal component state - only store what’s needed for rendering:
// ✅ Good: Derive computed values
function TodoList(handle: Handle) {
let todos: Array<{ text: string; completed: boolean }> = []
return () => {
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>
)
}
}
// ❌ Avoid: 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>
)
}
Event Handler Patterns
Do work in event handlers with minimal component state:
// ✅ Good: Work in handler, only store what renders need
function SearchResults(handle: Handle) {
let results: string[] = []
let loading = false
return () => (
<input mix={[on('input', async (event, signal) => {
let query = event.currentTarget.value
loading = true
handle.update()
let response = await fetch(`/search?q=${query}`, { signal })
let data = await response.json()
if (signal.aborted) return
results = data.results
loading = false
handle.update()
})]} />
)
}
CSS Prop Usage
Use css for static styles, style for dynamic values:
function ProgressBar(handle: Handle) {
let progress = 0
return () => (
<div
mix={[css({
height: '20px',
backgroundColor: '#eee',
borderRadius: '10px',
})]}
style={{
width: `${progress}%`, // Dynamic
}}
/>
)
}
Only control inputs when programmatic control is needed:
// ✅ Uncontrolled: User is the only source of truth
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
})]}>
<input name="query" />
<button type="submit">Search</button>
</form>
)
}
// ✅ Controlled: Programmatic control needed
function SlugForm(handle: Handle) {
let slug = ''
let autoGenerate = false
return () => (
<input
type="text"
value={autoGenerate ? generateSlug() : slug}
disabled={autoGenerate}
mix={[on('input', (event) => {
slug = event.currentTarget.value
handle.update()
})]}
/>
)
}