remix/component/jsx-dev-runtime
A minimal component system built on JavaScript and DOM primitives. Write components that render on the server, stream to the browser, and hydrate only where you need interactivity.
Features
- Component runtime APIs for rendering, hydration, link and form frame navigation, and JSX
- Server rendering APIs for streaming component trees and frames
mixcomposition with event, ref, and CSS helpers- Lower-level utilities for events, refs, attributes, and rendering lifecycle
Installation
npm i remixUsage
Components receive a handle and return a render function. Local state stays in ordinary JavaScript
variables, and handle.update() explicitly schedules another render:
import { on } from 'remix/component'
import type { Handle } from 'remix/component'
function Counter(handle: Handle<{ initialCount?: number }>) {
let count = handle.props.initialCount ?? 0
return () => (
<button
type="button"
mix={on('click', () => {
count++
void handle.update()
})}
>
Count: {count}
</button>
)
}Custom Mixins
Use createMixin(...) to share host behavior, manage DOM resources, or provide default props. The Mixins guide covers setup and render, MixinHandle, lifecycle events, and deferred removal with beforeRemove and event.persistNode(...).
Client Entry Loading
run() hydrates client entries by calling loadModule for each component module:
import { run } from 'remix/component'
let app = run({
async loadModule(moduleUrl, exportName) {
let mod = await import(moduleUrl)
return mod[exportName]
},
})
await app.ready()Client entries introduced after the initial document may depend on import maps added at runtime.
When targeting browsers without native support for multiple import maps, use
remix/multiple-import-maps-polyfill to load these modules and process their preloads:
import {
detectMultipleImportMapSupport,
importModule,
preloadShim,
} from 'remix/multiple-import-maps-polyfill'
let app = run({
async loadModule(moduleUrl, exportName) {
let module = await importModule(moduleUrl)
let Component = module[exportName]
if (typeof Component !== 'function') {
throw new Error(`Unknown component: ${moduleUrl}#${exportName}`)
}
return Component
},
async processClientEntryPreloads(preloads) {
if (await detectMultipleImportMapSupport()) return preloads
preloadShim(preloads)
return []
},
})Frame Navigation
The same runtime represents the current document as app.frames.top and intercepts eligible
same-origin links and forms through the browser's Navigation API. Those navigations fetch HTML with
the frame resolver and update the existing document in place instead of loading a new document.
This soft-navigation behavior applies even when the page only uses clientEntry() and does not
render an explicit <Frame>.
Frame navigation requires both window.navigation and NavigateEvent.sourceElement. Browsers
missing either capability use document navigation for links, forms, and navigate(). Hydration and
explicit frame reloads still work.
The default resolver sends X-Remix-Frame: true for every request, including top-frame navigation and reloads. Named frames also send X-Remix-Target; top-frame and unnamed frame requests omit the target. It only fetches same-origin sources and follows same-origin redirects.
The default browser resolver omits X-Remix-Top-Frame-Src. When render() middleware handles these requests, the server-rendered handle.frames.top.src defaults to the requested frame's URL.
The default resolver is equivalent to:
async function resolveFrame(src, options) {
let headers = new Headers({ Accept: 'text/html', 'X-Remix-Frame': 'true' })
if (options?.target != null) headers.set('X-Remix-Target', options.target)
let response = await fetch(src, {
body: getRequestBody(options),
headers,
method: options?.method,
mode: 'same-origin',
signal: options?.signal,
})
let isHtml = response.headers.get('Content-Type')?.toLowerCase().includes('text/html')
if (response.status >= 500 || (response.status >= 300 && !isHtml)) {
throw new Error(`Failed to resolve frame: ${response.status} ${response.statusText}`.trimEnd())
}
return response
}
function getRequestBody(options) {
let formData = options?.formData
let method = options?.method
if (!formData || !method || ['get', 'head'].includes(method.toLowerCase())) return
if (options?.encType === 'text/plain') {
let body = ''
for (let [name, value] of formData) {
name = normalizeLineBreaks(name)
value = normalizeLineBreaks(typeof value === 'string' ? value : value.name)
body += `${name}=${value}\r\n`
}
return new Blob([body], { type: 'text/plain' })
}
if (options?.encType !== 'application/x-www-form-urlencoded') return formData
let body = new URLSearchParams()
for (let [name, value] of formData) {
body.append(name, typeof value === 'string' ? value : value.name)
}
return body
}
function normalizeLineBreaks(value) {
return value.replace(/\r\n|\r|\n/g, '\r\n')
}The default resolver requests HTML. GET form values are already encoded in src;
application/x-www-form-urlencoded submissions use URLSearchParams, text/plain submissions use
CRLF-delimited text, and multipart/form-data submissions use FormData. Pass a custom
resolveFrame when the server requires additional headers, another body encoding, or a different
response policy.
Add data-rmx-document to a link or form to leave that navigation to the browser. To keep all links
and forms as document navigations while still hydrating client entries and using explicit frames,
register a listener before calling run():
window.navigation?.addEventListener('navigate', (e) => e.stopImmediatePropagation())This prevents Remix from intercepting Navigation API events. Explicit frame reloads such as
handle.frame.reload() continue to use the frame resolver.
The default resolver accepts 2xx responses and 3xx or 4xx responses whose Content-Type includes text/html, ignoring case. It rejects other 3xx or 4xx responses and all 5xx responses with an error containing their status and status text. A custom resolveFrame may return a Response with any status when it wants the component runtime to render the response body.
Forms remain ordinary HTML forms before the runtime starts. Add data-rmx-target to reload a named frame, or data-rmx-document to require a full-document submission:
import { Frame } from 'remix/component'
function AccountPage() {
return () => (
<>
<Frame name="account" src="/account/edit" />
<form action="/account/edit" method="post" data-rmx-target="account">
<label for="display-name">Display name</label>
<input id="display-name" name="displayName" required />
<button type="submit">Save</button>
</form>
</>
)
}Native constraint validation and submitter overrides still apply. GET form values arrive in src; non-GET forms provide formData, method, and encType to the resolver. See Frames for targeting, history behavior, request encoding, opt-outs, and server response guidance.
Use data-rmx-history="push|replace" on an enhanced anchor or form to control how the navigation updates history. This can override the automatic replacement used for non-GET form submissions to the current URL.
Single-page Applications
Use render and run from remix/spa when every route runs in the browser and returns a component
tree instead of an HTTP response body. The render middleware keeps the router's standard Request
to Response contract while associating the response with a node for the top frame to render:
import { createRouter } from 'remix/router'
import { render, run } from 'remix/spa'
let router = createRouter({ middleware: [render()] })
router.get('/', ({ render }) => render(<h1>Home</h1>))
router.get('/about', ({ render }) => render(<h1>About</h1>))
function LoadingPage() {
return () => <p role="status">Loading…</p>
}
let app = run(router, { fallback: <LoadingPage /> })
await app.ready()The optional fallback is a live Remix node displayed while the initial route loads.
app.ready() resolves after the initial URL has replaced it with the routed node. The runtime then
reuses frame navigation for same-origin links, forms, history traversal, redirects, cancellation,
and rmx-target.
Preserving Client-Owned DOM
Use data-rmx-preserve-dom on the smallest element whose live DOM should belong to client code after initial render, such as a custom element or third-party widget:
<pagefind-ui data-rmx-key="search" data-rmx-preserve-dom>
<button type="button">Search</button>
</pagefind-ui>The component runtime still renders the element's children during SSR and still hydrates any initial client entries inside it. On later frame reloads, matched data-rmx-preserve-dom elements keep their current attributes and children instead of accepting incoming DOM updates. See Preserving client-owned DOM for guidance and caveats.
Use data-rmx-preserve-attrs when client code owns only specific attributes, such as a theme set on <html>:
<html lang="en" data-rmx-preserve-attrs="data-theme"></html>On frame reloads, the space-separated attribute names in the incoming HTML keep their live values or absence. Other attributes and children reconcile normally. An empty or omitted list uses normal attribute reconciliation. This works on any matched element; it does not prevent removal or replacement. See Preserving client-owned attributes for examples and ownership rules.
Cascade Layers
The component runtime emits generated css(...) rules under the rmx cascade layer. Unlayered CSS outranks layered CSS, so use explicit layer order when mixing generated styles with global styles.
Put layers that should lose to generated styles before rmx:
@layer base, rmx;
@layer base {
button,
input,
textarea,
select {
font: inherit;
margin: 0;
padding: 0;
}
}License
See LICENSE