Skip to content

Chapter 5

Interactivity

Remix renders pages on the server and hydrates only the components that need to run in the browser. This chapter covers marking those components with clientEntry(...), starting the browser runtime with run(), updating local UI state, handling events with on(...) and other mixins, cancelling stale async work, and enhancing links and forms with client navigation. For application state and business rules, components call ordinary TypeScript models and keep their own state focused on the UI.

Progressive enhancement

Begin with links and forms that already describe the request the server should receive. This album form has a real action and method:

app/actions/albums/edit/public/album-edit-form.tsx
import type { Handle } from "remix/component";

import { routes } from "../../../../routes.ts";

interface Album {
  artist: string;
  id: string;
  title: string;
  year: number;
}

export function AlbumEditForm(handle: Handle<{ album: Album }>) {
  return () => {
    let { album } = handle.props;

    return (
      <form action={routes.albums.edit.action.href({ albumId: album.id })} method="post">
        <label>
          Title
          <input defaultValue={album.title} name="title" required />
        </label>
        <label>
          Artist
          <input defaultValue={album.artist} name="artist" required />
        </label>
        <label>
          Year
          <input defaultValue={album.year} name="year" required type="number" />
        </label>
        <button type="submit">Save album</button>
      </form>
    );
  };
}

With no JavaScript, the browser posts FormData to the route. The action validates it, updates the album, and returns a redirect. With JavaScript, an event handler can intercept that same submission to add pending UI or reload part of the page. The controller still owns the mutation and every error response.

Use the same rule for navigation. Render an anchor with a useful href first, then let the browser runtime enhance same-origin navigation when it is available.

Some interactions only make sense with JavaScript. The server can still render the useful content, navigation, and forms around them. Give each hydrated component the serializable data and route URLs it needs rather than moving the whole page into browser code.

Hydration boundaries with clientEntry

clientEntry(...) marks the smallest component that needs to run in the browser. An album edit page can remain server-only while its form hydrates:

app/actions/albums/edit/public/album-edit-form.tsx
import { clientEntry } from "remix/component";
import type { Handle } from "remix/component";

import { routes } from "../../../../routes.ts";

interface Album {
  artist: string;
  id: string;
  title: string;
  year: number;
}

export const AlbumEditForm = clientEntry(
  import.meta.url,
  function AlbumEditForm(handle: Handle<{ album: Album }>) {
    return () => {
      let { album } = handle.props;

      return (
        <form action={routes.albums.edit.action.href({ albumId: album.id })} method="post">
          {/* fields */}
        </form>
      );
    };
  },
);

On the server, this still renders like an ordinary component. The server wraps its output in a pair of HTML comments, <!-- rmx:h:... --> and <!-- /rmx:h -->, and serializes its props into the page. The comments mark a range of the document rather than adding a wrapper element, so the hydrated output can be any markup. In the browser, run() finds each range, loads the component's module with a normal import(), and hydrates the existing DOM in place with the serialized props.

Each client entry hydrates on its own. A client entry rendered inside another one can read its ancestor's context, but sibling client entries don't share component context. Pass each sibling the data it needs as props, or connect them through an ordinary TypeScript module, as shown in keeping application logic outside components.

Client-entry props cross from the server to the browser, so they must be serializable. They may contain strings, numbers, booleans, null, undefined, plain objects and arrays, and JSX elements. Do not pass functions, class instances, database records with custom prototypes, or other opaque runtime values.

Mark a boundary around interactive UI, not an entire page by habit. Static ancestors and siblings do not need to hydrate just because a button below them does. Keep the database, authenticated user, and request context on the server. Pass the client entry plain initial data, route URLs, and other browser-safe values it needs to handle its part of the page.

Booting the browser runtime with run

Client entries hydrate once a browser entry module calls run(). Load that module, such as app/actions/public/entry.ts, from the document shell and call run() once:

app/actions/public/entry.ts
import {
  detectMultipleImportMapSupport,
  importModule,
  preloadShim,
} from "remix/multiple-import-maps-polyfill";
import { run } from "remix/component";

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

