Guide

Effects

Make the reactivity visible: rows fade in and out of existence and updates flash — with zero app JS.

Try it#

Pick a style, then Flash the card (an update effect on a reply.replace), Add row (an enter effect riding the append), and dismiss a row (its exit runs before the element leaves the DOM). Every trigger here uses the per-call effect: form with the picked style held in signed state.

app/reactive_components/effects_gallery_component.rb
# frozen_string_literal: true

# The live effects gallery (issue #215). Pick a style, then trigger each hook:
# "Flash" re-renders the gallery with a per-call update effect, "Add row"
# appends a row whose arrival animates (per-call enter), and each row's ×
# animates its exit. Everything here is the PER-CALL form — reply.replace(
# effect:), Row.append(..., effect:), reply.remove(effect:) — with the picked
# style held in signed state; the Effects guide shows the global + per-class
# declarations the rest of the site's demos use.
class EffectsGalleryComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

  STYLES = EffectsGalleryRowComponent::STYLES

  reactive_state :style, :next_n

  action :set_style, params: { style: :string }
  action :flash_card
  action :add_row

  def initialize(style: 'fade', next_n: 1)
    @style = STYLES.include?(style) ? style : 'fade'
    @next_n = next_n
  end

  def id = 'effects-gallery'

  # The style param is CLIENT input — whitelist it before it ever reaches an
  # effect: kwarg (an unknown name would raise server-side by design).
  def set_style(style:)
    @style = style if STYLES.include?(style)
    reply.replace
  end

  def flash_card = reply.replace(effect: @style.to_sym)

  def add_row
    stream = EffectsGalleryRowComponent.append(
      target: 'effects-gallery-rows', n: @next_n, style: @style, effect: @style.to_sym
    )
    @next_n += 1
    # No self re-render (a replace would clobber the rows already added) — the
    # token-only refresh keeps repeated adds verifying.
    reply.streams(stream)
  end

  def view_template
    div(**reactive_root(class: 'flex flex-col gap-4', data: { testid: 'effects-gallery' })) do
      style_picker
      div(class: 'flex gap-2') do
        button(**mix(on(:flash_card), class: 'btn btn-sm btn-primary', data: { testid: 'fx-flash' })) do
          "Flash (update: #{@style})"
        end
        button(**mix(on(:add_row), class: 'btn btn-sm', data: { testid: 'fx-add' })) do
          "Add row (enter: #{@style})"
        end
      end
      ul(id: 'effects-gallery-rows', class: 'flex flex-col gap-2')
    end
  end

  private

  def style_picker
    div(class: 'join') do
      STYLES.each do |name|
        button(**mix(on(:set_style, style: name),
                     class: "join-item btn btn-xs #{'btn-active' if name == @style}",
                     data: { testid: "fx-style-#{name}" })) { name }
      end
    end
  end
end

Three opt-in levels#

Most specific wins; off by default.

Effects are off unless you opt in — with them off the rendered wire is byte-identical to previous releases.

# 1. GLOBAL — setting it is the opt-in AND the app-wide default set:
Phlex::Reactive.effects = true   # { enter: :fade, exit: :fade, update: :highlight }
Phlex::Reactive.effects = { enter: :slide, exit: :fade, update: :highlight }

# 2. PER COMPONENT — refine the global set, or opt in standalone:
class Notifications::Row < ApplicationComponent
  reactive_effects enter: :slide, exit: :fade
  # reactive_effects update: false   # disable one hook
  # reactive_effects false           # opt this component out entirely
  # reactive_effects enter: :random  # a random built-in per application
end

# 3. PER CALL — override one stream:
reply.remove(effect: :shake)
reply.append(item, to: :items, effect: :scale)
Item.replace(@todo, effect: false)   # suppress a declared effect once
Row.broadcast_to(@list, :todos, append: todo, target: 'rows', effect: :slide)

A component-level declaration works without the global switch — declaring reactive_effects on a component is that component's opt-in. Unknown effect names raise at class load (never at click time), and the client warns-and-skips anything it doesn't recognize — the same two-sided default-deny as every other wire surface.

The three hooks#

Each hook maps to the Turbo Stream actions that mean "something entered / left / changed":

HookStream actionsWhen it runs
enterappend, prependafter render, on the arriving element
exitremovebefore the element leaves — the removal waits for the animation
updatereplace, update (plain or morph)after render, on the fresh root

Effects fire for the actor's own reply and for broadcasts alike — one interceptor covers both delivery paths, identically on Action Cable and pgbus. If a debounced-save grid flashes too much, turn that one hook off where it's noisy: reactive_effects update: false.

Built-ins (shipped CSS)#

Link the engine stylesheet once:

<%= stylesheet_link_tag "phlex/reactive/effects" %>

Five named effects work on every hook: :fade, :slide, :scale, :highlight (the classic background flash), and :shake. :random picks one per application — every update animates differently (great for demos). Tune them with CSS custom properties:

:root {
  --reactive-fx-duration: 200ms;
  --reactive-fx-highlight-color: rgb(186 230 253 / 0.5);
}

Keep durations comfortably under one second — the client hard-caps any effect wait at 1000 ms, so an exit longer than that is removed mid-animation.

Custom effects (class legs)#

Skip the stylesheet entirely with named class legs — the same { during:, from:, to: } vocabulary js.toggle(transition:) uses, perfect for Tailwind utilities. during classes apply for the whole animation; from swaps to to on the next frame:

reactive_effects enter: { during: %w[transition-all duration-300],
                          from: %w[opacity-0 translate-y-2],
                          to: %w[opacity-100 translate-y-0] }

Exit-before-removal semantics#

A remove with an exit effect keeps the element in the DOM while the animation runs and only then lets Turbo remove it. Three guarantees make that safe:

  • the wait is settled by animationend/transitionend or a timeout just past the computed duration — an interrupted animation can't hang the removal;
  • a zero computed duration (the effects stylesheet isn't loaded, or reduced motion zeroed it) skips the wait entirely — a missing stylesheet_link_tag never freezes deletes;
  • everything is hard-capped at 1000 ms.

Rapid successive updates restart the flash (the class is removed, a reflow forced, and re-added), so a busy row keeps signalling.

Reduced motion & the wire#

prefers-reduced-motion: reduce disables effects twice over: the client interceptor skips them, and the shipped CSS lives inside a no-preference media query — so even a hand-built class can't animate against the user's setting.

On the wire, the resolved hooks ride the component root as data-reactive-effect-enter/exit/update attributes (omitted entirely when off), and a per-call override rides the <turbo-stream> element itself as data-reactive-effect ("off" suppresses). Values are effect names or class lists — identity and presentation, never state, and the client treats them as classList input only.