Guide

# Actions & events

How a DOM event becomes a server action: `action` declares what the client may invoke, `on(...)` wires an element to it, a param schema coerces the input, and event/timing modifiers cover the page-level patterns that would otherwise need a hand-written Stimulus controller.

## Declaring an action, binding an event

A reactive action is a two-step contract. `action :name` on the class declares the method the client may invoke — **default-deny**, so a public method with no `action` line is unreachable. `on(:name)` on an element wires a DOM event to that action: it emits the signed `data-reactive-*` attributes the one generic Stimulus controller reads.

```ruby
class Counter < ApplicationComponent
  include Phlex::Reactive::Component

  reactive_record :counter
  action :increment            # declared → invokable
  action :decrement

  def increment = @counter.increment!(:value)
  def decrement = @counter.decrement!(:value)

  def view_template
    div(**reactive_root) do
      button(**on(:decrement)) { "−" }
      span { @counter.value }
      button(**on(:increment)) { "+" }
    end
  end
end
```

`on(:action, event: "click")` defaults to a click and adds `type="button"` (so a bare button inside a `<form>` can't submit it). The event flows **client → endpoint → action → re-render**: the click POSTs the signed token, the endpoint verifies it and runs the declared method, and the component re-renders into its own `#id`. No routes, no partial, no hand-picked Turbo target.

> **Combining on(...) with other attributes:** `on(...)` returns a `data:` hash. Merging it with another `data:`/ `class:` with a bare `**` **clobbers** the `data-action` the descriptor rides on. Use Phlex's `mix` to deep-merge:button(**mix(on(:increment), class: "btn", data: { testid: "inc" })) { "+" }

## Passing input — the param schema

An action that takes input declares its params. Only declared keys reach the method, each **coerced to its declared type**; anything not in the schema is dropped before your method runs (no raw mass assignment). The schema is **compiled once at declaration** — a typo'd type symbol raises `Phlex::Reactive::UnknownParamType` at class load, not silently at click time.

```ruby
action :rename, params: { title: :string }
def rename(title:)
  authorize! @todo, :update?      # the signature is not authorization
  @todo.update!(title:)
end
```

| Type | Coercion | Dropped when |
| --- | --- | --- |
| `:string` | the default; used raw | — |
| `:integer` | `Integer(value)` | non-numeric |
| `:float` | `Float(value)` | non-numeric |
| `:boolean` | truthy string → `true`/`false` | — |
| `:date` | ISO8601 → `Date` | won't parse |
| `:datetime` | ISO8601 → `Time` | won't parse |
| `:decimal` | `BigDecimal(value)` | non-numeric |
| `:file` | `ActionDispatch::Http::UploadedFile`, untouched | a non-file value |

**Drop, don't fabricate.** A value that won't coerce is dropped, so the method receives its keyword default — never a fabricated zero or a coerced-to-string surprise. Give every declared param a default (`def rename(title: nil)`) so a dropped value has somewhere to land.

Nested and array params compose. A `params: { invoice: { date: :string } }` matches bracketed field names (`invoice[date]`) after the endpoint expands the brackets — so the schema must mirror the field **names**, not the conceptual params. A flat `{ date: :string }` against `invoice[date]` inputs matches nothing and the value is silently dropped.

> **Files & multipart:** Declare `:file` (or `[:file]` for multiple) to accept an upload in a reactive action. When the root holds a populated `<input type="file">`, the client sends the action as multipart `FormData` instead of JSON — same bracket-expanded field shape, so both bodies coerce identically. See the [uploads example](https://phlex-reactive.zoolutions.llc/docs/example-uploads) for the full walkthrough.

## Custom param types (Phlex::Reactive.param_type)

Register your own type in an initializer. The block receives the raw client value and returns the coerced value, or `Phlex::Reactive::ParamSchema::DROP` to reject it (the keyword default then applies — the same drop-don't-fabricate contract as the built-ins).

```ruby
# config/initializers/phlex_reactive.rb
Phlex::Reactive.param_type(:money) do |value|
  if /\A\d+(\.\d{1,2})?\z/.match?(value.to_s)
    BigDecimal(value)
  else
    Phlex::Reactive::ParamSchema::DROP
  end
end

# then, in any component:
action :charge, params: { amount: :money }
def charge(amount:) = @invoice.charge!(amount)   # a BigDecimal, or unset
```

A schema referencing a registered type is validated at declaration like any built-in.

> **Register during boot only:** The type registry is **frozen after initialization**. A runtime `param_type` call raises — register every custom type from an initializer, never lazily from a component.

## Keyboard triggers (event:)

`event:` is interpolated straight into the Stimulus action descriptor, so any Stimulus event string works — including its native **keyboard filters**. Pass `event: "keydown.enter"` to fire only on Enter, `event: "keydown.esc"` for Escape — the classic "Enter adds the row", "Escape cancels the edit" interactions, with no `event.key` check of your own.

```ruby
# Enter in the composer adds the todo (same action as the Add button).
input(**mix(on(:add, event: "keydown.enter"), name: "title", placeholder: "New todo…"))

# Inline editor: Enter saves; a separate control cancels on Escape.
input(**mix(on(:save, event: "keydown.enter"), name: "title", value: @todo.title))
button(**on(:cancel, event: "keydown.esc")) { "Cancel" }
```

The filter tokens are Stimulus's own (`enter`, `esc`, `space`, `up`, `down`, a bare letter, …). A keyboard trigger isn't a click, so it gets **no** forced `type="button"`. Folding the key into `event:` keeps `key` free as an ordinary action-param name.

> **One action per element:** Each trigger element carries a **single** reactive action, so you can't put `on(:save, event: "keydown.enter")` and `on(:cancel, event: "keydown.esc")` on the same input — the second overwrites the first. Bind each key trigger to its own element.

## Timing — debounce: and throttle:

Two mutually-exclusive rate limiters shape how rapid events reach the server. Passing both raises `ArgumentError`.

- **`debounce: 300` (trailing-edge)** coalesces rapid events into one round trip fired after the quiet period — live-as-you-type without a POST per keystroke. A blur flushes a pending dispatch so the last edit is never dropped.
- **`throttle: 250` (leading-edge)** fires the first event immediately, then drops further events until the window elapses — for a hot trigger like `scroll` or `mousemove`.

```ruby
# Recompute a total live as the user types, without hammering the endpoint.
input(**mix(on(:update, event: "input", debounce: 300), name: "quantity", value: @item.quantity))
```

## Page-level modifiers — window:, outside:, once:

Three modifiers (issue #80) cover the page-level trigger patterns that otherwise need a hand-written Stimulus controller:

- **`window: true`** binds the trigger to the window (Stimulus's native `@window`) for page-level events like `scroll`/`resize`. A window-bound trigger is **never `preventDefault`-ed** — a mounted dropdown must not kill link clicks elsewhere — and skips the forced `type="button"`.
- **`outside: true`** fires the action only for events whose target is **outside** this component's root — the close-a-dropdown-on-outside-click pattern. An event inside the root is a complete client-side no-op. Implies `window: true`.
- **`once: true`** fires at most once, then unbinds (Stimulus's `:once`).

```ruby
# A dropdown that closes itself on any click outside — no Stimulus controller.
div(**mix(reactive_root, on(:close_menu, outside: true))) do
  button(**on(:toggle_menu)) { "Menu" }
  ul { menu_items } if @open
end

# Throttled page-scroll tracking.
div(**mix(reactive_root, on(:track, event: "scroll", window: true, throttle: 500)))
```

## Reserved keyword names

These keywords on `on(...)` are **reserved** — they configure the trigger and are no longer usable as free action-param names. Everything **not** on this list flows through as an action param (so `on(:switch, key: "pgbus")` still passes `key` as a param):

| Keyword | Purpose |
| --- | --- |
| `event:` | the DOM event / Stimulus keyboard filter (`"click"`, `"keydown.enter"`, …) |
| `debounce:` | trailing-edge coalescing (ms) |
| `throttle:` | leading-edge rate limit (ms) |
| `window:` | bind to the window |
| `outside:` | fire only outside the root (implies `window:`) |
| `once:` | fire at most once |
| `confirm:` | gate behind a confirmation dialog |
| `listnav:` | combobox keyboard navigation |
| `optimistic:` | reversible cosmetic hint applied before the round trip |
| `loading:` / `disable_with:` | declarative pending state on the trigger |

A misspelled action surfaces early: with `Phlex::Reactive.verbose_errors` on (the default in development and test), `on(:typo)` **raises at render time** listing the declared actions, instead of an opaque 403 on click. Production keeps the permissive emit so a stale page after a deploy that removed an action never 500s on render.

## on_client — the same modifiers, zero round trips

`on_client(:event, ops)` binds a DOM event to a chain of declared DOM operations the controller applies **locally** — no token, no params, no POST, ever. It's the zero-round-trip sibling of `on(...)`, for purely presentational state (tabs, a dropdown, an accordion).

```ruby
div(**mix(reactive_root, on_client(:click, js.hide("#menu"), outside: true))) do
  button(**on_client(:click, js.show("#menu"))) { "Menu" }
  div(id: "menu", hidden: true) { menu_items }
end
```

`window:`, `once:`, and `outside:` compose **exactly** as they do on `on(...)`: the menu above closes on any outside click, and the window-bound trigger never `preventDefault`s. Reach for `on_client` when the interaction is presentational; reach for a signed `action` (a token

- POST) only when the server must **decide** or **persist** something. See the [client-only ops example](https://phlex-reactive.zoolutions.llc/docs/example-client-ops) for the live version.