app.addEventListener("error", (event) => {
  console.error("Component error:", event.error);
});

await app.ready();

loadModule imports the named component for every client entry discovered in the document. An import map tells the browser which URL each bare module specifier resolves to, and Remix adds more of them as client entries and frames load. Browsers that support multiple import maps on one page use native imports and preloads. Browsers without that support use the remix/multiple-import-maps-polyfill shim when later frame responses introduce new mappings. If you only target browsers with native support, loadModule can call import(moduleUrl) directly and you can drop processClientEntryPreloads.

The returned app runtime has three lifecycle methods:

  • app.ready() resolves after the initial client entries hydrate.
  • app.flush() synchronously applies pending component updates. This is mainly useful in tests and integrations that must observe the DOM immediately.
  • app.dispose() removes the runtime's components and listeners.

run() hydrates the server-rendered page. It is not a second application router. Browser requests still go to the route actions that own the corresponding server behavior.

run() also provides a default browser resolver for frames, the route-owned regions that load or reload independently.

Mount client-only UI with createRoot

Use createRoot(container) when there is no server-rendered component to hydrate, such as an imperative widget mounted into a container created by another application.

app/actions/public/support-widget.tsx
import { createRoot } from "remix/component";
import type { Handle } from "remix/component";

function SupportWidget(_handle: Handle) {
  return () => <a href="/support">Contact support</a>;
}

let container = document.getElementById("support-widget");

if (container) {
  let root = createRoot(container);
  root.render(<SupportWidget />);

  window.addEventListener("pagehide", () => root.dispose(), { once: true });
}

The root owns that container. root.render(node) schedules a tree, root.flush() applies pending work synchronously, and root.dispose() removes the tree and runs cleanup. Normal page UI should start on the server and use clientEntry(...). Otherwise users wait for JavaScript before they see content the server could have rendered.

State, updates, and post-render tasks

Component-local UI state lives in setup scope, the body of the component function that runs once before it returns its render function. Change it in an event handler, then call handle.update():

import { on } from "remix/component";
import type { Handle } from "remix/component";

function Counter(handle: Handle<{ initialCount: number }>) {
  let count = handle.props.initialCount;

  return () => (
    <button
      mix={on("click", () => {
        count++;
        handle.update();
      })}
      type="button"
    >
      Count: {count}
    </button>
  );
}

count lives in setup scope, so it survives each render without becoming part of the component's props.

Remix doesn't own your state. It's a plain variable, and you change it directly. handle.update() is the only signal that the component should render again. If you forget it, the component visibly doesn't change, which is usually easy to spot. Because a component only needs state and a way to request a render, you can build other patterns on top, such as a store or signals that call handle.update() when they change.

Call handle.update() from an event handler, queued task, subscription, timer, or other work that runs after the component commits, meaning its rendered output has been applied to the DOM. Calling it during setup warns and skips the extra render because the initial render follows setup. Calling it during rendering, or before the initial commit from outside setup, throws an error. If rendering discovers work that should run after the commit, schedule that work with handle.queueTask().

handle.update() schedules work and returns a promise. Await it when the next step needs the updated DOM:

import { on, ref } from "remix/component";
import type { Handle } from "remix/component";

function RenameButton(handle: Handle) {
  let editing = false;
  let input: HTMLInputElement;

  return () => (
    <div>
      <button
        mix={on("click", async () => {
          editing = true;
          await handle.update();
          input.focus();
        })}
        type="button"
      >
        Rename
      </button>
      {editing && <input aria-label="Album title" mix={ref((node) => (input = node))} />}
    </div>
  );
}

The promise resolves to an AbortSignal, an object that reports when work should stop and that APIs such as fetch() accept for cancellation. Capture it only if work continues across another async boundary after the update. The runtime cannot abort the signal between await handle.update() and the next synchronous statement, so input.focus() does not need a signal check. When more async work follows, pass the signal to that work when possible and check it after the later await before continuing:

// Inside an async event handler:
let signal = await handle.update();
let response = await fetch(href, { signal });
if (signal.aborted) return;

status = response.ok ? "saved" : "error";
handle.update();

