⚠️ Pre-release warning: The client-side packages (@remix-run/dom,@remix-run/events) are sourced directly from the Remix Jam 2025 demo disc and are not yet formally published. The documentation below is based on real source code fromkentcdodds/remix-jam,rossipedia/remix-jam-mk2, the officialremix-run/remixbookstore demo, and Kent C. Dodds' unofficial API gist. APIs will change before stable release.
The Remix 3 component framework is not React. It's a new component system built entirely on JavaScript and DOM primitives. Before diving in, a few things to re-wire:
- No virtual DOM. The framework renders directly to the DOM and updates it surgically.
- No hooks. State lives in plain
letvariables in the function body — just JavaScript. - No
useEffect. Side effects and event listeners are set up in the function body, not in a lifecycle hook. thisis back. Components receive athis: Remix.Handlecontext for triggering re-renders, accessing shared context, and scheduling tasks.onreplacesonClick/onFocus/etc. All event handling flows through a singleonprop that accepts declarative event descriptors.
The component signature is:
function MyComponent(this: Remix.Handle) {
// Setup: runs ONCE when the component mounts
let count = 0;
// Return: a render function that runs on every re-render
return () => (
<button on={[press(() => { count++; this.render(); })]}>
Count: {count}
</button>
);
}The outer function is your constructor. The inner function (what you return) is your render. This distinction is the key to understanding everything else.
The component framework is split across two packages:
| Package | Purpose |
|---|---|
@remix-run/dom |
The component system — createRoot, Remix.Handle, element refs |
@remix-run/events |
Declarative event handling — events(), dom.*, win.*, interactions, key helpers |
For full-stack SSR apps, two additional APIs from @remix-run/fetch-router bridge client and server:
| API | Purpose |
|---|---|
renderToStream |
Renders Remix components to a streaming HTML response on the server |
hydrated() |
Marks a component for selective client-side hydration |
createFrame / Frame |
Client-side bootstrap and async UI primitive |
These packages are currently available via the demo disc repo or the bleeding-edge preview/main branch:
# From the preview branch (pnpm 9+ required)
pnpm install "remix-run/remix#preview/main&path:packages/@remix-run/dom"
pnpm install "remix-run/remix#preview/main&path:packages/@remix-run/events"
# Or clone and link from the demo repos to experiment:
# https://github.com/kentcdodds/remix-jam
# https://github.com/remix-run/remix/tree/main/demos/bookstoreHere is the minimal component setup, equivalent to a "Hello World":
// main.tsx
import { createRoot } from "@remix-run/dom";
function App() {
return () => <h1>Hello from Remix 3</h1>;
}
createRoot(document.body).render(<App />);createRoot takes any DOM element and returns a root you can call .render() on. This is only used for pure client-side apps. For full-stack SSR apps, you use createFrame instead (covered later).
State is just plain let variables. You call this.render() to schedule a re-render when something changes.
import { createRoot, type Remix } from "@remix-run/dom";
import { press } from "@remix-run/events/press";
function Counter(this: Remix.Handle) {
let count = 0; // plain variable — no useState
return () => (
<div>
<p>Count: {count}</p>
<button
on={[
press(() => {
count++;
this.render(); // tell Remix to re-render
}),
]}
>
Increment
</button>
</div>
);
}
createRoot(document.body).render(<Counter />);Rather than scattered onClick, onKeyDown, onMouseOver etc., Remix 3 uses a single on prop that takes an array of event descriptors. Event descriptors come from @remix-run/events.
When you need to add listeners to things other than JSX elements (document, window, custom EventTarget classes), use events():
import { events, dom, win } from "@remix-run/events";
// Returns a cleanup function
let cleanup = events(someElement, [
dom.click(event => { console.log("clicked", event.target); }),
dom.mouseover(event => { console.log("hovered"); }),
]);
// Call later to remove all listeners
cleanup();Target helpers give type-safe access to native event maps:
dom—HTMLElementevents (click,focus,keydown,pointermove, etc.)win—Windowevents (resize,scroll, etc.)doc—Documentevents (DOMContentLoaded,visibilitychange, etc.)ws—WebSocketevents (message,error, etc.)xhr—XMLHttpRequestevents (load,error, etc.)
For custom elements or arbitrary event names:
import { events, bind } from "@remix-run/events";
events(myCustomElement, [
bind("my-custom-event", event => {
console.log(event.detail);
}),
]);press is the idiomatic way to handle button-like activation. It handles both pointer and keyboard (Space/Enter) automatically, which is important for accessibility:
import { press } from "@remix-run/events/press";
<button on={[press(event => {
console.log("input type:", event.detail.inputType); // 'pointer' | 'keyboard'
})]}>
Click or press Space/Enter
</button>Other press variants:
pressDown— fires on press start; setsrmx-active="true"on the targetpressUp— fires on press end; removesrmx-activelongPress(handler, { delay: 500 })— fires after holding for a durationouterPress— fires when the user presses outside the element, useful for dismissing dropdowns or modals
@remix-run/events/key provides semantic key interactions that prevent browser defaults and follow WAI-ARIA conventions:
import { space, arrowUp, arrowDown, escape, enter } from "@remix-run/events/key";
// Use on document for global shortcuts
events(document, [
space(() => player.toggle()),
arrowUp(() => player.setBpm(player.bpm + 1)),
arrowDown(() => player.setBpm(player.bpm - 1)),
escape(() => closeModal()),
]);
// Or on a specific element via the `on` prop
<input on={[enter(() => handleSubmit())]} />Available: space, enter, escape, arrowUp, arrowDown, arrowLeft, arrowRight, home, end, pageUp, pageDown, tab, backspace, del. You can also create custom ones with createKeyInteraction('s').
When you have a class that extends EventTarget, createEventType gives you a type-safe pair: an event binder (for events() or the on prop) and an event creator (for dispatching).
import { createEventType } from "@remix-run/events";
// Returns [binderFn, creatorFn]
let [bpmChange, createBpmChange] = createEventType<number>("player:bpm-change");
let [play, createPlay] = createEventType("player:play");
let [stop, createStop] = createEventType("player:stop");
export class AudioPlayer extends EventTarget {
// Attach as static methods for ergonomic usage
static bpmChange = bpmChange;
static play = play;
static stop = stop;
#bpm = 120;
get bpm() { return this.#bpm; }
setBpm(value: number) {
this.#bpm = value;
this.dispatchEvent(createBpmChange({ detail: value }));
}
play() {
this.dispatchEvent(createPlay());
}
}
// Usage in a component
function PlayerControls(this: Remix.Handle) {
let player = this.context.get(PlayerRoot);
events(player, [
AudioPlayer.bpmChange(event => {
console.log("new bpm:", event.detail);
this.render();
}),
]);
return () => (
<div>
<p>BPM: {player.bpm}</p>
<button on={[press(() => player.setBpm(player.bpm + 1))]}>+</button>
<button on={[press(() => player.setBpm(player.bpm - 1))]}>−</button>
</div>
);
}Components form trees. Parent components can place values into context; child components can read them. This is the idiomatic way to share state — no prop drilling, no global stores required.
import { createRoot, type Remix } from "@remix-run/dom";
import { events } from "@remix-run/events";
class Store extends EventTarget {
static change = createEventType("store:change")[0];
items: string[] = [];
add(item: string) {
this.items.push(item);
this.dispatchEvent(new CustomEvent("store:change"));
}
}
// Parent: creates the store and puts it in context
function App(this: Remix.Handle<Store>) {
let store = new Store();
this.context.set(store); // <-- available to all descendants
return () => (
<div>
<AddForm />
<ItemList />
</div>
);
}
// Child: reads the store from context using the *parent component function* as the key
function ItemList(this: Remix.Handle) {
let store = this.context.get(App); // <-- key is the parent function
events(store, [
Store.change(() => this.render()),
]);
return () => (
<ul>
{store.items.map(item => <li>{item}</li>)}
</ul>
);
}
function AddForm(this: Remix.Handle) {
let store = this.context.get(App);
let input: HTMLInputElement;
return () => (
<form>
<input
on={[connect(el => (input = el))]}
placeholder="New item"
/>
<button
type="button"
on={[press(() => {
store.add(input.value);
input.value = "";
})]}
>
Add
</button>
</form>
);
}
createRoot(document.body).render(<App />);The generic on Remix.Handle<T> types the value you're placing into context. this.context.get(ParentFn) retrieves it.
To get a reference to an actual DOM node, use the connect and disconnect descriptors from @remix-run/dom. These fire when the element enters and leaves the DOM, respectively.
import { connect, disconnect, type Remix } from "@remix-run/dom";
function SearchBox(this: Remix.Handle) {
let inputEl: HTMLInputElement;
return () => (
<div>
<input
on={[
connect(event => {
inputEl = event.currentTarget;
// Auto-focus on mount
inputEl.focus();
}),
disconnect(() => {
// Cleanup if needed
}),
]}
placeholder="Search..."
/>
<button
on={[
press(() => {
// inputEl is available here because connect fired first
console.log("searching for:", inputEl.value);
}),
]}
>
Search
</button>
</div>
);
}Sometimes you need to act on the DOM after a render (e.g., move focus to a newly-rendered element). this.queueTask() schedules a callback to run after the next DOM update:
function TodoList(this: Remix.Handle) {
let listEl: HTMLUListElement;
let items = ["Buy milk"];
return () => (
<div>
<ul on={[connect(el => (listEl = el))]}>
{items.map(item => <li>{item}</li>)}
</ul>
<button
on={[
press(() => {
items.push("New item");
this.render();
this.queueTask(() => {
// DOM has updated — safely scroll to bottom
listEl.lastElementChild?.scrollIntoView();
});
}),
]}
>
Add Item
</button>
</div>
);
}When you have a complex event pattern you want to reuse, package it as a custom interaction with createInteraction. The factory receives the target element and a dispatch function; it returns the cleanup:
import { createInteraction, events } from "@remix-run/events";
import { press } from "@remix-run/events/press";
// A "tap tempo" interaction that calculates BPM from repeated taps
let tapTempo = createInteraction<HTMLElement, number>(
"tap-tempo",
({ target, dispatch }) => {
let taps: number[] = [];
const MAX_INTERVAL = 2000;
let resetTimer: number;
let handleTap = () => {
let now = Date.now();
clearTimeout(resetTimer);
taps.push(now);
taps = taps.filter(t => now - t < MAX_INTERVAL);
if (taps.length >= 4) {
let intervals = taps.slice(1).map((t, i) => t - taps[i]);
let avgBpm = Math.round(60000 / (intervals.reduce((a, b) => a + b) / intervals.length));
dispatch({ detail: avgBpm });
}
resetTimer = window.setTimeout(() => { taps = []; }, MAX_INTERVAL);
};
return events(target, [press(handleTap)]);
}
);
// Usage: tap-tempo fires a custom event with the calculated BPM as `event.detail`
<button on={[tapTempo(event => player.setBpm(event.detail))]}>
Tap Tempo
</button>When building reusable components, Remix.Props<K> gives you the full set of HTML attributes plus the on prop for any element tag:
import type { Remix } from "@remix-run/dom";
// A typed wrapper around <button>
export function Button({ children, ...rest }: Remix.Props<"button">) {
return <button {...rest}>{children}</button>;
}
// With custom additional props
interface IconButtonProps extends Remix.Props<"button"> {
icon: string;
label: string;
}
export function IconButton({ icon, label, ...rest }: IconButtonProps) {
return (
<button aria-label={label} {...rest}>
{icon}
</button>
);
}For component children, use Remix.RemixNode:
export function Card({ children }: { children: Remix.RemixNode }) {
return <div css={{ border: "1px solid #ccc", padding: "16px" }}>{children}</div>;
}Remix 3 has a built-in css prop for dynamic, scoped styles. It accepts a CSSProperties-like object and supports basic nesting for pseudo-selectors. Unlike inline styles, these are generated as real CSS classes:
<div
css={{
display: "flex",
flexDirection: "column",
gap: "8px",
background: "#1a1a1a",
borderRadius: "12px",
padding: "24px",
"&:hover": {
background: "#2a2a2a",
},
}}
>
Content
</div>This is the most powerful part of Remix 3 — components that are server-rendered to HTML but can be selectively hydrated on the client.
In a route handler (using @remix-run/fetch-router), replace new Response(html...) with renderToStream to render a component tree to a streaming HTML response:
// server/books.tsx
import { renderToStream } from "@remix-run/fetch-router";
export default {
handlers: {
GET({ params }) {
let book = getBookBySlug(params.slug);
if (!book) {
return renderToStream(<Layout>Not found</Layout>, { status: 404 });
}
return renderToStream(
<Layout>
<BookDetail book={book} />
</Layout>
);
},
},
};The server-rendered response is pure HTML — no client-side JS is sent unless you explicitly opt into it.
hydrated() wraps a component and marks it for hydration on the client. Think of it as "use client" for Remix 3:
// components/add-to-cart.tsx
import { hydrated } from "@remix-run/fetch-router";
import { press } from "@remix-run/events/press";
// The inner component runs on the client
function AddToCartButton(this: Remix.Handle, { bookId }: { bookId: string }) {
let loading = false;
return () => (
<button
disabled={loading}
on={[
press(async () => {
loading = true;
this.render();
await addToCart(bookId);
loading = false;
this.render();
}),
]}
>
{loading ? "Adding…" : "Add to Cart"}
</button>
);
}
// Export the hydrated version — this is what you use in server-rendered components
export let HydratedAddToCart = hydrated(AddToCartButton);On the server, HydratedAddToCart renders as static HTML. On the client, Remix boots the component and attaches the event handlers in-place.
Frame is Remix 3's primitive for sections of UI that reload independently from the server. It's conceptually similar to <iframe> but built on top of the router — when a Frame reloads, it fetches the route's HTML response and intelligently morphs it into the existing DOM (using an algorithm inspired by HTMX's idiomorph).
createFrame bootstraps the entire Frame system in your client entry point:
// client/entry.ts
import { createFrame } from "@remix-run/fetch-router";
// Boot the client-side frame system
createFrame(document);Then in your server-rendered components, use <Frame> to mark regions that can reload:
// components/layout.tsx
import { Frame } from "@remix-run/fetch-router";
function Layout({ children }: { children: Remix.RemixNode }) {
return (
<html>
<head>
<title>My App</title>
<script type="module" src="/client/entry.js" />
</head>
<body>
<nav>...</nav>
{/* The main content area is a Frame — it reloads on navigation */}
<Frame>{children}</Frame>
</body>
</html>
);
}To programmatically reload a Frame from a hydrated component (e.g., after a form submission), call this.frame.reload():
function DeleteButton(this: Remix.Handle, { bookId }: { bookId: string }) {
return () => (
<button
on={[
press(async () => {
await fetch(`/books/${bookId}`, { method: "DELETE" });
// Reload the nearest parent Frame to reflect the deletion
this.frame.reload();
}),
]}
>
Delete
</button>
);
}
export let HydratedDeleteButton = hydrated(DeleteButton);When this.frame.reload() fires, the server re-renders the route to HTML, and the hybrid reconciler morphs the new HTML into the existing DOM — only changing what actually changed, with no full-page reload and no flicker.
Remix 3 components use JSX, but not the React JSX transform. You need to point TypeScript at the Remix JSX runtime:
// tsconfig.json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@remix-run/dom",
"strict": true,
"moduleResolution": "bundler",
"target": "ES2022"
}
}Here's a minimal but complete full-stack example — an SSR page with a client-hydrated counter widget:
// routes.ts
import { route } from "@remix-run/fetch-router";
export let routes = route({ home: "/" });// client/counter.tsx — a hydrated component
import { hydrated, type Remix } from "@remix-run/fetch-router";
import { press } from "@remix-run/events/press";
function Counter(this: Remix.Handle) {
let count = 0;
return () => (
<div>
<p>Count: {count}</p>
<button on={[press(() => { count++; this.render(); })]}>+1</button>
</div>
);
}
export let HydratedCounter = hydrated(Counter);// server/home.tsx — the route handler
import { renderToStream, Frame } from "@remix-run/fetch-router";
import { HydratedCounter } from "../client/counter.tsx";
export default {
handlers: {
GET() {
return renderToStream(
<html>
<head>
<title>Home</title>
<script type="module" src="/client/entry.js" />
</head>
<body>
<Frame>
<h1>Welcome</h1>
{/* Renders as static HTML on server, hydrates on client */}
<HydratedCounter />
</Frame>
</body>
</html>
);
},
},
};// client/entry.ts — boot the client
import { createFrame } from "@remix-run/fetch-router";
createFrame(document);| Export | Description |
|---|---|
createRoot(el) |
Create a root for a pure client-side app |
createRangeRoot(range) |
Create a root from a DOM Range |
connect(cb) |
Element ref — fires when element enters DOM |
disconnect(cb) |
Fires when element leaves DOM |
Remix.Handle |
The type for this in a component |
Remix.Handle<T> |
Typed context handle |
Remix.Props<K> |
Props type for HTML element K |
Remix.RemixNode |
Type for JSX children |
| Export | Description |
|---|---|
events(target, descriptors) |
Attach event listeners, returns cleanup |
bind(type, handler) |
Raw string event descriptor |
dom.* |
Type-safe HTMLElement event helpers |
win.* |
Window event helpers |
doc.* |
Document event helpers |
ws.* |
WebSocket event helpers |
xhr.* |
XMLHttpRequest event helpers |
createEventType(name) |
Create a type-safe custom event pair |
createInteraction(name, factory) |
Create a reusable interaction |
| Export | Description |
|---|---|
press(handler) |
Pointer + keyboard activation |
pressDown(handler) |
Press start (sets rmx-active) |
pressUp(handler) |
Press end |
longPress(handler, opts) |
Hold to activate |
outerPress(handler) |
Press outside element |
space, enter, escape, arrowUp, arrowDown, arrowLeft, arrowRight, home, end, pageUp, pageDown, tab, backspace, del, createKeyInteraction(key)
| Export | Description |
|---|---|
renderToStream(jsx, opts?) |
Server-render a component to a streaming Response |
hydrated(Component) |
Mark a component for client-side hydration |
createFrame(document) |
Boot the client-side Frame system |
Frame |
Independently-reloadable UI region |
this.frame.reload() |
Reload the nearest parent Frame from a hydrated component |
- Remix Jam 2025 — Part 1 (Components): https://www.youtube.com/watch?v=iZl0IKj0HHc
- Remix Jam 2025 — Part 2 (Server + Frame): https://www.youtube.com/watch?v=dZbZgxWlzr8
- Bookstore demo (official): https://github.com/remix-run/remix/tree/main/demos/bookstore
- Demo disc (Kent's bootleg): https://github.com/kentcdodds/remix-jam
- Drum machine Mk2 (community): https://github.com/rossipedia/remix-jam-mk2
- Remix 3 resources (Mark Dalgleish): https://github.com/markdalgleish/remix3-resources
- epicflare (real-world Cloudflare Workers starter): https://github.com/epicweb-dev/epicflare