Counter

State-backed actions with and without params — the simplest reactive shape.

0
# State rides in the signed token; start from any value:
render CounterComponent.new(count: 0)
# frozen_string_literal: true

# State-backed reactive counter (no DB). The signed token carries `count`; each
# action re-renders the component into its own #id. Demonstrates the simplest
# reactive shape: reactive_state + actions with and without params, plus a few
# reply.* reply styles (a flash, an in-place morph, a redirect).
class CounterComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

  reactive_state :count
  action :increment
  action :decrement
  action :set, params: { count: :integer }
  action :reset_with_flash
  action :bump_via_morph

  def initialize(count: 0)
    @count = count
  end

  def id = 'counter'

  def increment = @count += 1
  def decrement = @count -= 1
  def set(count:) = @count = count

  # Reply: replace self (token refresh) + append a flash.
  def reset_with_flash
    @count = 0
    reply.replace.flash(:notice, 'Reset')
  end

  # Reply: morph the element in place so a focused control would keep focus.
  def bump_via_morph
    @count += 1
    reply.morph
  end

  def view_template
    div(**reactive_root(class: 'flex items-center gap-3')) do
      button(**mix(on(:decrement), class: 'btn btn-sm', data: { testid: 'dec' })) { '−' }
      span(class: 'text-2xl tabular-nums w-10 text-center',
           data: { testid: 'count' }) { @count.to_s }
      button(**mix(on(:increment), class: 'btn btn-sm', data: { testid: 'inc' })) { '+' }
      button(**mix(on(:bump_via_morph), class: 'btn btn-sm btn-ghost',
                                        data: { testid: 'bump-morph' })) { '+1 (morph)' }
      button(**mix(on(:reset_with_flash), class: 'btn btn-sm btn-ghost',
                                          data: { testid: 'reset' })) { 'reset' }
    end
  end
end