handle.queueTask(task) runs a task during the commit after the next update. Use it when DOM measurement or another update must happen as part of that flush:

// Inside an event handler after `detailsSection` has been assigned:
showDetails = true;
handle.update();
handle.queueTask(() => {
  detailsSection.scrollIntoView({ block: "nearest" });
});

Do not create extra state only to make a queued task notice it later. Start work in the event handler that owns it, or key a queued task directly to the prop value it observes.

Keys matter whenever an update changes a list. Use a stable ID, not an array index that changes when the list is sorted or a random value created during render. Matching keys let Remix move existing DOM and component instances instead of pairing state with the wrong item.

Setup-scope variables are a good home for UI state such as whether a menu is open, which field has focus, or whether a request is pending. Put business rules and shared application state in ordinary TypeScript outside the component, then call that code from event handlers and update the component when the model changes.

Events with on

on(type, handler, capture?) attaches a typed DOM event to a host element. The event's currentTarget is inferred from that host:

import { on } from "remix/component";
import type { Handle } from "remix/component";

function AlbumForm(_handle: Handle) {
  return () => (
    <form
      mix={on("submit", (event) => {
        event.preventDefault();
        let formData = new FormData(event.currentTarget);
        console.log(formData.get("title"));
      })}
    >
      {/* fields */}
    </form>
  );
}

Prefer native elements and their native events. A button's click event already covers pointer, touch, Enter, and Space activation. A form's submit event covers its submit button and Enter from a field. Use lower-level pointer or keyboard events when the interaction itself requires them, not to rebuild behavior the platform already supplies.

If an event starts temporary listeners on window or document, give those listeners their own cleanup signal and stop them when the interaction finishes or the component disconnects.

Only call preventDefault() when the enhanced path will finish the browser's job. If an event handler merely adds pending state before an ordinary form submission, allow the submission to continue.

Controlled and uncontrolled inputs

An uncontrolled input keeps its current value in the DOM, and defaultValue gives it an initial value. A controlled input gets its current value from component state instead. Its input handler copies the DOM value into that state and updates the component:

app/ui/public/title-inputs.tsx
import { on } from "remix/component";
import type { Handle } from "remix/component";

const initialTitle = "Thriller";

function TitleInputs(handle: Handle) {
  let title = initialTitle;

  return () => (
    <div>
      <p>
        <label>
          Uncontrolled <input defaultValue={initialTitle} />
        </label>
      </p>

      <p>
        <label>
          Controlled{" "}
          <input
            value={title}
            mix={on("input", (event) => {
              title = event.currentTarget.value;
              handle.update();
            })}
          />
        </label>
      </p>

      <button
        mix={on("click", () => {
          title = initialTitle;
          handle.update();
        })}
        type="button"
      >
        Reset controlled input
      </button>
    </div>
  );
}

Typing changes both inputs. Clicking the button resets the controlled input because its value comes from title. The uncontrolled input keeps its DOM value because defaultValue only applies its initial value.

Composing behavior with mix

The mix prop attaches reusable behavior and styles to host elements. Pass one mixin directly, or an array when the element needs several:

import { attrs, css, on, ref } from "remix/component";
import type { Handle } from "remix/component";

const saveButtonStyle = css({
  minWidth: "8rem",
  minHeight: "2.5rem",
  border: 0,
  borderRadius: "999px",
  background: "#111",
  color: "#fff",
});

function SaveButton(_handle: Handle) {
  return () => (
    <button
      mix={[
        saveButtonStyle,
        attrs({ type: "button" }),
        ref((node) => console.log("Mounted", node)),
        on("click", () => console.log("Save album")),
      ]}
    >
      Save
    </button>
  );
}

The core mixins serve different host-level jobs:

  • on(...) attaches a typed event handler.
  • ref(...) runs setup when the element is inserted and supplies a cleanup signal.
  • attrs(...) provides default attributes without replacing explicit element props.
  • link(...) adds client navigation behavior and link semantics.
  • css(...) applies static generated CSS.

