Examples

Example: live payment split (sum-to-total rebalancer)

Three amounts must always add up to a total. Editing one rebalances the others — the pattern behind a live invoice split, a budget allocator, or any “these fields must sum” form. It comes in two flavors: a server round-trip that recomputes and morphs in place, and a client-side compute that rebalances INSTANTLY with the server reply reconciling behind it.

Try it — a client-side compute#

Before the payment-split walkthrough, here's the reactive_compute + reactive_text shape in its smallest form: a live post preview. Type in the field — the heading mirrors the title and the counter recomputes in the browser with no round trip (an input->reactive#recompute runs the preview JS reducer). Click Save and the server persists and re-renders, seeding the identical derived text — the one-math-two- sites contract reactive_compute exists to enforce.

Type here to preview

20/80Saved: Type here to preview
app/components/post_preview_component.rb
# frozen_string_literal: true

# reactive_text + typed-compute (issue #104) — a live title preview and character
# counter that update IN-BROWSER as you type, with NO round trip, then a save
# reconciles through the server.
#
# Two things mirror the `title` field client-side:
#   * The preview heading is an IDENTITY MIRROR — reactive_text(:title) with no
#     reducer output. The declared input's raw string syncs to its text node on
#     every `input`, so the heading tracks the field with zero reducer wiring.
#   * The character counter is a reducer OUTPUT written to a text node — the
#     "preview" reducer (typed inputs { title: :string }) returns
#     { char_count: `${title.length}/80` }, which has no matching form field, so it
#     lands on the [data-reactive-text="char_count"] node via textContent.
#
# save fires the reactive `save` action; the server persists the title and
# re-renders, SEEDING the same derived heading + counter so the morph repaints
# them from the authoritative value (the reconcile contract reactive_compute
# documents — the server render must seed what the reducer would).
class PostPreviewComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

  reactive_record :todo

  # Typed inputs (hash form): `title` is read RAW (a :string), never coerced
  # through Number. The lone output — char_count — has no form field, so it
  # resolves to the [data-reactive-text="char_count"] node.
  reactive_compute :preview,
                   inputs: { title: :string },
                   outputs: %i[char_count]

  action :save, params: { title: :string }

  def initialize(todo:)
    @todo = todo
  end

  def id = dom_id(@todo, 'preview')

  # Persisted path: save the title, then re-render self. The re-render seeds the
  # SAME derived preview + counter the JS reducer would, so the morph reconciles.
  def save(title:)
    @todo.update!(title:) if title.present?
    reply.replace
  end

  def view_template
    div(**mix(reactive_root(class: 'flex flex-col gap-3'), reactive_compute_attrs(:preview))) do
      # The live preview heading — an identity mirror of `title`. Seed it with the
      # server's current value so the first paint (and any morph) is correct.
      h3(class: 'text-lg font-semibold', data: { testid: 'preview' }) do
        reactive_text(:title, @todo.title)
      end

      # The character counter — a reducer output written to this text node. Seed it
      # server-side too (same contract) so a morph doesn't repaint stale text.
      small(class: 'opacity-60', data: { testid: 'counter' }) do
        reactive_text(:char_count, char_count(@todo.title))
      end

      # The title field drives BOTH: the client recompute/mirror on `input` (no
      # round trip) AND, on the save button, the reactive save action. mix so the
      # recompute action deep-merges with reactive_field's data: (Never-Do #8).
      input(**mix(
        reactive_field(:title, value: @todo.title, type: 'text',
                               class: 'input input-bordered', data: { testid: 'title' }),
        { data: { action: 'input->reactive#recompute' } }
      ))
      button(**mix(on(:save), class: 'btn btn-sm btn-primary w-fit', data: { testid: 'save' })) { 'Save' }

      # A SERVER-ONLY echo of the PERSISTED title (a plain text node, NOT a
      # reactive_text mirror) — the client never touches it, so it's the barrier
      # that a save round trip actually reconciled.
      span(class: 'text-xs opacity-50', data: { testid: 'saved' }) do
        plain 'Saved: '
        span { @todo.title }
      end
    end
  end

  private

  # The server twin of the JS reducer's counter — seeds the same derived string on
  # render so the morph reconciles from the authoritative value.
  def char_count(title) = "#{title.to_s.length}/80"
end

Two flavors of the same rebalance#

The rebalance is one calculation — three amounts spill their difference across each other so they always sum to the total. Where that calculation runs is the choice:

  • Server round-trip — every edit fires an action (on(:rebalance, event: "change")); the server recomputes and reply.morphs the reconciled numbers in place. One source of truth, no client math.
  • Client-side computereactive_compute runs a JS reducer on input, writing the peers with no round trip, while a debounced POST reconciles from the server. The compute just paints first.

This page shows the server variant first (it is the simplest to reason about), then the compute variant that makes it feel instant. The math is identical — that lockstep is the whole contract.

