Module: Phlex::Reactive::Component

Extended by:
ActiveSupport::Concern
Defined in:
lib/phlex/reactive/component.rb

Overview

Component turns a self-contained Phlex component into a Livewire-style reactive unit: declare actions in Ruby, and the generic reactive Stimulus controller wires clicks/inputs to an HTTP round trip that re-renders the component and applies it back into the DOM (a plain replace by default; return Response.morph(self) to morph in place and keep the focused input — issue #28). No per-feature Stimulus controllers, no hand-picked Turbo targets.

Include alongside Phlex::Reactive::Streamable (which provides #id and the re-render machinery).

Security model (the decisive design choice) ===

We do NOT ship component STATE to the browser (no snapshot). The DOM carries a signed IDENTITY:

* Record-backed (the common case): reactive_record :todo signs the
record's GlobalID. The server re-finds it via GlobalID — the client
can neither forge the component class nor swap the record. State =
the database.
* State-backed (record-less, e.g. a counter): reactive_state :count
signs the listed instance variables. Use when there is genuinely no
record to re-find.
* Both (the inline_edit pattern): reactive_record :record plus
reactive_state :attribute, :editing signs the record's GlobalID AND
the transient mode in one token, so "which field / what mode" survives
every action round trip and stays tamper-proof.

Actions are DEFAULT-DENY: only methods declared with action :name may be invoked. The signature proves the token is ours, NOT that this user may act — your action must still authorize the record. Action params pass through a declared schema; nothing else reaches the method.

Usage (record-backed):

class Todos::Item < ApplicationComponent
include Phlex::Reactive::Streamable
include Phlex::Reactive::Component

reactive_record :todo
action :toggle
action :rename, params: { title: :string }

def initialize(todo:) = @todo = todo
def id = dom_id(@todo)

def toggle  = (authorize!(@todo, :update?); @todo.toggle!(:done))
def rename(title:) = (authorize!(@todo, :update?); @todo.update!(title:))

def view_template
  li(id:, **reactive_attrs) do
    button(**on(:toggle)) { @todo.done? ? "" : "" }
    span { @todo.title }
  end
end
end

Defined Under Namespace

Classes: Action, CollectionDefinition

Constant Summary collapse

EMPTY_PARAMS_JSON =

Attributes for an element that triggers an action. button(**on(:toggle)) { "○" } form(**on(:save, event: "submit")) { ... } input(**on(:update, event: "input", debounce: 300)) # live-as-you-type

Extra keyword args become explicit params merged over collected form fields. For click triggers we force type="button" so a bare button inside a

can't submit it and cause a full-page navigation.

debounce: (milliseconds) coalesces rapid events (e.g. keystrokes on an "input" trigger) into ONE round trip fired after the quiet period — so live-update-as-you-type doesn't POST per keystroke. A blur flushes a pending dispatch so the last edit is never dropped. Omit it for the immediate-dispatch default.

confirm: (a message string) gates the action behind a confirmation prompt (issue #52). Destructive reactive triggers can't use Hotwire's data-turbo-confirm — the reactive controller calls preventDefault and enqueues the POST itself, so Turbo's confirm handling never runs. The client shows window.confirm(message) FIRST and bails before any enqueue/debounce if the user declines (and prevents the native default so a submit trigger can't navigate on cancel). Omit it for no prompt. button(**on(:destroy, confirm: "Really delete this item?")) { "Delete" } The verbatim JSON for an empty explicit-params payload. The common trigger (on(:increment), no params) hits this on EVERY render — skipping params.to_json (which re-serializes {} to the same "{}" each time) avoids a per-render allocation while keeping the wire format byte-identical.

"{}"

Instance Method Summary collapse



352
353
354
# File 'lib/phlex/reactive/component.rb', line 352

def reactive_input(param, **attrs)
  input(**reactive_field(param, **attrs))
end

#reactive_root(**overrides) ⇒ Object

The WHOLE reactive root in one spread (issue #48). reactive_attrs alone doesn't emit id:, so id: and data-controller="reactive" can land on DIFFERENT elements — putting id: on a child leaves the controller root's id empty, which silently breaks token threading (the client self-matches its next token by this.element.id) and 403s on the next action.

reactive_root binds the id to the SAME element as reactive_attrs, so the footgun is unbuildable:

div(**reactive_root) { ... }                       # id + controller + token
div(**reactive_root(class: "card")) { ... }        # add your own attrs

mix deep-merges, so overrides add class:/data: without clobbering the controller/token data: (a bare data: would). The id is resolved separately (an explicit override wins as a clean replace, not a mix string-concat — mix would join two String ids into "default override").



288
289
290
291
# File 'lib/phlex/reactive/component.rb', line 288

def reactive_root(**overrides)
  root_id = overrides.delete(:id) || id
  mix({ **reactive_attrs }, overrides, { id: root_id })
end

#reactive_select(param, **attrs) ⇒ Object

Render a