Ramonda

Events

Events are how a component reacts to what the user does — a click, typing, submitting a form.

On an element you render

Pass a handler as a prop. An event prop is on plus the event's own name, exactly as the browser spells it — onclick, oninput, onmouseenter, onfocusin. There is no second vocabulary to learn and nothing is translated: whatever you would pass to addEventListener, put on in front of it.

That is why it is lowercase. onMouseEnter is a convention from elsewhere, and this framework refuses it rather than quietly accepting a name the DOM does not have — the error names the spelling to use.

render() {
  return <button onclick={this.increment}>+1</button>;
}

this.increment is a method on your component. Ramonda ties your methods to the component for you, so handing one to onclick just works — this still means the component inside it. No constructor, no arrow-function fields, no .bind(this). (A method whose name starts with _ is left unbound, if you ever want to opt out.)

If your handler needs the event, annotate its parameter with the matching DOM event type — or leave it out, because the type is already known: every handler's parameter is typed from the DOM's own event map. oninput hands you a plain Event — read event.target for the value; onclick a PointerEvent (a MouseEvent with pointer details, so clientX and button are there too):

onInput(event: Event) {
  this.text = (event.target as HTMLInputElement).value;
}

That cast is not the answer — it is what EventOn below removes. It is written here because the DOM's own types leave you nowhere else to go.

An inline arrow — onclick={(e) => …} — types e for you, and that is the only thing it does better. It also builds a new function on every render, so the listener is removed and re-added on the element every time. Both halves of the toolchain say so: ramonda-check reports it from the source before anything runs, and a development build reports it again as RMD020 once it does. Annotate the method instead; when a handler has to be built per item, @memoized caches it by its arguments.

An event whose name on… cannot spell

on plus the name covers every standard event, because all of them are a single lowercase word. A custom event usually is not — a web component dispatches my-event, with a dash — and onmy-event reads as a typo while on-my-event would listen for -my-event, which nothing dispatches.

So write it after a colon and it is taken exactly as it stands:

<x-thing on:my-event={this.handle} />

The handler receives a plain Event; a custom event's detail is your own, so cast it to the shape you dispatched.

Reading a field off the element the handler is on

The event itself is already typed from the name — onclick gives you a PointerEvent, onkeydown a KeyboardEvent — but reading the element is a separate question, and the DOM's own types cannot answer it:

onChange(e: Event) {
  this.draft = e.currentTarget.value;
                 ~~~~~~~~~~~~~
  Property 'value' does not exist on type 'EventTarget'.
}

currentTarget is typed EventTarget | null, because in the DOM an event can be listened for anywhere. Say which element it is:

import type { EventOn } from "@ramonda/core";

class Draft extends Component {
  @state draft = "";

  onChange(e: EventOn<HTMLInputElement>) {
    this.draft = e.currentTarget.value;
  }

  render() {
    return <input aria-label="Draft" onchange={this.onChange} />;
  }
}

A second argument names the event too, for a handler that wants both halves:

import type { EventOn } from "@ramonda/core";

class Picker extends Component {
  onPick(e: EventOn<HTMLButtonElement, PointerEvent>) {
    e.currentTarget.blur();
  }

  render() {
    return <button type="button" onclick={this.onPick}>Pick</button>;
  }
}

target is not narrowed, and that is deliberate. currentTarget is the element the listener is attached to, which the framework knows because it attached it. target is where the event originated — a click on a <span> inside a <button> has the span as its target — so a type naming it as the button would be wrong exactly when it matters. Reach for currentTarget.

It is an annotation, not a proof. Naming EventOn<HTMLSelectElement> on an <input> compiles: a handler prop accepts a narrower parameter than it promises — which is what lets EventOn stand here at all — and nothing cross-checks the element you named against the tag it is on. You are telling the compiler what is there, exactly as the as HTMLInputElement cast this replaces did, in fewer characters and at the top of the handler where it can be read.

A listener you arm and disarm

@onWindow and @onDocument attach for the component's whole life. When the listener should only be live sometimes — a keydown while a dialog is open, a pointermove during a drag — that is the Listener hook, and it hands you a plain Event:

import { Component, Listener, mounted } from "@ramonda/core";

export class Dialog extends Component<{ onClose: () => void }> {
  private escape = this.use(Listener, () => ({
    on: "document" as const,
    type: "keydown",
    run: (e) => {
      if ((e as KeyboardEvent).key === "Escape") this.props.onClose();
    },
  }));

  @mounted open() {
    this.escape.listen();
  }

  close() {
    this.escape.stop();
  }

  render() {
    return <div role="dialog">…</div>;
  }
}

as const on the target is needed because on takes one of two words, and an object literal widens a string unless it is told not to. The cast on the event is the honest spelling here and not an oversight. The decorators type the event from the NAME because the name is written in their signature; on the hook it is a prop, so the type cannot follow it. Three ways round that were tried and each fails — the details are on ListenerProps.run if you are about to try a fourth.

