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.
- JSX Runtime - Convenient JSX syntax
- Component State - State managed with plain JavaScript variables
- Manual Updates - Explicit control over when components update via
handle.update() - Real DOM Events - Events are real DOM events using the
on()mixin andaddEventListeners() - Inline CSS -
css(...)mixin with pseudo-selectors and nested rules - Server Rendering - Stream full pages or fragments with
renderToStream - Hydration - Mark interactive components with
clientEntryand hydrate them on the client withrun - Frames -
<Frame>streams partial server UI into the page and can be reloaded without a full page navigation
npm i remixRender a full page to a streaming response:
import { renderToStream } from 'remix/component/server'
import { Frame } from 'remix/component'
import { Counter } from './assets/counter.tsx'
function App() {
return () => (
<html>
<head>
<title>My App</title>
<script async type="module" src="/assets/entry.js" />
</head>
<body>
<h1>Hello</h1>
<Counter setup={0} label="Clicks" />
<Frame src="/sidebar" fallback={<div>Loading...</div>} />
</body>
</html>
)
}
let stream = renderToStream(<App />, {
resolveFrame(src, target, context) {
let headers = new Headers({ accept: 'text/html' })
if (target) headers.set('x-remix-target', target)
return fetch(new URL(src, context?.currentFrameSrc ?? request.url), { headers }).then((res) =>
res.text(),
)
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/html' },
})Mark components that need client-side interactivity with clientEntry. They render on the server and hydrate on the client:
import { clientEntry, on, type Handle } from 'remix/component'
export let Counter = clientEntry(
'/assets/counter.js#Counter',
function Counter(handle: Handle, setup: number) {
let count = setup
return (props: { label: string }) => (
<div>
<span>
{props.label}: {count}
</span>
<button
mix={[
on('click', () => {
count++
handle.update()
}),
]}
>
+
</button>
</div>
)
},
)The first argument is the module URL and export name the client will use to load this component. The component renders on the server like any other component, and the client hydrates it in place, preserving the server-rendered HTML.
Boot the client with run. It finds all client entries in the page, loads their modules, and hydrates them:
import { run } from 'remix/component'
let app = run({
async loadModule(moduleUrl, exportName) {
let mod = await import(moduleUrl)
return mod[exportName]
},
async resolveFrame(src, signal, target) {
let headers = new Headers({ accept: 'text/html' })
if (target) headers.set('x-remix-target', target)
let res = await fetch(src, { headers, signal })
return res.body ?? (await res.text())
},
})
await app.ready()<Frame> renders server content into the page. Frames can stream in after the initial HTML, nest other frames, and contain client entries. They can be reloaded from the client without a full page navigation:
<Frame src="/sidebar" fallback={<div>Loading sidebar...</div>} />Client entries inside a frame can trigger a reload:
function RefreshButton(handle: Handle) {
return () => (
<button
mix={[
on('click', () => {
handle.frame.reload()
}),
]}
>
Refresh
</button>
)
}When a frame reloads, its server HTML is re-fetched and diffed into the page. Client entries inside the frame receive updated props from the server while preserving their local state.
You can also name frames and reload adjacent ones:
<Frame name="cart-summary" src="/cart-summary" />
<Frame src="/cart-row" />function CartRow(handle: Handle) {
return () => (
<button
mix={[
on('click', async () => {
await handle.frames.get('cart-summary')?.reload()
await handle.frame.reload()
}),
]}
>
Save
</button>
)
}When a frame reloads, its server HTML is re-fetched and diffed into the page. Client entries inside the frame receive updated props from the server while preserving their local state.
All components return a render function. The setup function runs once when the component is first created, and the returned render function runs on the first render and every update afterward:
function Counter(handle: Handle, setup: number) {
// Setup phase: runs once
let count = setup
// Return render function: runs on every update
return (props: { label?: string }) => (
<div>
{props.label || 'Count'}: {count}
<button
mix={[
on('click', () => {
count++
handle.update()
}),
]}
>
Increment
</button>
</div>
)
}When a component returns a function, it has two phases:
- Setup phase - The component function receives the
setupprop and runs once. Use this for initialization. - Render phase - The returned function receives props and runs on initial render and every update afterward. Use this for rendering.
The setup prop is separate from regular props. Only the setup prop is passed to the setup function, and only props are passed to the render function.
setupprop for values that initialize state (e.g.,initial,defaultValue)- Regular props for values that change over time (e.g.,
label,disabled)
// Usage: setup prop goes to setup function, regular props go to render function
let el = <Counter setup={5} label="Total" />
function Counter(
handle: Handle,
setup: number, // receives 5 (the setup prop value)
) {
let count = setup // use setup for initialization
return (props: { label?: string }) => {
// props only receives { label: "Total" } - not the setup prop
return (
<div>
{props.label}: {count}
</div>
)
}
}Events use the on() mixin. Listeners receive an AbortSignal that's aborted when the component is disconnected or the handler is re-entered.
function SearchInput(handle: Handle) {
let query = ''
return () => (
<input
type="text"
value={query}
mix={[
on('input', (event, signal) => {
query = event.currentTarget.value
handle.update()
// Pass the signal to abort the fetch on re-entry or node removal
// This avoids race conditions in the UI and manages cleanup
fetch(`/search?q=${query}`, { signal })
.then((res) => res.json())
.then((results) => {
if (signal.aborted) return
// Update results
})
}),
]}
/>
)
}You can also listen to global event targets like document or window using addEventListeners() with automatic cleanup on component removal:
function KeyboardTracker(handle: Handle) {
let keys: string[] = []
addEventListeners(document, handle.signal, {
keydown: (event) => {
keys.push(event.key)
handle.update()
},
})
return () => <div>Keys: {keys.join(', ')}</div>
}Use the css(...) mixin for inline styles with pseudo-selectors and nested rules:
function Button(handle: Handle) {
return () => (
<button
mix={[
css({
color: 'white',
backgroundColor: 'blue',
'&:hover': {
backgroundColor: 'darkblue',
},
'&:active': {
transform: 'scale(0.98)',
},
}),
]}
>
Click me
</button>
)
}The syntax mirrors modern CSS nesting, but in object form. Use & to reference the current element in pseudo-selectors, pseudo-elements, and attribute selectors. Use class names or other selectors directly for child selectors:
.button {
color: white;
background-color: blue;
&:hover {
background-color: darkblue;
}
&::before {
content: '';
position: absolute;
}
&[aria-selected='true'] {
border: 2px solid yellow;
}
.icon {
width: 16px;
height: 16px;
}
@media (max-width: 768px) {
padding: 8px;
}
}function Button(handle: Handle) {
return () => (
<button
mix={[
css({
color: 'white',
backgroundColor: 'blue',
'&:hover': {
backgroundColor: 'darkblue',
},
'&::before': {
content: '""',
position: 'absolute',
},
'&[aria-selected="true"]': {
border: '2px solid yellow',
},
'.icon': {
width: '16px',
height: '16px',
},
'@media (max-width: 768px)': {
padding: '8px',
},
}),
]}
>
<span className="icon">★</span>
Click me
</button>
)
}Use the ref(...) mixin to get a reference to the DOM node after it's rendered. This is useful for DOM operations like focusing elements, scrolling, or measuring dimensions.
function Form(handle: Handle) {
let inputRef: HTMLInputElement
return () => (
<form>
<input
type="text"
// get the input node
mix={[ref((node) => (inputRef = node))]}
/>
<button
mix={[
on('click', () => {
// Select it from other parts of the form
inputRef.select()
}),
]}
>
Focus Input
</button>
</form>
)
}The ref callback receives an AbortSignal as its second parameter, which is aborted when the element is removed from the DOM:
function Component(handle: Handle) {
return () => (
<div
mix={[
ref((node, signal) => {
// Set up something that needs cleanup
let observer = new ResizeObserver(() => {
// handle resize
})
observer.observe(node)
// Clean up when element is removed
signal.addEventListener('abort', () => {
observer.disconnect()
})
}),
]}
>
Content
</div>
)
}Components receive a Handle as their first argument with the following API:
handle.update()- Schedule an update and await completion to get anAbortSignal.handle.queueTask(task)- Schedule a task to run after the next update. Useful for DOM operations that need to happen after rendering (e.g., moving focus, scrolling, measuring elements, etc.).addEventListeners(target, handle.signal, listeners)- Listen to an event target with automatic cleanup when the component disconnects.handle.signal- AnAbortSignalthat's aborted when the component is disconnected. Useful for cleanup.handle.id- Stable identifier per component instance.handle.context- Context API for ancestor/descendant communication.handle.frame- The component's closest frame. Callhandle.frame.reload()to refresh the frame's server content.handle.frames.get(name)- Look up named frames in the current runtime tree for adjacent frame reloads.
Schedule an update and optionally await completion to coordinate post-update work.
function Counter(handle: Handle) {
let count = 0
return () => (
<button
mix={[
on('click', () => {
count++
handle.update()
}),
]}
>
Count: {count}
</button>
)
}You can await the update before doing DOM work:
function Player(handle: Handle) {
let isPlaying = false
let playButton: HTMLButtonElement
let stopButton: HTMLButtonElement
return () => (
<div>
<button
disabled={isPlaying}
mix={[
ref((node) => (playButton = node)),
on('click', async () => {
isPlaying = true
await handle.update()
// Focus the enabled button after update completes
stopButton.focus()
}),
]}
>
Play
</button>
<button
disabled={!isPlaying}
mix={[
ref((node) => (stopButton = node)),
on('click', async () => {
isPlaying = false
await handle.update()
// Focus the enabled button after update completes
playButton.focus()
}),
]}
>
Stop
</button>
</div>
)
}Schedule a task to run after the next update. Useful for DOM operations that need to happen after rendering (e.g., moving focus, scrolling, measuring elements).
function Form(handle: Handle) {
let showDetails = false
let detailsSection: HTMLElement
return () => (
<form>
<label>
<input
type="checkbox"
checked={showDetails}
mix={[
on('change', (event) => {
showDetails = event.currentTarget.checked
handle.update()
if (showDetails) {
// Scroll to the expanded section after it renders
handle.queueTask(() => {
detailsSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}
}),
]}
/>
Show additional details
</label>
{showDetails && (
<section
mix={[
css({
marginTop: '2rem',
padding: '1rem',
border: '1px solid #ccc',
}),
ref((node) => (detailsSection = node)),
]}
>
<h2>Additional Details</h2>
<p>This section appears when the checkbox is checked.</p>
</section>
)}
</form>
)
}Listen to an EventTarget with automatic cleanup when the component disconnects. Ideal for listening to events on global event targets like document and window.
function KeyboardTracker(handle: Handle) {
let keys: string[] = []
addEventListeners(document, handle.signal, {
keydown: (event) => {
keys.push(event.key)
handle.update()
},
})
return () => <div>Keys: {keys.join(', ')}</div>
}The listeners are automatically removed when the component is disconnected, so you don't need to manually clean up.
An AbortSignal that's aborted when the component is disconnected. Useful for cleanup operations.
function Clock(handle: Handle) {
let interval = setInterval(() => {
// clear the interval when the component is disconnected
if (handle.signal.aborted) {
clearInterval(interval)
return
}
handle.update()
}, 1000)
return () => <span>{new Date().toString()}</span>
}Stable identifier per component instance. Useful for HTML APIs like htmlFor, aria-owns, etc. so consumers don't have to supply an id.
function LabeledInput(handle: Handle) {
return () => (
<div>
<label htmlFor={handle.id}>Name</label>
<input id={handle.id} type="text" />
</div>
)
}Context API for ancestor/descendant communication. All components are potential context providers and consumers. Use handle.context.set() to provide values and handle.context.get() to consume them.
function App(handle: Handle<{ theme: string }>) {
handle.context.set({ theme: 'dark' })
return () => (
<div>
<Header />
<Content />
</div>
)
}
function Header(handle: Handle) {
// Consume context from App
let { theme } = handle.context.get(App)
return () => (
<header mix={[css({ backgroundColor: theme === 'dark' ? '#000' : '#fff' })]}>Header</header>
)
}Setting context values does not automatically trigger updates. If a provider needs to render its own context values, call handle.update() after setting them. However, since providers often don't render context values themselves, calling update() can cause expensive updates of the entire subtree. Instead, make your context an EventTarget and have consumers subscribe to changes.
import { TypedEventTarget } from 'remix/component'
class Theme extends TypedEventTarget<{ change: Event }> {
#value: 'light' | 'dark' = 'light'
get value() {
return this.#value
}
setValue(value: string) {
this.#value = value
this.dispatchEvent(new Event('change'))
}
}
function App(handle: Handle<Theme>) {
let theme = new Theme()
handle.context.set(theme)
return () => (
<div>
<button
mix={[
on('click', () => {
// no updates in the parent component
theme.setValue(theme.value === 'light' ? 'dark' : 'light')
}),
]}
>
Toggle Theme
</button>
<ThemedContent />
</div>
)
}
function ThemedContent(handle: Handle) {
let theme = handle.context.get(App)
// Subscribe to theme changes and update when it changes
addEventListeners(theme, handle.signal, { change: () => handle.update() })
return () => (
<div mix={[css({ backgroundColor: theme.value === 'dark' ? '#000' : '#fff' })]}>
Current theme: {theme.value}
</div>
)
}Use Fragment to group elements without adding extra DOM nodes:
function List(handle: Handle) {
return () => (
<>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</>
)
}- Getting Started
- Components
- Handle API
- Server Rendering
- Hydration
- Frames
- Styling
- Events
- Interactions
- Context
- Composition
- Patterns
- Testing
- Animations
- Server Rendering
See LICENSE