Skip to content

7. Reactivity

Three behaviours turn the form from a list of inputs into something that reacts: the slug writes itself, Published at only exists for published posts, and a subtitle appears for the one post kind that uses it. All three are declared, all three are enforced twice.

live() and sets

F.text('title').label('Title').required().maxLength(200).live().sets('slug', 'slugify')

examples/blog-admin/app/adonia/resources/post_resource.ts

live(debounceMs = 300) marks the field as one whose changes re-run the reactive graph as you type. sets(target, transform) declares a derivation: each keystroke in title rewrites slug. The built-in transforms are slugify, lowercase, uppercase and copy; fn:<name> reaches a transform you registered, and an unresolvable name fails compilation rather than silently doing nothing.

On the wire the rule rides beside props, never inside it:

{
  "type": "text-input",
  "key": "title",
  "props": { "label": "Title", "required": true, "maxLength": 200 },
  "reactive": {
    "live": { "debounceMs": 300 },
    "sets": [{ "target": "slug", "transform": "slugify" }]
  }
}

The server re-runs every derivation on submit and overwrites whatever the client sent for the target. Post title=Reactivity, Chapter Eight! with slug=client-tampered-slug and the row stores reactivity-chapter-eight: the client’s copy of the rule is a convenience, not the authority.

Re-derivation happens after validation, so sets is not a defaulting mechanism — a required target still has to arrive. And because it runs on every submit, editing a title would move a published URL. The fix is the fill set, not a condition:

F.text('slug').label('Slug').required().maxLength(220).unique().readonlyOn('edit')

readonlyOn('edit') renders and validates the field but leaves it out of the fill set for that mode: the derivation still runs, and the column does not move.

visibleWhen / requiredWhen

F.datetime('publishedAt')
  .label('Published at')
  .visibleWhen('status', 'published')
  .requiredWhen('status', 'published')

with F.select('status')…live() driving it. The matcher compiles to JsonLogic:

{ "visibleWhen": { "===": [{ "var": "status" }, "published"] } }

A bare value becomes ===, an array becomes in, and a when(…) expression passes through — when.all, when.any, when.not, gte, contains, isTruthy and the rest compose without a server round trip. A closure matcher is allowed but leaves the declarative path: it forces a descriptor refetch.

The mirror is what matters. While status is not published:

  • the client hides the field, and
  • the server prunes the key before validation, omits it from the validator, and omits it from the fill set.

So a client that submits publishedAt anyway changes nothing:

curl -X POST /admin/posts -d 'status=draft&publishedAt=2020-01-01T00:00:00.000Z&…'

stores published_at = NULL. Flip status to published and the same key becomes required — enforced by the compiled validator, not by the widget:

{ "publishedAt": "The publishedAt field must be defined" }

subtitle in the tutorial app is the same rule with a deliberately silly trigger, so you can watch it happen: type special into Title and the field appears; save with anything else in Title and a submitted subtitle is discarded.

The rest of the catalogue

The twenty patterns Adonia supports — and the exact JsonLogic each compiles to — are in the reactivity catalogue, with a JSON fixture per pattern that the server and client test suites both run. The four mechanisms are worth knowing by name:

Mechanism Example Cost
JsonLogic conditions visibleWhen, requiredWhen, disabledWhen none — evaluated in the browser
sets derivations slugify, uppercase, copy none
Capability URLs dependent options, searchable relations one JSON request
Server closures rules(v => …) cross-field checks submit-time only

Prefer them in that order. Everything above the line is mirrored automatically; everything below it is server-side by definition.

Next: theming and tests.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close