---
title: "Reactivity, server side (§9.3 / §11.2)"
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, server side (§9.3 / §11.2)

What an author writes, what rides the wire, and what the server enforces at
submit. The client half is `docs/form-engine.md`; the rule vocabulary is
the reactivity contract and `docs/reactivity/catalogue.md`.

The rule of the whole subsystem: **client evaluation is cosmetic**. Every
declarative rule that affects visibility or requiredness is re-evaluated
server-side against the submitted state, and every `sets` derivation is
recomputed there, so a bypassed UI cannot change what is validated or
persisted.

## The `when()` DSL

`when(path)` opens a condition on a flat state key (dotted for JSON columns,
`./`-prefixed inside a repeater row). Every terminal method returns plain
JsonLogic — the exact JSON the fixtures pin — so rules compose, nest, and can
be stored in a constant.

```ts
import { F, when } from '@adonia/core'

F.datetime('publishedAt')
  .visibleWhen(when('status').is('published'))
  .requiredWhen(when('status').is('published'))

F.text('coupon').visibleWhen(
  when.all([when('type').is('discount'), when('amount').gt(100)])
)

F.text('vatId').requiredWhen(when.not(when('country').is('US')))
```

<!-- @sample-preamble
import { F, when } from '@adonia/core'
-->

| Builder | Emits |
| --- | --- |
| `.equals(v)` / `.is(v)` | `{"===": [{"var": path}, v]}` |
| `.notEquals(v)` / `.isNot(v)` | `{"!==": …}` |
| `.in([…])` | `{"in": [{"var": path}, […]]}` |
| `.notIn([…])` | `{"!": [{"in": …}]}` |
| `.gt/.gte/.lt/.lte(v)` | `{">": …}`, … — null-safe, never coerced to `0` |
| `.contains(v)` | `{"in": [v, {"var": path}]}` (state is the haystack) |
| `.truthy()` / `.isTruthy()` | bare `{"var": path}` — consumers `Boolean()` it |
| `.falsy()` / `.isFalsy()` | `{"!": [{"var": path}]}` |
| `when.all(…)` / `when.any(…)` | `{"and": […]}` / `{"or": […]}` (array or variadic) |
| `when.not(rule)` | `{"!": [rule]}` |
| `when.ref(path)` | `{"var": path}` — the operand form, for cross-field comparisons |

The field shorthand is unchanged and compiles identically:
`visibleWhen('status', 'published')` ≡ `visibleWhen(when('status').is('published'))`,
and an array matcher ≡ `.in([...])`. A raw rule object still passes through.

Comparisons take a second rule as their operand, which is how a cross-field
condition is expressed without leaving the subset:

```ts
F.text('overlapReason').requiredWhen(when('endDate').lt(when.ref('startDate')))
```

`packages/core/tests/when_dsl.spec.ts` re-emits every reactive block in
`docs/reactivity/fixtures/` from DSL calls and compares it byte-identically
with the committed JSON, and fails if a fixture block has no DSL spelling.

## The closure fallback

A closure passed where a rule was expected cannot be serialized. The field is
flagged `reactive.refetchSchema: true` — it stays validated and fillable, and
the client refetches the descriptor after the state settles. Outside
production the descriptor compiler warns through the app logger (`module:
'adonia'`, §20) naming the resource, the field and the offending call:

```
resource "posts", field "legacy": visibleWhen(<closure>) passed a server
closure, so the node is flagged refetchSchema:true and the form refetches
its descriptor after every state change. Express the condition with the
when() DSL to stay on the fast path.
```

## `sets` derivations, server side

A client that never ran a derivation still submits, and a hostile one submits
whatever it likes for the target. So the server recomputes every entry from
the submitted sources and overlays the result onto the validated payload for
targets that are in the fill set (R6). A tampered `slug` loses.

```ts
F.text('title').live().sets('slug', 'slugify')

F.toggle('sameAsShipping').live(0).sets('billingStreet', 'copy', {
  source: 'shippingStreet',
  if: when('sameAsShipping').isTruthy(),
})
```

`source` defaults to the declaring field; `if` guards the entry. Built-ins are
`slugify`, `lowercase`, `uppercase` and `copy`, and they MUST agree
character-for-character with the client's copies — the fixture corpus is the
shared oracle.

`fn:<name>` resolves from the server registry:

```ts
import { registerTransform } from '@adonia/core'

registerTransform('sumPrices', (items) =>
  Array.isArray(items)
? items.reduce<number>((total, row) => total + Number((row as { price?: number }).price ?? 0), 0)
: 0
)
```

Register at boot (a provider `boot()` hook or a plugin). Transforms MUST be
pure — they run on every submit, and the client runs its own copy on every
keystroke.

**An unresolvable `fn:*` fails compilation**, naming the resource, field,
target and transform. The client can only warn and skip; the server can fail
loudly, and it should: silently persisting the un-derived value is the one
outcome nobody can debug from the outside.

## What the validator does with a rule

`compileValidator` folds R1–R6 into one Vine schema (see
`packages/core/src/validation/validator_compiler.ts`):

- **hidden ⇒ absent.** `visibleWhen` false deletes the key before Vine runs
  and drops it from the validator key set and the fill set (R3).
- **required ⇒ mirrored.** A simple equality becomes Vine's native
  `requiredWhen(field, '=', value)`. Any richer shape — composite `and`/`or`/
  `!`, a numeric threshold, a cross-field comparison — becomes a
  complementary `vine.group` pair, so the key is shape-validated whether or
  not it is required.
- **disabled ⇒ never required, never filled.** `disabledWhen` folds INTO the
  requiredness rule rather than sitting beside it, so a disabled input cannot
  be demanded (R5).
- **derived ⇒ recomputed.** The `sets` overlay is applied last, after
  validation (R6).

## Conformance

`packages/core/tests/mirroring.spec.ts` runs the WHOLE corpus in
`docs/reactivity/fixtures/` — every case, in both modes unless the case pins
one — asserting exact validator/required/fill key sets, that a hidden key
survives neither the validator nor the fill, that a mirrored requirement
holds when the UI is bypassed, and that derivations match. The `@adonia/ui`
parity suite reads the same files. A new catalogue pattern ships its fixture
in the same PR (plan §5) and is enrolled automatically.

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