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/80

Save

Saved: 

Type here to preview

```ruby
# 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.morph`s the reconciled numbers in place. One source of truth, no client math.
- **Client-side compute** — `reactive_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.

```ruby
# 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:

```ruby
# ✅ 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:

```ruby
# 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:

```ruby
# 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.

```ruby
# 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):

```javascript
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**:

```ruby
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:

```ruby
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
```

> **Tip:** 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.