What the server variant demonstrates#

  • Auto-collected siblings — editing one field fires one action that receives the current value of every field of the reactive root, read at dispatch time.
  • Model-scoped (bracketed) params — the inputs are named split[allowance], so the action schema nests under split: to match.
  • A disabled computed field — the total is disabled yet still collected and read (unlike a native form submit).
  • Transient compute, no persist — the action recomputes and re-renders in place; nothing is saved or broadcast.

The server-variant component#

Each amount is a change-triggered rebalance. The bracketed name: is one place a manual name beats the reactive_field sugar — see the note below.

app/components/payment_split.rb
# app/components/payment_split.rb
class PaymentSplit < ApplicationComponent
  include Phlex::Reactive::Component

  FIELDS = %i[allowance cash leasing].freeze

  reactive_state :allowance, :cash, :leasing, :total
  action :rebalance, params: {
    changed: :string,
    split: { allowance: :integer, cash: :integer, leasing: :integer, total: :integer }
  }

  def id = "payment-split"

  # `split` is the bracket-expanded, coerced inner hash; `total` rides in
  # it even though its field is disabled. `apply`/`reconcile` are YOUR
  # own methods (assign the fields, then spill the difference into the
  # peers) — the gem only hands you reply.morph.
  def rebalance(changed:, split:)
    apply(split)
    reconcile(changed.to_sym)
    reply.morph                    # no persist, no broadcast
  end

  def view_template
    div(**reactive_root) do
      FIELDS.each do |field|
        # change-triggered; the field name is bracketed so it nests under split:
        input(**mix(on(:rebalance, event: "change", changed: field.to_s),
                    type: "number", name: "split[#{field}]", value: send(field), min: 0))
      end
      # disabled, but still collected + read by the action (#66)
      input(type: "number", name: "split[total]", value: @total, disabled: true)
    end
  end
end
apply / reconcile are YOUR methods
Neither is gem API. apply assigns the collected split onto your state and reconcile is your pure rebalance math (spill the difference into the peers). The gem gives you reply.morph; the calculation is ordinary Ruby you write.