Components and mixins compose along different axes. Components compose the tree, so a component renders other components and host elements inside it. Mixins compose behavior and styles on one host element. To add a tooltip, a menu trigger, and a click handler to a button, keep the button where you render it and add a mixin for each behavior, rather than wrapping it in a component for each one.

Mixins apply in array order. on(...) handlers for the same event run in that order, so calling event.stopImmediatePropagation() in one handler stops the handlers after it.

Animation mixins use the same prop. Put frequently changing values in normal props such as style, value, checked, and ARIA attributes rather than recreating static mixins for every state change.

DOM references, global events, and cleanup

ref(...) receives the inserted DOM node and a signal that aborts when that node is removed. Tie observers and element-owned listeners to that signal:

import { ref } from "remix/component";
import type { Handle } from "remix/component";

function MeasuredPanel(handle: Handle) {
  let width = 0;

  return () => (
    <div
      mix={ref((node, signal) => {
        let observer = new ResizeObserver((entries) => {
          let entry = entries[0];
          if (!entry) return;
          width = Math.round(entry.contentRect.width);
          handle.update();
        });

        observer.observe(node);
        signal.addEventListener("abort", () => observer.disconnect());
      })}
    >
      Panel width: {width}px
    </div>
  );
}

The ref callback runs when the element is inserted, not on every component update. If an element is conditional, its ref signal has the right lifetime for work that should stop when just that element disappears.

For event targets outside the rendered tree, such as window, document, or a media query, use native addEventListener(). Schedule browser-only setup after the client commit, and pass handle.signal so the listener is removed when the component disconnects:

import { clientEntry } from "remix/component";
import type { Handle } from "remix/component";

export const ViewportWidth = clientEntry(import.meta.url, function ViewportWidth(handle: Handle) {
  let width: number | undefined;

  handle.queueTask(() => {
    width = window.innerWidth;
    window.addEventListener(
      "resize",
      () => {
        width = window.innerWidth;
        handle.update();
      },
      { signal: handle.signal },
    );
    handle.update();
  });

  return () => <span>{width === undefined ? "Measuring…" : `${width}px`}</span>;
});

Scheduling the setup avoids reading window during server rendering. For a client-only root, setup already runs only in the browser, so it can register the listener directly.

Some listeners need a shorter lifetime than the component. Create an AbortController when that work starts, pass its signal to addEventListener(...), and abort it when the work finishes. Also abort it from handle.signal so navigation cannot leave temporary listeners behind.

Async work and cancellation

Remix gives async work an abort signal scoped to the work that created it:

Signal Aborts when Good for
The second argument to an on(...) handler The same handler runs again or its element is removed Requests where the latest event should replace earlier work.
The argument to a queued task The component renders again or disconnects Prop-keyed loading and post-render DOM work.
handle.signal The component disconnects Component-lifetime timers and global listeners.

Pass the narrowest signal into fetch() and check it after any awaited operation that cannot accept the signal. In this example, the server passes routes.albums.search.href() as searchHref, and the route returns a short text result. The browser component does not hard-code the endpoint:

app/ui/public/album-search.tsx
import { clientEntry, on } from "remix/component";
import type { Handle } from "remix/component";

export const AlbumSearch = clientEntry(
  import.meta.url,
  function AlbumSearch(handle: Handle<{ searchHref: string }>) {
    let error: string | undefined;
    let loading = false;
    let resultText = "";

    return () => (
      <div>
        <input
          aria-label="Search albums"
          mix={on("input", async (event, signal) => {
            let url = new URL(handle.props.searchHref, window.location.href);
            url.searchParams.set("query", event.currentTarget.value);
            error = undefined;
            loading = true;
            handle.update();

            try {
              let response = await fetch(url, { signal });
              if (!response.ok) {
                throw new Error(`Search failed with status ${response.status}`);
              }

              let nextResultText = await response.text();
              if (signal.aborted) return;

              resultText = nextResultText;
              loading = false;
              handle.update();
            } catch (caught) {
              if (signal.aborted) return;
              error = caught instanceof Error ? caught.message : "Search failed";
              loading = false;
              handle.update();
            }
          })}
          type="search"
        />
        {error ? <p role="alert">{error}</p> : null}
        {loading ? <p>Searching…</p> : null}
        {resultText ? <p>{resultText}</p> : null}
      </div>
    );
  },
);

