Form Effects Reference
This page defines the persisted schema contract and runtime behavior of Form Effects. For a task-oriented introduction, start with Form Effects. For visual editing, see the Form Logic Editor.
Schema Location
Effects are stored in the root schema's effects array:
{
"type": "object",
"properties": {
"firstName": {"type": "string"},
"lastName": {"type": "string"},
"fullName": {"type": "string"}
},
"layout": ["firstName", "lastName", "fullName"],
"effects": [
{
"id": "combine-names",
"listen": ["$value.firstName", "$value.lastName"],
"when": "${$value.firstName != null || $value.lastName != null}",
"do": [
{
"type": "set",
"field": "fullName",
"value": "${[$value.firstName, $value.lastName].filter(x => x).join(' ')}"
}
]
}
]
}
Effect Object
| Property | Required | Type | Description |
|---|---|---|---|
id | Conditional | string | Identifier used for diagnostics and references. Required and unique for an invoked effect. Unique IDs are recommended for every effect. |
name | No | string | Human-readable label used only for display and documentation. |
description | No | string | Longer human-readable explanation. It does not affect execution. |
listen | Conditional | string[] | Paths watched by a value-change effect. Mutually exclusive with trigger. |
trigger | Conditional | EffectTrigger | A widgetEvent or invoked trigger. Mutually exclusive with listen. |
when | No | string | JEXL condition. The effect runs only when it evaluates to boolean true. |
do | Yes | EffectAction[] | Actions executed in array order when the condition passes. |
disabled | No | boolean | Keep the effect in the schema but skip it at runtime. |
runOnInitialization | No | boolean | Run a value-change effect once after initialization settles. Default: false. |
When when is omitted, the condition is treated as satisfied and do runs
after the trigger fires.
An effect is one of three forms:
// Value change
{listen: string[], when?: string, do: EffectAction[]}
// Widget event
{trigger: {type: 'widgetEvent', objectPointer: string, event: string}, do: EffectAction[]}
// Explicit invocation
{id: string, trigger: {type: 'invoked'}, do: EffectAction[]}
Value Contracts
The schema uses three different path and value contracts:
| Contract | Properties | Syntax |
|---|---|---|
| Watched context path | listen[] | Fully prefixed, for example $value.customer.id |
| Literal form-control path | field, from, to | Dot notation without $value or ${...}, for example customer.id |
| JEXL expression | when, expression-backed value, nested scriptData, nested inputs | Wrapped in ${...} |
effectId is a literal ID. objectPointer is a JSON Pointer into the schema.
Neither is a JEXL expression.
Legacy listen values that wrap a simple path in ${...} remain supported,
but new schemas should use direct paths.
Trigger Types
Value Change
A value-change effect has listen and no trigger. It runs when at least one
watched path changes according to deep equality.
{
"id": "recalculate-total",
"listen": ["$value.quantity", "$value.price"],
"do": [
{
"type": "set",
"field": "total",
"value": "${($value.quantity || 0) * ($value.price || 0)}"
}
]
}
An empty or missing listen array never runs as a value-change effect.
Supported listen roots are:
| Root | Watched data |
|---|---|
$value | Current root form value |
$context | Form context |
$outputContext | Output context |
$config | Application/API configuration |
$runtimeInfo | Current user and runtime information |
$version | Application version |
$build | Build metadata |
listen is not inferred from expressions. If an effect reads
$value.customer.id and its change should rerun the effect, include that path
explicitly.
Datasource results are resolved as part of the expressions that call them;
they are not a top-level value-change trigger. Put the datasource call in
when, set.value, or runEffect.inputs and listen to the form or context
values passed as its parameters. Datasource calls inside scriptData are not
resolved by the current Form Effects runtime.
Initialization
The first settled form snapshot normally establishes only the comparison
baseline. runOnInitialization: true opts a value-change effect into exactly
one run after initial values, expression defaults, and their data sources have
settled.
Intermediate default or datasource emissions do not each cause a separate initialization run. The option does not apply to widget-event or invoked effects, and the designer removes it when the trigger type changes.
Widget Event
A widget-event effect listens to one semantic event from one concrete widget or layout node:
{
"id": "remember-opened-panel",
"trigger": {
"type": "widgetEvent",
"objectPointer": "/layout/0",
"event": "panelOpened"
},
"do": [
{
"type": "set",
"field": "lastOpenedPanel",
"value": "${$payload.index}"
}
]
}
| Trigger property | Description |
|---|---|
type | Always widgetEvent. |
objectPointer | Canonical JSON Pointer of the emitting schema node. |
event | Stable event name declared by that widget's plugin definition. |
Only events declared by the widget are accepted at runtime. The Form Designer filters the event selector accordingly and provides known payload members to JEXL autocomplete.
The source selector displays the widget breadcrumb, selector, and pointer. Use Locate widget to select that source in the designer and scroll it into view.
The event context contains:
{
name: string;
payload?: unknown;
objectPointer: string;
selector?: string;
}
Use $payload as the preferred shorthand for $event.payload. Use $event
when event metadata such as name, objectPointer, or selector is required.
For tab and step transitions, prefer stable identifiers such as
$payload.previousId and $payload.activeId when available. Position-based
previousIndex and activeIndex remain available.
objectPointer identifies a schema position rather than a persistent widget
ID. Do not maintain it by hand after structural edits. The Form Designer remaps
widget-event pointers when nodes are moved, inserted, renamed, duplicated, or
deleted.
Explicit Invocation
An invoked effect is a reusable named action block. It runs only through a
runEffect action:
{
"id": "process-customer",
"trigger": {"type": "invoked"},
"when": "${$input.customer != null}",
"do": [
{
"type": "set",
"field": "customerName",
"value": "${$input.customer.name}"
}
]
}
Its non-empty id must identify exactly one effect in the root schema. Values
passed by runEffect.inputs are available through $input.
Action Types
clear
clear changes a control according to its mode. The default mode is empty.
{"type": "clear", "field": "statusDescription", "mode": "empty"}
| Mode | Runtime behavior | Use it when |
|---|---|---|
empty | Scalars become null, arrays lose all items, and each direct child of a group becomes null. The control is dirty, which blocks default reapplication. | The value should stay explicitly blank. |
unset | The value becomes undefined and the control becomes pristine. A computed default may apply later if its result changes. | User input should be discarded and reactive default behavior restored. |
reset | The control becomes pristine and its schema default is resolved and applied immediately. Stored control defaults are used as a fallback. | The value should return to its default now. |
The modes affect form state as well as the visible value. empty is therefore
not interchangeable with unset.
set
set writes one static or evaluated value to a control:
{
"type": "set",
"field": "fullName",
"value": "${[$value.firstName, $value.lastName].filter(x => x).join(' ')}"
}
field is a literal control path. It is not an expression:
| Incorrect | Correct |
|---|---|
"field": "${$value.customer.email}" | "field": "customer.email" |
"field": "$value.customer.email" | "field": "customer.email" |
"field": "customer/email" | "field": "customer.email" |
value can be a static string or a ${...} expression. Expression results may
have any value type accepted by the target control. The runtime writes only
when the resolved value differs from the current value.
{"type": "set", "field": "status", "value": "active"}
{"type": "set", "field": "price", "value": "${$value.quantity * $value.unitPrice}"}
copy
copy reads one form path and deep-copies its value to another:
{"type": "copy", "from": "shippingAddress", "to": "billingAddress"}
Both paths use the same literal dot notation as set.field. Objects and arrays
are cloned so later changes to the target do not mutate the source. Form values
used with copy must remain JSON-compatible.
When an exact target control is not present, the runtime can patch the deepest existing parent control at the remaining nested path.
script
script dispatches a server-side script and can continue with store actions or
form actions after the result arrives:
{
"type": "script",
"scriptCode": "calculate_price",
"scriptData": {"productId": "${$value.productId}"},
"await": true,
"successDo": [
{"type": "set", "field": "price", "value": "${$response.calculatedPrice}"},
{"type": "clear", "field": "errorMessage", "mode": "empty"}
],
"errorDo": [
{"type": "clear", "field": "price", "mode": "empty"},
{"type": "set", "field": "errorMessage", "value": "${$response.error.message || $response.message}"}
]
}
| Property | Required | Type | Description |
|---|---|---|---|
scriptCode | Yes | string | Script code to execute. |
scriptData | No | unknown | Value or nested object containing expressions, evaluated recursively before dispatch. |
hideScriptError | No | boolean | Hide the standard script error presentation. Default: false. |
successActions | No | StoreAction[] | Store-level actions dispatched on success. |
errorActions | No | StoreAction[] | Store-level actions dispatched on error. |
successDo | No | EffectAction[] | Form actions executed on success with $response. |
errorDo | No | EffectAction[] | Form actions executed on error with $response. |
await | No | boolean | Wait for the selected outcome branch before continuing the parent effect. Default: false. |
successActions and errorActions are handled by the application's action
layer. Use them for reloads, messages, navigation, and other store-level UI
behavior. See Success / Error Actions.
scriptData can use ordinary effect context expressions, but not datasource
calls. Resolve datasource-backed values first with set or pass them through a
separate invoked effect.
successDo and errorDo continue the form workflow. They can contain set,
clear, copy, or runEffect. A nested script action is rejected; invoke a
reusable effect instead.
After a response arrives, the matching successActions or errorActions are
dispatched first. The matching successDo or errorDo branch then executes
with $response in scope.
Script ordering
await | Behavior |
|---|---|
true | Wait for the result, execute and await the matching Do branch in the same execution chain, then continue with later parent actions. |
false or omitted | Dispatch the script and continue the parent effect immediately. Execute the matching Do branch later as a detached continuation. |
await controls workflow ordering; it does not block the browser UI thread.
Use it whenever later actions depend on the script outcome. Fire-and-forget is
appropriate only for an independent side operation.
An awaited script has a 60-second timeout. A timeout runs errorDo with:
{
"error": {
"code": "SCRIPT_TIMEOUT",
"message": "Awaited script did not finish before the timeout.",
"scriptCode": "loadCustomer"
}
}
Once a script request has been sent, its success or error continuation is allowed to finish even if a newer form-state emission supersedes the original value-change run.
$response
$response contains parsed response.data when available. A returned failure
payload is preserved; a thrown error is normalized as {error: ...}.
It is available only in the script's direct successDo or errorDo branch.
Standard context values remain available alongside it. If an invoked effect
needs the response, map the required values through runEffect.inputs.
runEffect
runEffect calls one effect declared with trigger.type === 'invoked':
{
"type": "runEffect",
"effectId": "process-customer",
"inputs": {
"customer": "${$response}",
"requestedBy": "${$runtimeInfo.userName}"
}
}
effectId is a literal ID in the same root schema. inputs is evaluated
recursively, including expressions nested in objects and arrays. The resulting
object becomes $input in the invoked effect.
The runtime rejects:
- a missing or ambiguous ID;
- a target that is not an invoked effect;
- a disabled target;
- direct or indirect invocation recursion.
Invocation is an explicit context boundary. $event and $response are not
implicitly inherited by the target.
When an invoked effect ID is renamed in the Form Designer, local
runEffect.effectId references are updated with it.
Expression Context
Effect expressions use the same JEXL runtime as computed form properties, with an effect-specific scope:
| Value | Meaning in a Form Effect |
|---|---|
$value | Current value of the complete root form. |
$context | Form context from the Fluent Forms store. |
$outputContext | Output context from composed widgets and flows. |
$config | Application/API configuration. |
$configUi | Reserved for UI configuration. The current Form Effects runtime supplies {}, so do not depend on it yet. |
$runtimeInfo | Current user and runtime metadata. |
$version | Application version. |
$build | Build metadata. |
$row | Always 0 for root Form Effects. |
dataSources | Registered datasource functions. |
dataSourcesValues | Settled datasource values available to the evaluator. |
$dataSourcesLoading | Whether an expression datasource is still resolving. |
$event | Complete semantic event in a widget-event effect. |
$payload | Shorthand for $event.payload. |
$input | Evaluated input object in an invoked effect. |
$response | Script result in the script's direct outcome branch. |
The stage-specific values are preserved in a script outcome branch belonging
to that same effect. An invoked effect receives only the values explicitly
mapped into $input.
Member access
Author ordinary dot and bracket access in Form Effects expressions:
$value.customer.address.street
$response.items[0].name
The expression compiler adds safe member access. Do not add JavaScript optional
access operators such as ?. to persisted Form Effects expressions. Use a
fallback when the final result can be missing:
$value.customer.address.street || 'N/A'
See the JEXL language reference for operators, functions, transforms, and Public API data sources.
Datasources
Only datasources declared for effect usage can be called from an effect
expression. The current runtime resolves datasource calls in when,
set.value, and runEffect.inputs; it does not resolve them in scriptData:
| Effect mode | Runtime behavior |
|---|---|
plainValue | Resolve a scalar or object value on the first emission. |
identifiedPack | Wait until the returned pack has loading !== true. Read the result from .data. |
unsupported | Reject the datasource in an effect and omit it from effect autocomplete. |
Example using an identifiedPack datasource:
{
"id": "load-user-name",
"listen": ["$value.userId"],
"do": [
{
"type": "set",
"field": "userName",
"value": "${evalScriptByCode('GetUserName').data}"
}
]
}
Datasource parameters are reevaluated when their dependencies change. A new value-change run supersedes an older synchronous run that has not completed.
Execution and Cascades
Within one run:
- matching effects are evaluated in schema order;
- each
whenmust return exactly booleantrue; - actions execute in array order;
- changed fields are collected;
- effects listening to those fields form the next cascade iteration.
One failing effect is reported without preventing the remaining matching effects in the same run from being evaluated.
Cycle safeguards
| Safeguard | Default behavior |
|---|---|
| Maximum cascade depth | Stop after 10 iterations. |
| Depth warning | Warn when depth reaches 3. |
| Field change tracking | Detect repeated changes to the same field in one chain. |
| Invocation stack | Reject direct and indirect runEffect cycles. |
| Burst limit | Stop after more than 10 top-level runs in a 2-second window. |
Synchronous expressions, functions, and transforms share the JavaScript thread with form rendering and user input. A computationally expensive or non-terminating expression can make the page unresponsive before runtime safeguards get a chance to run. Move expensive work to a server-side script and keep the effect graph acyclic.
Designer Validation and Diagnostics
| Diagnostic | Result |
|---|---|
$value read not covered by listen | Dependency warning. Add the path if its change should rerun the effect. |
| Field dependency cycle | Warning. Saving remains possible. |
Direct or indirect runEffect recursion | Error; saving is blocked. |
| Invoked effect without an ID | Schema validation error. |
| Duplicate invoked-effect ID | Schema validation error. |
Missing, ambiguous, or non-invoked runEffect target | Schema validation error. |
Invalid ${...} syntax | JEXL validation error. |
The diagram displays field and invocation cycles. The linear Effects editor also groups missing-listen dependency warnings.
Widget Event Catalog
Representative built-in widget events include:
| Widget | Events | Known payload |
|---|---|---|
dtl-button, dtl-create-entity-button, dtl-submit-request-button | success, error | Operation-specific response |
dtl-fluent-inplace | saveSuccess, saveError, scriptSuccess, scriptError | Save or script outcome |
dtl-attachments-widget | uploadSuccess, uploadError, uploadScriptSuccess, uploadScriptError, deleteAttachmentSuccess, deleteAttachmentError, deleteAttachmentScriptSuccess, deleteAttachmentScriptError | Upload, deletion, or script response |
dtl-fluent-accordion | panelOpened, panelClosed | index, header |
dtl-fluent-tab | activeTabChanged | previousIndex, activeIndex, previousId, activeId |
dtl-fluent-steps | activeStepChanged, stepsViewStateChanged | Transition IDs/indexes or visited-step counts |
dtl-fluent-accordion-array | activeItemChanged | Nullable previousIndex, activeIndex |
dtl-form-input-text | leftIconClicked, rightIconClicked | No payload |
dtl-clipboard-button | copySuccess, copyError | Error reason when copying fails |
| LOV widgets with create support | itemCreated | id, value |
This table is intentionally representative. The selected widget's event list in the Form Designer is the source of truth for the installed widget version.
Legacy widget actions
Older schemas can contain successDo, errorDo, saveSuccessDo, or similar
arrays inside widget configuration. They remain runtime-compatible. When the
schema is edited, the Form Designer migrates them to root widget-event effects
and removes the legacy arrays from the widget configuration.
Legacy ${$response.total} expressions may become
${$event.payload.total}. This is equivalent to the preferred
${$payload.total} form.
Configuration Effects
Plugins and base-form configurations can supply predefined effects for an entity type. These effects:
- use the same runtime contract as schema effects;
- are visible but read-only in the Form Designer;
- are applied automatically to forms using that configuration;
- can be deactivated for one schema by ID.
The designer persists deactivation as a minimal disabled stub:
{
"id": "configuration-effect-id",
"disabled": true,
"listen": [],
"do": []
}
At runtime, configuration and schema effects are matched by id. A disabled
schema stub suppresses the matching configuration effect. Other form-owned
effects remain unchanged.
To deactivate a configuration effect:
- Open the form in the Form Designer.
- Open Effects.
- Expand the effect in Configuration Effects.
- Enable Deactivate this effect in schema.
Runtime Limitations
- Effects are defined at the root schema level.
field,from, andtoare literal paths and cannot be calculated.- Array targets require concrete indexes; there is no wildcard per-row action.
$rowis0in effect expressions.- Widget-event
objectPointervalues identify positions and must be remapped after structural schema changes; use the Form Designer for this. - Nested
scriptactions insidesuccessDoanderrorDoare blocked.
Troubleshooting
Runtime logging
Guardrail and execution failures are always reported as console warnings or errors. Detailed per-effect traces are emitted only when runtime effect logging is enabled. Representative messages include:
[FluentEffects] Max depth reached, stopping execution
[FluentEffects] Execution depth warning
[FluentEffects] Possible infinite loop detected: ...
[FluentEffects] Control not found for set action: ...
[FluentExpressionRuntimeService] evaluation error: ...
[FluentScriptEffects] Script executed successfully
[FluentScriptEffects] Script execution failed
Cycle errors include the cascade depth and executionPath, which identifies
the sequence of effect IDs that led to the guardrail.
The effect does not run
- Confirm that it is not disabled.
- For a value-change effect, confirm that
listencontains the value that actually changed. - For a widget event, confirm the selected widget source and event name.
- Remember that the initial form snapshot is only a baseline unless
runOnInitializationis enabled.
A field is not found
Use a literal dot path such as customer.address.street. Remove $value,
${...}, or JSON Pointer slashes from action targets.
A condition looks truthy but does not run
when must evaluate to boolean true. Inspect whether the expression returns a
string, number, object, or missing value instead.
A datasource expression does not resolve
Confirm that the datasource declares a supported effect mode. For an
identifiedPack, read its settled result from .data.
A script result is missing in an invoked effect
$response does not cross an invocation boundary. Map the required value into
runEffect.inputs and read it from $input in the invoked effect.