The nested schema mirrors the field names (#67)#

A standard Form(model: @invoice) names its inputs invoice[amount]. The client posts those names verbatim and the endpoint bracket-expands them before matching your schema, so the schema must nest to match:

# ✅ nested — matches the bracketed field names
action :rebalance, params: { split: { allowance: :integer, cash: :integer,
                                       leasing: :integer, total: :integer } }

# ❌ flat — matches NOTHING; the action silently gets its keyword defaults
action :rebalance, params: { allowance: :integer, cash: :integer }
A flat schema drops bracketed names silently
There is no error — the top-level key after expansion is split, so a flat allowance: key never matches and the action runs with defaults.

The total is disabled — and still read (#66)#

Reactive field collection includes disabled controls, deliberately unlike a native <form> submit. That is what lets a read-only computed field (here the total) travel to the action. Give the control no name, or make it readonly instead of disabled, if you want native-form parity.

The compute variant below adds a remaining output with no matching field — the reducer writes it into a live reactive_text node (text content, XSS-safe) with no round trip, so a derived read-out never needs the server at all.

Live as you type: input + debounce#

The component above triggers on event: "change", which fires on blur/commit — the rebalance lands when you leave the field. For a truly live feel, trigger on input and debounce so it recomputes as the user types:

# Recompute while typing, but coalesce the POSTs: one request 300ms
# after the last keystroke, not one per character.
input(**mix(on(:rebalance, event: "input", debounce: 300, changed: field.to_s),
            type: "number", name: "split[#{field}]", value: send(field), min: 0))

Because the round-trip is now firing on every pause, show that it is in flight. Every trigger and the root already carry data-reactive-busy (and aria-busy) while a request is out — style them, or add a scoped busy_on(:rebalance) spinner and a disable_with: hint:

# Scoped spinner: only lit while :rebalance is in flight.
span(**busy_on(:rebalance), class: "loading loading-spinner loading-xs hidden")

# Or a declarative per-trigger loading hint on the field itself.
input(**mix(on(:rebalance, event: "input", debounce: 300, loading: { class: "opacity-50" }),
            type: "number", name: "split[#{field}]", value: send(field)))

Instant, no round trip: the client-side compute (#75/#104)#

For a NEW, unsaved invoice the running total should feel instant. reactive_compute declares a client-side reducer that runs on input and writes the derived amounts straight into the DOM — no round trip. When the component also carries on(...), the debounced POST still fires and the server reply reconciles; the compute just paints first.

app/components/payment_split.rb
# app/components/payment_split.rb — the client-side compute variant
class PaymentSplit < ApplicationComponent
  include Phlex::Reactive::Component

  FIELDS = %i[allowance cash leasing].freeze

  reactive_state :allowance, :cash, :leasing, :total
  action :rebalance, params: {
    split: { allowance: :integer, cash: :integer, leasing: :integer, total: :integer }
  }

  # Client-side reducer (registered in JS under this name). It reads the
  # named inputs and WRITES the outputs with no round trip — the debounced
  # POST reconciles afterwards from reply.morph.
  reactive_compute :payment_split,
    inputs:  %i[allowance cash leasing total],       # fields the JS reducer reads
    outputs: %i[allowance cash leasing remaining]    # 3 fields + one text node

  def id = "payment-split"

  def rebalance(split:)
    apply(split)                     # your own: assign the live fields
    reconcile(:allowance)            # your own: pure rebalance math
    reply.morph                      # server reconciles the client paint
  end

  def view_template
    # Spread reactive_compute_attrs ALONGSIDE reactive_root so the generic
    # controller finds the reducer and the named fields inside this root.
    div(**mix(reactive_root, reactive_compute_attrs(:payment_split))) do
      FIELDS.each do |field|
        # `input`-triggered + debounced: recompute on the client every
        # keystroke, POST 300ms after the last one.
        input(**mix(on(:rebalance, event: "input", debounce: 300),
                    type: "number", name: "split[#{field}]", value: send(field), min: 0))
      end
      # A live text output the reducer writes — no name, never POSTed.
      # Seeded with the same value the reducer would produce.
      span { plain "Remaining: " }
      reactive_text(:remaining, "0")
    end
  end
end

Register the reducer once at boot. Its signature is (values, { changed }); branching on changed is what makes a multi-way, mutual rebalance expressible as one reducer (#75):

app/javascript/application.js
import { setComputeReducer } from "phlex/reactive/compute"

// The JS twin of the Ruby rebalance — keep the two in lockstep.
// The edited `allowance` keeps its value; cash absorbs the remainder;
// an over-total edit caps allowance and zeros the peers. `remaining`
// is a text-node output (no matching field) written straight to the DOM.
setComputeReducer("payment_split", ({ allowance, total }) => {
  if (allowance >= total) return { allowance: total, cash: 0, leasing: 0, remaining: 0 }
  const cash = total - allowance
  return { allowance, cash, leasing: 0, remaining: total - allowance - cash }
})
Seed the same value the reducer would
The server render must seed each output (reactive_text(:remaining, "0"), the field values) with the same derived number the reducer produces — otherwise a later morph repaints stale text. That lockstep is the reconcile contract the whole new-vs-persisted split relies on.

A recap outside the root: cross-root mirrors (#159)#

reactive_text is deliberately root-isolated (issue #15): a node outside the computing component's reactive root is invisible to it. But the split's numbers often need to show up in a read-only recap that isn't inside the editor at all — a summary tab pane, a sticky footer total. Instead of collapsing two components into one form-wide root, the component declares the escape:

reactive_compute :payment_split,
  inputs:  %i[allowance cash leasing total],
  outputs: %i[allowance cash leasing],
  mirror:  { cash: "#summary-cash", total: "#summary-total" }

On every compute pass, each declared mirror name is painted into its document-wide id target(s) via textContent. The value comes from wherever the pass produced it — a reducer-result key (an extra, text-only output), a just-written output's settled field value, or a declared input's identity value (which works with no reducer at all). A name the pass produced no value for is skipped — a mirror never blanks a recap.

Declared, not arbitrary
Only the selectors in the mirror: map are ever written, and they must be single id selectors — a class/attribute/compound selector raises at declare time and is re-refused by the client interpreter (two-sided default-deny). Writes are textContent only, never innerHTML — same XSS-safe contract as reactive_text. For a server-driven cross-root paint, use reply.js(js.text("#summary-cash", cash, global: true)) instead.

Record for auth, compute over live values (#64)#

This demo is state-backed for simplicity. In a real app, swap reactive_state for reactive_record :invoice and authorize the row — the record is identity + authorization only, while the action computes over the collected params and returns a partial update, persisting nothing:

reactive_record :invoice   # identity + auth ONLY
action :rebalance, params: { split: { allowance: :integer, cash: :integer,
                                       leasing: :integer, total: :integer } }

def rebalance(split:)
  authorize! @invoice, :update?          # identity is not permission
  apply(split)                           # your own: assign the live fields
  reconcile(:allowance)                  # your own: pure rebalance math
  reply.morph                            # re-render in place — no persist, no broadcast
end
Deliberately no broadcast — peer tabs may have their own in-flight edits that must not be clobbered. Broadcasting is always opt-in.

Notes#

  • reply.morph re-renders through Idiomorph, so the field you are editing keeps its caret while the peers update to their reconciled numbers.
  • The changed: param (an explicit on(:rebalance, changed: field)) tells the server which field was edited — explicit params ride alongside collected fields. The reducer gets the same name in its { changed } meta.
  • The bracketed name: "split[allowance]" is hand-written on purpose: reactive_field(:value) binds a control to a flat param, but the model-scoped bracket is exactly the case where the manual name: is clearer than the sugar.
  • The rebalance is pure Ruby over the params — the same shape works whether the numbers come from signed state, a re-found record, or the client reducer.