Typing again aborts the previous handler signal and its fetch. A slow response for an old query cannot replace the newer results.

Keep application logic outside components

Setup-scope variables make component state easy, but they do not make the component the right owner for every rule. Keep state in the component when it describes the current UI, such as whether a panel is open, which element should receive focus, or whether a request is pending. Put application data and operations in ordinary TypeScript that the component calls.

A small model makes that boundary concrete. This cart owns its items and quantity changes:

app/ui/public/cart-model.ts
import { TypedEventTarget } from "remix/component";

export interface CartItem {
  id: string;
  quantity: number;
}

export class CartModel extends TypedEventTarget<{ change: Event }> {
  #items: CartItem[];

  constructor(items: readonly CartItem[]) {
    super();
    this.#items = [...items];
  }

  get itemCount(): number {
    return this.#items.reduce((total, item) => total + item.quantity, 0);
  }

  add(productId: string): void {
    let item = this.#items.find((item) => item.id === productId);

    this.#items = item
      ? this.#items.map((item) =>
          item.id === productId ? { ...item, quantity: item.quantity + 1 } : item,
        )
      : [...this.#items, { id: productId, quantity: 1 }];

    this.dispatchEvent(new Event("change"));
  }
}

The component creates the model from serializable props, listens for changes, and keeps only the open state for its details panel:

app/ui/public/cart.tsx
import { clientEntry, on } from "remix/component";
import type { Handle } from "remix/component";

import { CartModel, type CartItem } from "./cart-model.ts";

interface CartProps {
  initialItems: CartItem[];
  product: { id: string; name: string };
}

export const Cart = clientEntry(import.meta.url, function Cart(handle: Handle<CartProps>) {
  let cart = new CartModel(handle.props.initialItems);
  let detailsOpen = false;

  cart.addEventListener("change", () => handle.update(), { signal: handle.signal });

  return () => (
    <section>
      <button mix={on("click", () => cart.add(handle.props.product.id))} type="button">
        Add {handle.props.product.name}
      </button>
      <button
        aria-expanded={detailsOpen}
        mix={on("click", () => {
          detailsOpen = !detailsOpen;
          handle.update();
        })}
        type="button"
      >
        Cart ({cart.itemCount})
      </button>
      {detailsOpen ? <p>{cart.itemCount} items in your cart.</p> : null}
    </section>
  );
});

cart.addEventListener() connects model changes to handle.update(), and handle.signal removes the subscription when the component disconnects. If several components use the same model, create it at their nearest shared client boundary and provide it through component context. Do not pass a class instance through clientEntry(...) props because those props are serialized.

With this split, you can test cart rules without a DOM and save component tests for the details button and rendered item count, which depend on the component and DOM.

Client navigation

Keep using real anchors for links. After run() starts, the runtime can enhance same-origin navigations through the browser's Navigation API, which lets scripts intercept a navigation and handle it without a full page load. Cross-origin links, downloads, and navigations the browser cannot intercept continue as normal document requests.

app/ui/album-link.tsx
import type { Handle } from "remix/component";

import { routes } from "../routes.ts";

function AlbumLink(handle: Handle<{ albumId: string; title: string }>) {
  return () => (
    <a href={routes.albums.show.href({ albumId: handle.props.albumId })}>{handle.props.title}</a>
  );
}

Use navigate(href, options) when navigation begins in code. Apply link(href, options) to any HTML host that should behave like a link, such as a card whose whole surface is interactive:

app/ui/album-navigation.tsx
import { link, navigate, on } from "remix/component";
import type { Handle } from "remix/component";

import { routes } from "../routes.ts";

function AlbumNavigation(handle: Handle<{ albumId: string; title: string }>) {
  return () => {
    let href = routes.albums.show.href({ albumId: handle.props.albumId });

    return (
      <div>
        <article mix={link(href)}>{handle.props.title}</article>
        <button mix={on("click", () => navigate(href, { history: "replace" }))} type="button">
          Replace current album
        </button>
      </div>
    );
  };
}

