---
title: "Reactivity Rule Catalogue (S0-4)"
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Reactivity Rule Catalogue (S0-4)

The top-20 reactive form patterns, catalogued from Filament v4 (`visibleWhen`/`requiredWhen`/`disabledWhen`/`live`/`afterStateUpdated`/`dependentOptions`) and Laravel Nova (`dependsOn`, `readonly`, `showWhen`), classified against the Adonia JsonLogic subset (the reactivity contract).

**Classification buckets:**

| Bucket | Meaning |
|---|---|
| `jsonlogic` | Compiles to the declarative JsonLogic subset; evaluated client-side (cosmetic) and mirrored server-side (§11.2). |
| `capability-url` | Needs server data → a capability URL under `urls` (§9.2), called with current form state. |
| `registry-transform` | Declarative `sets` entry with a named `fn:*` transform registered on **both** sides (server for fill recompute, client for live preview). |
| `server-closure` | Not expressible declaratively in v1 → server closure; validation closures run at submit (always server-side anyway), visibility closures force `refetchSchema: true`. |

Spike evidence: every row below is executable in `spikes/jsonlogic-reactivity/src/catalogue.check.ts` (23 fixtures, 39 cases, 0 failures); the wire JSON lives in `docs/reactivity/fixtures/`.

## The 20 patterns

| # | Pattern | Example (Filament/Nova) | Bucket | Adonia compile |
|---|---|---|---|---|
| 1 | show-when-equals | show `publishedAt` when `status = published` | `jsonlogic` | `visibleWhen('status','published')` → `{ "===": [{"var":"status"}, "published"] }` |
| 2 | show-when-not-equals | show `cancelReason` when `status ≠ active` | `jsonlogic` | `when('status').notEquals('active')` → `{ "!==": … }` |
| 3 | show-when-in | show shipping fields when `type ∈ {physical, digital}` | `jsonlogic` | `when('type').in([…])` → `{ "in": [{"var":"type"}, […]] }` |
| 4 | show-when-not-in | hide internal notes from customer/guest roles | `jsonlogic` | `when('role').notIn([…])` → `{ "!": [{ "in": … }] }` |
| 5 | show-if-checkbox | "Other" checkbox reveals a text field | `jsonlogic` | `when('other').truthy()` → `{ "var": "other" }` (bare var; consumers Boolean it) |
| 6 | multi-condition-and | show coupon when `type = discount ∧ amount > 100` | `jsonlogic` | `when.all(…, …)` → `{ "and": […] }` |
| 7 | multi-condition-or | show archive warning when `status ∈ {archived, deleted}` via OR | `jsonlogic` | `when.any(…, …)` → `{ "or": […] }` |
| 8 | negated-composite | show public note when `¬(internal ∨ draft)` | `jsonlogic` | `when.not(when.any(…))` → `{ "!": [{ "or": … }] }` |
| 9 | numeric-threshold | show bulk discount when `quantity ≥ 10` (boundary pinned) | `jsonlogic` | `when('quantity').gte(10)` → `{ ">=": … }` |
| 10 | required-when-equals | `companyName` required when `accountType = business` | `jsonlogic` | `requiredWhen(...)` → same grammar; **mirrored server-side (§11.2)** |
| 11 | required-unless | `vatId` required unless `country = US` | `jsonlogic` | `when.not(when('country').equals('US'))` |
| 12 | disabled-when / readonly-when-editing | slug locked once published; slug readonly on edit | `jsonlogic` (+ static) | dynamic: `disabledWhen(...)` (dynamic rule); static: `readonlyOn('edit')` at compile time, no rule |
| 13 | sets/slugify derivation | `title.live()` → `slug` slugified | `jsonlogic` (sets) | `sets: [{ "target":"slug", "transform":"slugify" }]`; server recomputes (R6) |
| 14 | sets case-normalization | SKU uppercased as typed | `jsonlogic` (sets) | `transform: "uppercase"` / `"lowercase"` |
| 15 | copy-on-checkbox | "billing same as shipping" copies fields while checked | `jsonlogic` (sets) | `sets: [{ "target":"billingStreet", "transform":"copy", "source":"shippingStreet", "if": {"var":"sameAsShipping"} }]` |
| 16 | dependent select options refetch | country → states | `capability-url` | `refetch: { "url":"urls.options", "withState":["country"] }` + `GET field.options` (§10.1) |
| 17 | searchable relation options | belongsTo author, search-as-you-type | `capability-url` | `urls.options` on the node; server `exists` validation (§11.1) |
| 18 | conditional default on change | changing ticket `type` re-defaults `priority` | `registry-transform` | `sets: [{ "target":"priority", "transform":"fn:priorityForType", "source":"type" }]` |
| 19 | computed sum across repeater rows | invoice total = Σ line-item prices | `registry-transform` | `fn:sumPrices` receives the whole `items` array; JsonLogic arithmetic/map/reduce stay **out** of v1 |
| 20 | cross-field date validation | `endDate ≥ startDate` | `server-closure` | `rules(v => …)` closure; enforced at submit, always server-side; client pre-validation optional/cosmetic |

