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 morphs it back into the DOM. 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

Instance Method Summary collapse

Instance Method Details

#nested_attributes(association, attrs) ⇒ Object

Map a declared nested param onto Rails’ <assoc>_attributes, carrying the existing associated record’s id so accepts_nested_attributes_for matches it IN PLACE instead of building a second one (issue #24). Returns the update hash; pass it to update!:

def save(address:) = nested_update!(:address, address)

The id is only added when the association already exists, so the first save (no associated record yet) creates one cleanly. The given attrs are not mutated.



245
246
247
248
249
250
251
# File 'lib/phlex/reactive/component.rb', line 245

def nested_attributes(association, attrs)
  merged = attrs.dup
  existing = reactive_record_for_nested.public_send(association)
  merged[:id] = existing.id if existing

  {"#{association}_attributes": merged}
end

#nested_update!(association, attrs, **extra) ⇒ Object

Map a nested param onto <assoc>_attributes (with id preservation) AND apply it to the component’s record in one call (issue #24). Extra keyword attributes update alongside the association.

def save(address:, name:) = nested_update!(:address, address, name:)


257
258
259
# File 'lib/phlex/reactive/component.rb', line 257

def nested_update!(association, attrs, **extra)
  reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
end

#on(action_name, event: "click", debounce: nil, **params) ⇒ Object

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 <form> 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.



196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/phlex/reactive/component.rb', line 196

def on(action_name, event: "click", debounce: nil, **params)
  attrs = {
    data: {
      action: "#{event}->reactive#dispatch",
      reactive_action_param: action_name.to_s,
      reactive_params_param: params.to_json
    }
  }
  attrs[:data][:reactive_debounce_param] = debounce if debounce
  attrs[:type] = "button" if event == "click"
  attrs
end

#reactive_attrsObject

Root-element attributes: marks the element reactive and carries the signed identity token. Spread onto the root:

div(id:, **reactive_attrs) { ... }


173
174
175
176
177
178
179
180
# File 'lib/phlex/reactive/component.rb', line 173

def reactive_attrs
  {
    data: {
      controller: "reactive",
      reactive_token_value: reactive_token
    }
  }
end

#reactive_connection_idObject

The acting client’s SSE connection id during the current action (nil outside an action, or when the client isn’t subscribed to a stream). Pass it as ‘exclude:` when broadcasting from an action so the actor doesn’t receive the echo of its own change — it already gets the action’s HTTP response:

def send_message(body:)
  msg = ChatMessage.create!(room: @room, body:)
  ChatMessage::Item.broadcast_append_to("chat", @room,
    target: "messages", model: msg, exclude: reactive_connection_id)
end


166
167
168
# File 'lib/phlex/reactive/component.rb', line 166

def reactive_connection_id
  Phlex::Reactive.current_connection_id
end

#reactive_field(param, **attrs) ⇒ Object

Bind a form control’s ‘name` to an action param so its value travels with the action — instead of hand-writing the magic `name: “value”` on every input and silently getting no params when you forget it (issue #23). Returns a Phlex attributes hash to spread onto any control:

input(**reactive_field(:value, value: @record.name))
select(**reactive_field(:status)) { ... }

Extra attrs merge over the binding; an explicit name: still wins (escape hatch). The trigger (on(:save)) stays on the button, not the field — so focusing the input doesn’t dispatch and collapse edit mode.



218
219
220
# File 'lib/phlex/reactive/component.rb', line 218

def reactive_field(param, **attrs)
  {name: param.to_s, **attrs}
end

#reactive_input(param, **attrs) ⇒ Object

Render an <input> already bound to an action param (issue #23). Sugar for input(**reactive_field(param, **attrs)); the value/type/etc. pass through.

reactive_input(:value, value: @record.name, type: "text")


225
226
227
# File 'lib/phlex/reactive/component.rb', line 225

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

#reactive_select(param, **attrs, &block) ⇒ Object

Render a <select> bound to an action param (issue #23). The options block is the element’s content, so the awkward FormBuilder positional split (where name: lands after the options/html-options args) goes away:

reactive_select(:status) { @statuses.each { |s| option(value: s, selected: s == @record.status) { s } } }


233
234
235
# File 'lib/phlex/reactive/component.rb', line 233

def reactive_select(param, **attrs, &block)
  select(**reactive_field(param, **attrs), &block)
end