On a non-anchor host, link(...) adds role="link", makes the element keyboard-focusable when needed, handles Enter and mouse or modifier-key activation, and reflects a disabled host with aria-disabled. Prefer an anchor when its markup fits. Use the mixin when making another host act as a link is an intentional part of the UI.

Native anchors and forms can also control frame-aware navigation with attributes that correspond to the link(...) and navigate(...) options:

  • data-rmx-target names the frame to reload.
  • data-rmx-src provides the URL to fetch for the mounted named frame selected by data-rmx-target while href remains the browser's destination.
  • data-rmx-history="push|replace" controls how the navigation updates history, including overriding a form's default.
  • data-rmx-reset-scroll="manual" preserves the current scroll position.
  • data-rmx-reset-focus="manual" disables the browser's automatic focus reset after navigation.
  • data-rmx-document opts out of interception and lets the browser perform a full-document navigation.

Use resetScroll: "manual" or resetFocus: "manual" for the same behavior in link(...) and navigate(...). Both controls also accept "after-transition", the default. Existing boolean options and "true" / "false" attribute values still work. true corresponds to "after-transition" and false to "manual".

The top frame follows the browser URL. If data-rmx-target is omitted, an intercepted navigation reloads the top frame from the link or form destination. Remix reconciles the new page into the current document, so a client entry that the new page renders in the same place keeps its local state while receiving the new server props. If a server render shows a section collapsed but the user expanded it in a client entry, it stays expanded. If a specified target does not match a mounted frame, the browser performs a document navigation. Native form navigation preserves its method, body, files, and submitter overrides. Back and forward traversal reloads the destination document instead of reconciling stale frame content.

A supplied data-rmx-src must be a valid same-origin URL regardless of the target. Invalid or cross-origin values disable interception and leave the navigation to the browser. These rules also apply to the src and target options of link(...) and navigate(...).

These attributes are most useful with named frames, and a form can navigate a frame directly through the frame resolver.

You can opt into link prefetching to speed up client-side navigations with the data-rmx-prefetch="intent|viewport" attribute. Remix loads the destination HTML to discover JavaScript, CSS, and declared image and font preloads without running the destination's code. Navigation reuses matching fresh HTML and asset responses from the browser HTTP cache.

Use intent to prefetch on hover, focus, or pointer press. Or use the more aggressive viewport value to prefetch links when they enter the viewport, including links already visible on initial load.

<a href={routes.albums.show.href({ albumId: handle.props.albumId })} data-rmx-prefetch="intent">
  {handle.props.title}
</a>

If you need to prefetch imperatively based on custom logic, you can use the prefetch API:

import { prefetch } from "remix/component";

import { routes } from "../routes.ts";

prefetch(routes.albums.show.href({ albumId: "thriller" }));

Only prefetch links whose GET requests are safe to make speculatively. See the prefetching reference for more information.

Enhancing forms with fetch and navigation

Once run() boots the runtime, Remix already submits forms as frame navigations without a full page reload. Write your own submit handler when you need behavior that doesn't provide, such as handling an error response inside the component.

You can do that without adding a second mutation API. The submit handler sends the form's existing action, method, and FormData, then follows the action's redirect:

app/actions/albums/edit/public/album-edit-form.tsx
import { clientEntry, navigate, on } from "remix/component";
import type { Handle } from "remix/component";

import { routes } from "../../../../routes.ts";

interface Album {
  artist: string;
  id: string;
  title: string;
  year: number;
}

export const AlbumEditForm = clientEntry(
  import.meta.url,
  function AlbumEditForm(handle: Handle<{ album: Album }>) {
    let error: string | undefined;
    let pending = false;

    return () => {
      let { album } = handle.props;

      return (
        <form
          action={routes.albums.edit.action.href({ albumId: album.id })}
          method="post"
          mix={on("submit", async (event, signal) => {
            let form = event.currentTarget;
            event.preventDefault();
            error = undefined;
            pending = true;
            handle.update();

            try {
              let response = await fetch(form.action, {
                body: new FormData(form),
                method: form.method,
                signal,
              });

              if (signal.aborted) return;
              if (!response.ok) {
                error = await response.text();
                if (signal.aborted) return;
                pending = false;
                handle.update();
                return;
              }

              await navigate(response.url, { history: "replace" });
            } catch (caught) {
              if (signal.aborted) return;
              error = caught instanceof Error ? caught.message : "Unable to save album";
              pending = false;
              handle.update();
            }
          })}
        >
          <label>
            Title
            <input defaultValue={album.title} name="title" required />
          </label>
          <label>
            Artist
            <input defaultValue={album.artist} name="artist" required />
          </label>
          <label>
            Year
            <input defaultValue={album.year} name="year" required type="number" />
          </label>
          <button disabled={pending} type="submit">
            {pending ? "Saving…" : "Save album"}
          </button>
          {error ? <p role="alert">{error}</p> : null}
        </form>
      );
    };
  },
);

The server action is the same one the plain form posts to. A non-enhanced submission still receives its redirect or error response. The enhanced path consumes that response, shows an error locally when it is not successful, and follows the controller's redirect URL with navigate(...).

Choose the synchronization boundary that matches the UI:

  • Submit the existing form normally when a document navigation is the right result.
  • Intercept it with fetch() when the page needs pending or inline error UI before navigating.
  • Send JSON when a browser-side model owns the data and no server-rendered region needs to change.
  • Poll a small JSON endpoint when data changes independently of the current page.

A mutation can also reload one route-owned frame without navigating the whole document.

Pending and optimistic UI

Keep a pending flag in setup scope when the render output reads it. Set it in the submit handler before the request and clear it when the request settles, as in the enhanced album form. That is enough for disabled controls, pending labels, progress indicators, and duplicate-submission prevention.

Optimistic UI renders the expected result before the request completes. Put the optimistic operation and its recovery policy in the model that owns the data. The component can render the model's current state while keeping UI concerns such as a pending label, open menu, or focused control locally.

Optimistic state works best for reversible, predictable changes such as toggles, reordering, and adding a known item. Wait for the response when the server chooses the final value, performs a destructive operation, or can reject the request in ways the user must resolve first. If an optimistic request fails, the model can restore its previous state or adopt the server response, and the component can render the resulting error or retry control.

When to create a custom event mixin

Most application code should use on(...) with native events. Create an event mixin when several components need the same interaction built from multiple low-level events, such as a gesture with timing or pointer state. The mixin can keep that state in setup scope and dispatch one semantic, typed event for consumers.

This longPress() mixin turns a held pointer into an app:longpress event:

app/ui/public/long-press.tsx
import { createMixin, on } from "remix/component";

export const longPressType = "app:longpress" as const;

export class LongPressEvent extends Event {
  constructor() {
    super(longPressType, { bubbles: true });
  }
}

declare global {
  interface HTMLElementEventMap {
    [longPressType]: LongPressEvent;
  }
}

export const longPress = createMixin<HTMLElement>((handle) => {
  let node: HTMLElement | undefined;
  let timeout: number | undefined;

  handle.addEventListener("insert", (event) => {
    node = event.node;
  });
  handle.addEventListener("remove", cancel);

  function cancel() {
    window.clearTimeout(timeout);
  }

  return () => (
    <handle.element
      mix={[
        on("pointerdown", () => {
          cancel();
          timeout = window.setTimeout(() => {
            node?.dispatchEvent(new LongPressEvent());
          }, 500);
        }),
        on("pointerup", cancel),
        on("pointercancel", cancel),
        on("pointerleave", cancel),
      ]}
    />
  );
});

Consume the semantic event with on(...) like any other DOM event:

<button
  mix={[
    longPress(),
    on(longPressType, () => {
      console.log("Long press");
    }),
  ]}
>
  Hold for options
</button>

Namespace custom event names to avoid collisions, and keep one-off behavior in the component that uses it. The remix/component API overview covers the full mixin API.