Skip to content

Instantly share code, notes, and snippets.

@tylermercer
Last active March 15, 2026 21:23
Show Gist options
  • Select an option

  • Save tylermercer/5ef7a140779de720b51de7f587645280 to your computer and use it in GitHub Desktop.

Select an option

Save tylermercer/5ef7a140779de720b51de7f587645280 to your computer and use it in GitHub Desktop.
A Claude-generated getting-started guide for the pre-release Remix 3 DOM packages

Getting Started with the Remix 3 Component Framework

⚠️ 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 from kentcdodds/remix-jam, rossipedia/remix-jam-mk2, the official remix-run/remix bookstore demo, and Kent C. Dodds' unofficial API gist. APIs will change before stable release.


The Mental Model

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 let variables 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.
  • this is back. Components receive a this: Remix.Handle context for triggering re-renders, accessing shared context, and scheduling tasks.
  • on replaces onClick/onFocus/etc. All event handling flows through a single on prop 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.


Packages

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

Installation

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/bookstore

Your First Component

Here 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 and Re-renders

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 />);

Events: the on Prop and @remix-run/events

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.

events() — the low-level primitive

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:

  • domHTMLElement events (click, focus, keydown, pointermove, etc.)
  • winWindow events (resize, scroll, etc.)
  • docDocument events (DOMContentLoaded, visibilitychange, etc.)
  • wsWebSocket events (message, error, etc.)
  • xhrXMLHttpRequest events (load, error, etc.)

bind() — raw string events

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);
  }),
]);

The press interaction

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; sets rmx-active="true" on the target
  • pressUp — fires on press end; removes rmx-active
  • longPress(handler, { delay: 500 }) — fires after holding for a duration
  • outerPress — fires when the user presses outside the element, useful for dismissing dropdowns or modals

Key interactions

@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').


Custom Event Types

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>
  );
}

Context: Sharing State Between Components

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.


Element References with connect and disconnect

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>
  );
}

this.queueTask() — Running Code After DOM Updates

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>
  );
}

Custom Interactions

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>

Typing Props with Remix.Props

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>;
}

The css Prop

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>

Full-Stack: SSR with renderToStream, hydrated, and Frame

This is the most powerful part of Remix 3 — components that are server-rendered to HTML but can be selectively hydrated on the client.

renderToStream — rendering on the server

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() — selective client-side hydration

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 and createFrame — async UI

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.


TypeScript Configuration

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"
  }
}

Putting It All Together

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);

Quick API Reference

@remix-run/dom

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

@remix-run/events

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

@remix-run/events/press

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

@remix-run/events/key

space, enter, escape, arrowUp, arrowDown, arrowLeft, arrowRight, home, end, pageUp, pageDown, tab, backspace, del, createKeyInteraction(key)

Full-stack (from @remix-run/fetch-router)

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

Resources

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment