Skip to content

Conversation

@aleclarson
Copy link

@aleclarson aleclarson commented Nov 4, 2025

This proposal has the following goals:

  • Avoid global registry for custom “interactions”
  • Avoid [interaction](event) {…} syntax for interaction listening
  • Restore support for “event descriptor factories” (e.g. on.click(fn)) while keeping the new string-keyed API (e.g. { click: fn })

I believe these goals reflect the main concerns people have about the latest Events API (@remix-run/[email protected]).

1. Introduce Interaction type

Change defineInteraction to return a type-safe function, instead of a string.

import { defineInteraction, type Interaction } from '@remix-run/interaction'

// Assume `Press` and `PressEvent` are identical to what you see in ./src/lib/interactions/press.ts
const longPress = defineInteraction<PressEvent>('rmx:long-press', Press)

longPress satisfies Interaction<PressEvent> // New return type

This change…

  • removes the need for interface HTMLElementEventMap {…} extensions
  • removes the need for a global runtime registry for custom interactions

Note

Use of satisfies in this proposal is purely illustrative. You won't need it when using these APIs in your code. Read it as "this variable ABC is inferred to be of type XYZ".

Usage

An example of using an interaction with a JSX element:

return (
  <button
    on={{
      click(event) {},
      ...longPress(event => {}),
    }}
  >Click me</button>
)

The longPress() interaction returns a type-safe event descriptor:

longPress() satisfies {
  'rmx:long-press': (event: PressEvent) => void
}

If the ... spread syntax feels jarring to you, note that you can nest it in an array instead. Before you roll your eyes, note that the new on() function (described in the next section) is yet another alternative syntax that you might prefer. The key here is to be flexible, as it lets developers choose the syntax that feels most natural to them, and it's more forgiving to agentic coding.

<button
  on={[
    {
      click(event) {},
    },
    longPress(event => {}),
  ]}
>Click me</button>

2. Make on() multi-purpose

The on() function can be used 1 of 2 ways:

  • Add one or more listeners to an event target
  • Declare an event descriptor (when no event target is provided)

When declaring event listeners with JSX, you don't provide an event target:

import { on } from '@remix-run/interaction'
import { longPress } from '@remix-run/interaction/press'

function MyButton(this: Remix.Handle) {
  return (
    <button
      on={[
        on.click((event) => {
          event satisfies MouseEvent
          event.type satisfies 'click'
          event.currentTarget satisfies HTMLButtonElement
        }),
        // Example of listener options
        on.focus({ once: true }, (event) => {}),
        // Example of a custom interaction
        longPress((event) => {
          event satisfies PressEvent
          event.type satisfies 'rmx:long-press'
          event.currentTarget satisfies HTMLButtonElement
        }),
      ]}
    >
      Click me
    </button>
  )
}

Importantly, you can still pass a listeners object to the on prop. This API will feel more natural to beginners.

<button
  on={{
    click(event) {},
    focusin: capture(event => {}),
  }}>
  Click me
</button>

Forwarding the on prop

Your components may want to accept an on prop and forward it to a child JSX element. This is easy if we add nesting support. Essentially, the reconciler will flatten the array of listeners into a single object.

function Foo(props: {
  on?: Remix.EventListeners<HTMLButtonElement>
}) {
  return (
    <button on={[
      props.on,
      on.click(event => {}),
    ]}>
      Click me
    </button>
  )
}

Targeted on() calls

The current on() API is largely unchanged, but it now supports the same values as the new JSX on prop.

When on() receives an event target as the first argument, the listeners are immediately added to the target.

import { longPress } from '@remix-run/interaction/press'
import { on, capture } from '@remix-run/interaction'

// Basic API: Multiple listeners
const dispose = on(target, signal, {
  foo(event) {},
  bar: capture(event => {}),
})

// Basic API: Single listener
const dispose = on.foo(target, signal, event => {})

// Advanced API
const dispose = on(target, signal, [
  on.foo(event => {}),
  on.bar({ capture: true }, event => {}),
  longPress(event => {}),
  {
    foo(event) {},
    bar: capture(event => {}),
  }
])

@gustavopch
Copy link
Contributor

on reads so much better than events and is less likely to cause name clashes

@aleclarson
Copy link
Author

@gustavopch I agree, and I've updated the proposal to use on instead of events.

@mjackson
Copy link
Member

mjackson commented Nov 5, 2025

Thanks for the thoughtful proposal, @aleclarson.

