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.

fade

slide

scale

highlight

shake

random

Flash (update: fade)

Add row (enter: fade)

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

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

| Hook | Stream actions | When it runs |
| --- | --- | --- |
| `enter` | `append`, `prepend` | after render, on the arriving element |
| `exit` | `remove` | **before** the element leaves — the removal waits for the animation |
| `update` | `replace`, `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:

```erb
<%= 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:

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

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

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