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|""|NaNfalsy; empty arrays/objects are truthy (PHP semantics do NOT apply). - 9 (numeric threshold) — relational semantics are null-safe: a
nulloperand (absent/empty state) makes the comparison false, never coerced to 0 (kills thenull <= 10footgun). 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.numberSHOULD still dehydrate empty state tonullrather 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-*pinrequiresexactly. - 12 (disabled) —
disabledWhentrue ⇒ never required, never filled; a tampered submission is shape-validated but its value dropped (R5). StaticreadonlyOn('edit')is a compile-time projection concern, no rule on the wire. - 13–15 (sets) —
setsderivations are always recomputed server-side from submitted source values (R6); the client preview is cosmetic, so a tamperedslugin the payload is overwritten by the derivation (fixture13-*).sourcedefaults to the live field itself;ifguards 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 fromregistry.transformson both sides; unregistered names fail closed (derivation skipped + dev warning), nevereval. Aggregation transforms receive plain dotted-path sources (noitems.*.pricewildcards — 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).