Avoid global registry for custom “interactions”

I don't believe this will be a big concern in practice since event names are easily prefixed (e.g. "rmx:press"). Is your main concern collision of event names?

Avoid [interaction](event) {…} syntax for interaction listening

Please say more about why this is a goal of yours. You dislike the syntax?

Restore support for “event descriptor factories” (e.g. on.click(fn))

One important goal that we have is to avoid needing an import just to declare handlers for events that are built into the browser, like click.

// This sucks :/
import { on } from '@remix-run/interaction'

let button = <button on={[ on.click(...) ]}>Click Me</button>

A separate import is fine for custom interactions. Folks are already used to importing things they make. But it doesn't feel right to make them import basic stuff the browser already knows about.

@afoures
Copy link

afoures commented Nov 5, 2025

But it doesn't feel right to make them import basic stuff the browser already knows about.

I feel like having to import capture and listenWith goes directly against this 🤔

@mjackson
Copy link
Member

mjackson commented Nov 5, 2025

having to import capture and listenWith goes directly against this

@afoures You're right, it does. I'm open to any ideas you may have about how to avoid it.

@aleclarson
Copy link
Author

aleclarson commented Nov 5, 2025

Avoid global registry for custom “interactions”

Is your main concern collision of event names?

The main concern, for me, is with code splitting. As I understand it, bundlers cannot "tree-shake" impure function calls.

Avoid [interaction](event) {…} syntax for interaction listening

Please say more about why this is a goal of yours. You dislike the syntax?

It's a fine syntax, but it requires a declare global {} statement for type safety, which isn't great.


Both of those concerns with the new defineInteraction are "solved" by returning a function instead of a string. Of course, it introduces a new problem (how to use an interaction alongside built-in events), and my solution may be less aesthetically appealing to you.

I definitely understand the aversion to importing on everywhere you need event listeners, but the JSX on prop still supports the "basic API" (e.g. { click: fn }) in my proposal. You only need to import { on } from "@remix-run/interaction" if you want to use the on.click(…)-style API, which many people prefer.

As far as having to import capture/listenWith, I think those instances are rare enough that it feels like a non-issue. But, we could add support for the following syntax:

<div
  on={{
    click: {
      capture: true,
      listener(event) {},
    },
  }}
>
  {}
</div>

…or perhaps a more elegant solution?

<div
  on={{
    click: {
      capture(event) {},
    },
    dblclick: {
      once(event) {},
    },
  }}
>
  {}
</div>

…or you could flip it:

<div
  on={{
    capture: {
      click(event) {},
    },
    once: {
      dblclick(event) {},
    },
  }}
>
  {}
</div>

…and use captureOnce to combine them.

@mjackson
Copy link
Member

mjackson commented Nov 6, 2025

we could add support for the following syntax:

<div
  on={{
    click: {
      capture: true,
      listener(event) {},
    },
  }}
>
  {}
</div>

I actually had the exact same thought this morning after I left my comment. We're already doing something similar in the router where you can define a route handler either as a plain handler function or as an object of { middleware, handler }. I actually really like the symmetry between the two.

As for code splitting concerns, we can address that easily through a separate module that contains our global registry map. This module would be a dependency of anyone who needs the registry. Unless I'm missing something, that shouldn't be an issue.

@afoures
Copy link

afoures commented Nov 6, 2025

another alternative could be to inline the factory inside the on function, like that nothing has to be imported except for the on function and custom interactions:

type Bind<target extends EventTarget> = <event extends EventType<target>>(
  name: event,
  listener: ListenerFor<target, event>,
  options?: { capture?: boolean; once?: boolean; passive?: boolean },
) => Array<any>;

function on<target extends EventTarget>(
  target: target,
  cb: (bind: Bind<target>) => void,
  options?: { signal?: AbortSignal },
) {
  // ...
}

const button = document.createElement("button");

const press = defineInteraction("custom:press", () => {});

declare global {
  interface HTMLElementEventMap {
    [press]: PointerEvent;
  }
}

const controller = new AbortController();

on(
  button,
  (bind) => [
    bind("click", (event) => {}),
    bind("click", (event) => {}, { capture: true }),
    bind(press, (event) => {}, { once: true }),
    bind("focus", (event) => {}, { passive: true }),
  ],
  { signal: controller.signal },
);