If the listener lives for the whole component, use the decorator. It is typed and checked:

@onDocument("keydown") onKey(e: KeyboardEvent) { … }

On window or document

Some events don't come from an element you render — the window resizing, a key pressed anywhere on the page. Decorators handle those:

@onWindow("resize")
onResize(event: UIEvent) {}

@onDocument("keydown")
onKey(event: KeyboardEvent) {}

An event from an element you did render needs no decorator: write the handler on that element, where you can see which element it is on.

Each attaches when the component appears and removes itself when the component goes away — no cleanup to write, and no way to leave one dangling.

They take the event's own name, not the prop. There is no on in front of it here — the prop on an element is onclick and the decorator's argument is "click". A name the target has types the handler's parameter for you; any other string is accepted and hands over a plain Event, which is how a custom event works:

export class Thing extends Component {
  @onDocument("my-event")
  onCustom(event: Event) {
    void event;
  }
  render() {
    return <span />;
  }
}

Two spellings are refused, because neither can ever fire — onclick is the JSX prop rather than the event, and addEventListener is case-sensitive. The error names the one to use:

export class Wrong extends Component {
  @onWindow("onclick")
  a(event: Event) {
    void event;
  }
  @onWindow("MouseDown")
  b(event: Event) {
    void event;
  }
  render() {
    return <span />;
  }
}

Anything else passes. A custom event may be called anything, so "clik" cannot be refused without refusing "save" and "my-event" with it.

window is 0px wideresize the browser

last key: pressed 0 times — type anywhere on the page

The event is typed from its name

Name the event and the handler's parameter is typed to match: "keydown" gives a KeyboardEvent, "click" a MouseEvent, with no cast. A custom event name the platform doesn't know is typed as the general Event.

There is no decorator for your own elements

A component has no element of its own — it puts what its render() returns on the page — so there is nothing for a decorator to attach to, and nothing to guess about. Write the handler on the element that emits the event:

render() {
  return (
    <button onmouseenter={this.onEnter} onclick={this.onClick}>

    </button>
  );
}

Which also settles the awkward cases. mouseenter needs a box to enter and focus needs something focusable — on the element in front of you, both are obviously fine.

Both decorators work on a Hook as well as on a component, because window and document are the same target whoever listens.

A handler, or a value, per item

A row usually needs a handler that knows which row it is, and the obvious way to write that is a closure per item — which is a new function on every render, re-attached to every button, every time. @memoized caches the function by its arguments, per instance, so asking twice gives the same function back:

@memoized
remove(name: string) {
  return () => {
    this.items = this.items.filter((item) => item !== name);
  };
}

// in render:
<button onclick={this.remove(name)}>remove</button>

The decorated method returns the handler rather than being one — that is what gives the cache something to key. Arguments must be strings, numbers or booleans; the key is built from them, and an object has no stable form to build one out of. When the value you have is an object, pass the index the list's mapper already hands you, or an id from the item.

Entries whose arguments were not asked for during a render are dropped, so the cache follows the list instead of growing with every value ever seen.

It caches a value as readily as a function. A row that needs a stable object — a config bag, a query key, props for a child — has the same problem and the same answer:

@memoized
config(id: string) {
  return { id, href: `/rows/${id}` };
}

// in render:
<Row cfg={this.config(row.id)} />

Nothing else reaches this case. A @compute belongs to the component, not to the row, so it cannot hold one value per item; a field and a module constant cannot either. That is what RMD020 and RMD022 mean when they report an object rebuilt per row.

What the cache is allowed to remember

The method runs once per key and never again, so anything it reads before returning the handler is closed into that handler and would be frozen there:

@memoized
remove(name: string) {
  const mode = this.mode;                  // read while BUILDING the handler
  return () => this.apply(mode, name);
}

That is watched. The reads the builder makes are tracked, and when one of them changes, that entry is dropped — the next render builds the handler again, with the value the signal now holds. Only that entry: a handler built for other arguments, which read nothing, keeps the very same function.

So the rule is short: read state inside the returned handler when you can, and if you read it while building, expect a new function when it changes — which is right, because the handler now does something different.

A builder that reads nothing is the common case and pays nothing at all: no reads are tracked, no entry is ever dropped, and the handler is the same function for the life of the component. A plain field (not @state) read while building is the one thing that cannot be watched, for the same reason it cannot be watched anywhere — there is no signal to hear from.

  • apples
  • bread
  • coffee

Press the identity button: the same arguments give back the same function, which is what keeps the listener from being re-attached — and what stops RMD020 reporting the row.

Which to use

the target is…use
an element in your render()a prop: onclick={this.handle}
window or document@onWindow / @onDocument
an element you rendereda handler in the markup — onclick={this.handle}

Next