## Pattern details and edge semantics

- **1–9 (visibility conditions)** — all compile through the `when()` DSL; none require server round-trips. Bare-var truthiness (#5) pins JS truthiness: `false|null|0|""|NaN` falsy; **empty arrays/objects are truthy** (PHP semantics do NOT apply).
- **9 (numeric threshold)** — relational semantics are null-safe: a `null` operand (absent/empty state) makes the comparison **false**, never coerced to 0 (kills the `null <= 10` footgun). Two numbers compare numerically; two ISO-8601 strings order lexicographically (dates work). Mixed number/string comparisons are a compiler dev-warning (runtime coerces and warns). `F.number` SHOULD still dehydrate empty state to `null` rather than `""`.
- **10–11 (requiredWhen)** — the §11.2 mirror: the server re-evaluates the rule against the submitted state and enforces requiredness even when the client UI was bypassed. Fixtures `10-*`/`11-*` pin `requires` exactly.
- **12 (disabled)** — `disabledWhen` true ⇒ never required, never filled; a tampered submission is shape-validated but its value dropped (R5). Static `readonlyOn('edit')` is a compile-time projection concern, no rule on the wire.
- **13–15 (sets)** — `sets` derivations are always recomputed server-side from submitted source values (R6); the client preview is cosmetic, so a tampered `slug` in the payload is overwritten by the derivation (fixture `13-*`). `source` defaults to the live field itself; `if` guards the derivation (both are the reactivity contract to §9.3).
- **16–17 (capability URLs)** — no JsonLogic at all; state-dependence is explicit (`withState`) because closures are not introspectable. `dependentOptions(fn, { dependsOn: ['country'] })` (state dependency list) → `withState`.
- **18–19 (registry transforms)** — `fn:<name>` resolves from `registry.transforms` on both sides; unregistered names fail closed (derivation skipped + dev warning), never `eval`. Aggregation transforms receive plain dotted-path sources (no `items.*.price` wildcards — the whole array is passed, the transform aggregates).
- **20 (cross-field validation)** — validation is always server-enforced, so a closure here costs nothing; only *client-live* cross-field validation would need the subset, deferred.

## Considered and deferred (not in the 20)

| Pattern | Why deferred |
|---|---|
| Condition on repeater row **count** (`items.length > 3`) | No length operator in the subset; v2 candidate (`count` op) or `fn:` rule names. Workaround: server closure + `refetchSchema`. |
| Regex matching inside conditions | No `match`/`test` op in v1; static `mask`/`regex` validation covers the common case. |
| Record-dependent visibility on edit (e.g. "hide if record locked") | Per-record ⇒ §9.4 cache bypass subtree + `refetchSchema`; deliberately not a form-state rule. |
| Arithmetic in conditions (`total > creditLimit`) | Needs a derived value → `fn:` transform on a `sets` target, then a plain `>` condition on that target. |
| Wizard/step visibility | Not a separate mechanism: `visibleWhen` attaches to **any** schema node (layout included), same grammar. |

## Repeater-item scoping (interlocks with protocol v1)

`var` paths are **absolute** against the root form state. Inside a repeater item subtree, `./`-prefixed paths (`{"var":"./label"}`) are item-relative against the row sub-state. No relative-first fallback. The v1 fixture corpus is flat; per-row mirroring fixtures are a v1.1 extension (see mirroring-test-design §Matrix, row R-7).

Source: https://adonia.pages.dev/reactivity/catalogue/index.mdx