even though the object approach resembles the way fetch-router routes work, i prefer this factory approach, for a few reasons:

  • the bind function has the same args as the addEventListener native function, and aligning with the web api is a core value of Remix
  • no imports for native browser features like capture/once/passive, everything is available from the get-go, and only custom interactions needs to be imported
  • always returning an array removes the weird changes when we want to go from one listener to two (click(event) {…}, to click: [(event) => {…}]
  • easier merging of listeners (bind) => on_from_somewhere(bind).concat(…) {... on_from_somewhere, click: [on_from_somewhere.click, (event)=> {…}]} // making sure to never override click listener
  • it is easy to add conditional listeners by pushing into the array and then return it

but this approach would be a little unusual when writing JSX

<div
  on={(bind) => [
    bind("click", () => { /* ... */ },{ capture: true }),
    bind("focus", () => { /* not captured */ }),
  ]}
/>

@aleclarson
Copy link
Author

As for code splitting concerns, we can address that easily through a separate module that contains our global registry map. This module would be a dependency of anyone who needs the registry. Unless I'm missing something, that shouldn't be an issue.

The issue appears when calling defineInteraction(), because of its side effect of adding to the "global" registry. Bundlers typically can't remove such function calls.

@aleclarson
Copy link
Author

@afoures I would argue your suggestion solves a “problem” that is inconsequential. Importing on is not difficult, and is only required if the basic API isn't enough (or you prefer the “advanced” on.click(…) API).

In regard to conditional listeners, I think my proposal could support such a thing:

function Foo(props: {
  on?: Remix.EventListeners<HTMLButtonElement>
  enableClicks?: boolean
}) {
  return (
    <button on={[
      props.on,
      props.enableClicks && on.click(event => {}),
    ]}>
      Click me
    </button>
  )
}

@afoures
Copy link

afoures commented Nov 6, 2025

@aleclarson how would you prevent this usage with TS

on(signal, [
  on.click((event) => {
    event satisfies MouseEvent
    event.type satisfies 'click'
    event.currentTarget satisfies HTMLButtonElement
  }),
])

@aleclarson
Copy link
Author

aleclarson commented Nov 6, 2025

@afoures I don't understand your question. In my proposal, on(…) requires an event target:

type falsy = false | null | undefined

type DisposeFn = () => void

type EventDescriptor = {
  type: string
  listener: (event: Event) => void
  options?: AddEventListenerOptions
}

declare function on(
  target: EventTarget,
  signalOrOptions: AbortSignal | AddEventListenerOptions | undefined,
  descriptors: readonly (EventDescriptor | falsy)[]
): DisposeFn

on.click(listener) satisfies EventDescriptor
on.click(options, listener) satisfies EventDescriptor

on.click(target, listener) satisfies DisposeFn
on.click(target, options, listener) satisfies DisposeFn

Of course, on() will be typed with generics in reality, but this conveys the idea. Also, you can assume on will also be a Record<string, Function> that is typed as needed for on.click(…)-style calls.

@afoures
Copy link

afoures commented Nov 6, 2025

sorry, my question was: how can you make sure that typescript will only allow using the click factory for targets that emit at a certain point a click event. for example, an AbortSignal is an EventTarget, but it does not make sense to listen for a click event on it, and typescript should catch it.

but i have trouble seeing how your proposal would do that!

@afoures
Copy link

afoures commented Nov 7, 2025

another alternative with function could be

type EventListenerFactory<target extends EventTarget> = {
  [event in EventType<target>]: (
    listener: ListenerFor<target, event>,
    options?: { capture?: boolean; once?: boolean; passive?: boolean },
  ) => void;
};

function on<target extends EventTarget>(
  target: target,
  options: { signal: AbortSignal },
  cb: (target: EventListenerFactory<target>) => void,
) {
  // ...
}

const button = document.createElement("button");

const press = defineInteraction("custom:press", () => {});

declare global {
  interface HTMLElementEventMap {
    [press]: PointerEvent;
  }
}

const controller = new AbortController();

on(button, { signal: controller.signal }, (target) => [
  target.click((event) => {
    // ...
  }),
  target.click(
    (event) => {
      // ...
    },
    { capture: true },
  ),
  target[press](
    (event) => {
      // ...
    },
    { once: true },
  ),
  target.focus(
    (event) => {
      // ...
    },
    { passive: true },
  ),
]);

<div
  on={(target) => [
    target.click(
      (event) => {
        // ...
      },
      { capture: true },
    ),
    target.focus((event) => {
      // not captured
    }),
  ]}
/>

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants