Status: active Priority: P0 Created: 2026-02-20 Owner: AI Depends-on: project-setup, session-id-convention, macro-rewrites
The RAD form system is the largest and most complex UISM in fulcro-rad. com.fulcrologic.rad.form/form-machine (~270 lines of UISM definition) manages the full lifecycle of entity editing: loading, creating, editing, saving, undo, route guarding, subform management, and dirty tracking. This spec defines the conversion from UISM to statecharts.
The current form UISM is defined in src/main/com/fulcrologic/rad/form.cljc lines 1180-1447. Helper functions that support the machine (start-edit, start-create, leave-form, calc-diff, etc.) are defined alongside it in the same file.
form-machine (defstatemachine) with a statechart definition using statechart, state, transition, on-entry, script, etc.save!, cancel!, undo-all!, add-child!, delete-child!, edit!, create!, view!, start-form!, input-changed!, input-blur!, etc.)fo/statechart option (users can override the chart)rstate/istate) instead of Fulcro dynamic routing#{:actor/form} ;; The form component being edited
{:confirmation-message [:actor/form :ui/confirmation-message]
:route-denied? [:actor/form :ui/route-denied?]
:server-errors [:actor/form ::form/errors]}
| UISM State | Description |
|---|---|
:initial | Entry point. Dispatches to create or edit based on ::create? event data |
:state/loading | Waiting for server load of existing entity |
:state/asking-to-discard-changes | Confirmation dialog when abandoning dirty form |
:state/saving | Waiting for server save response |
:state/editing | Main interactive editing state (handles most events) |
| Event | Handler |
|---|---|
:event/exit | uism/exit - tears down the state machine |
:event/reload | Re-issues load for existing entities (not tempids) |
:event/mark-complete | Marks all form fields as complete for validation |
No named events. The handler function runs immediately:
uism/store :optionsfo/triggers :started if defined::create? true: calls start-create -> transitions to :state/editing::create? false: calls start-edit -> transitions to :state/loading| Event | Handler |
|---|---|
:event/loaded | Clears errors, auto-creates to-one refs, handles UI props, adds form config, marks complete, notifies router if pending, transitions to :state/editing |
:event/failed | Sets server-errors alias to [{:message "Load failed."}] |
| Event | Handler |
|---|---|
:event/ok | Calls leave-form (reverts form, routes away) |
:event/cancel | Returns to :state/editing |
| Event | Handler |
|---|---|
:event/save-failed | Extracts errors from mutation result, sets server-errors alias, calls fo/triggers :save-failed, runs on-save-failed txn, transitions to :state/editing |
:event/saved | Updates history route (edit action + real ID), calls fo/triggers :saved, runs on-saved txn, marks form pristine, transitions to :state/editing |
| Event | Handler |
|---|---|
:event/attribute-changed | Clears errors, updates value in state, marks field complete, fires on-change trigger, runs derive-fields |
:event/blur | No-op (placeholder for future use) |
:event/route-denied | If :async confirm: stores desired route, sets route-denied? true. If sync confirm fn: prompts user, either leaves or stays |
:event/continue-abandoned-route | Retrieves stored route, pushes/replaces history, retries route via DR, resets form to pristine |
:event/clear-route-denied | Sets route-denied? to false |
:event/add-row | Merges new child entity, adds form config, marks fields complete, fires on-change, runs derive-fields |
:event/delete-row | Removes child ident from parent relation, fires on-change, runs derive-fields |
:event/save | Validates form. If valid: calculates diff, triggers remote save mutation, transitions to :state/saving. If invalid: marks all complete, stays in editing |
:event/reset | Calls undo-all (clears errors, restores pristine) |
:event/cancel | Calls leave-form (reverts form, routes away) |
| Function | Purpose | Statechart Equivalent |
|---|---|---|
start-edit | Issues uism/load for form entity | fops/load in on-entry script |
start-create | Generates default state, merges, marks complete | Script in on-entry |
leave-form | Reverts form, determines cancel route, schedules routing | Script + routing event |
calc-diff | Computes fs/dirty-fields for save delta | Pure function (unchanged) |
clear-server-errors | uism/assoc-aliased :server-errors [] | fops/assoc-alias :server-errors [] |
undo-all | Clears errors + fs/pristine->entity* | Script with fops/apply-action |
auto-create-to-one | Creates missing to-one refs marked autocreate | Script |
apply-derived-calculations | Runs :derive-fields triggers | Script |
handle-user-ui-props | Runs ::initialize-ui-props after load | Script |
route-target-ready | Notifies router that deferred route is ready | Statecharts routing handles this natively |
(ns com.fulcrologic.rad.form-chart
(:require
[com.fulcrologic.statecharts.chart :refer [statechart]]
[com.fulcrologic.statecharts.elements :refer
[state transition on-entry on-exit script final data-model]]
[com.fulcrologic.statecharts.convenience :refer [on handle]]
[com.fulcrologic.statecharts.data-model.operations :as ops]
[com.fulcrologic.statecharts.integration.fulcro :as scf]
[com.fulcrologic.statecharts.integration.fulcro.operations :as fops]
[com.fulcrologic.rad.form-expressions :as fex]))
(def form-chart
(statechart {:initial :initial}
(data-model {:expr (fn [_ _] {:options {}})})
;; ===== INITIAL (decision state) =====
(state {:id :initial}
(on-entry {}
(script {:expr fex/store-options}))
;; Eventless transitions act as a decision node
(transition {:cond fex/create? :target :state/creating})
(transition {:target :state/loading}))
;; ===== CREATING =====
;; Separated from loading because create is synchronous
(state {:id :state/creating}
(on-entry {}
(script {:expr fex/start-create-expr}))
;; Immediately transition to editing after create setup
(transition {:target :state/editing}))
;; ===== LOADING =====
(state {:id :state/loading}
(on-entry {}
(script {:expr fex/start-load-expr}))
(on :event/loaded :state/editing
(script {:expr fex/on-loaded-expr}))
(on :event/failed :state/load-failed
(script {:expr fex/on-load-failed-expr}))
;; Global events available during loading
(on :event/exit :state/exited)
(on :event/reload :state/loading
(script {:expr fex/start-load-expr})))
;; ===== LOAD FAILED =====
;; Terminal-ish state - can retry or exit
(state {:id :state/load-failed}
(on :event/reload :state/loading)
(on :event/exit :state/exited))
;; ===== EDITING (main interactive state) =====
(state {:id :state/editing}
;; --- Global events ---
(on :event/exit :state/exited)
(on :event/reload :state/loading
(script {:expr fex/start-load-expr}))
(handle :event/mark-complete fex/mark-all-complete-expr)
;; --- Field editing ---
(handle :event/attribute-changed fex/attribute-changed-expr)
(handle :event/blur fex/blur-expr)
;; --- Subform management ---
(handle :event/add-row fex/add-row-expr)
(handle :event/delete-row fex/delete-row-expr)
;; --- Save flow ---
(transition {:event :event/save :cond fex/form-valid? :target :state/saving}
(script {:expr fex/prepare-save-expr}))
(handle :event/save fex/mark-complete-on-invalid-expr)
;; --- Undo ---
(handle :event/reset fex/undo-all-expr)
;; --- Cancel / Route guarding ---
(on :event/cancel :state/leaving
(script {:expr fex/prepare-leave-expr}))
(handle :event/route-denied fex/route-denied-expr)
(handle :event/continue-abandoned-route fex/continue-abandoned-route-expr)
(handle :event/clear-route-denied fex/clear-route-denied-expr))
;; ===== SAVING =====
(state {:id :state/saving}
(on :event/saved :state/editing
(script {:expr fex/on-saved-expr}))
(on :event/save-failed :state/editing
(script {:expr fex/on-save-failed-expr}))
;; Global events
(on :event/exit :state/exited))
;; ===== LEAVING =====
;; Transient state for form exit cleanup
(state {:id :state/leaving}
(on-entry {}
(script {:expr fex/leave-form-expr}))
(transition {:target :state/exited}))
;; ===== EXITED (final) =====
(final {:id :state/exited})))
:initial uses eventless transitions instead of a handler function. The UISM initial state runs a handler immediately; in statecharts, we use a state with conditional eventless transitions to route to :state/creating or :state/loading.
:state/creating is a separate state from :state/loading. The UISM combines them in :initial, but separating them makes the chart clearer and allows the creating state's on-entry to set up defaults before transitioning to editing.
:state/load-failed is explicit rather than remaining in :state/loading with an error alias. This makes the failure visible in the statechart configuration.
:state/leaving is a transient state that runs leave-form cleanup and immediately transitions to :state/exited. The UISM handles this inline; the statechart makes it explicit.
:state/asking-to-discard-changes is removed as a top-level state. However, the async confirmation pattern (where a modal asks "discard changes?") requires a distinct state for the UI to render the dialog. This can be modeled as a child state of :state/editing if needed:
(state {:id :state/editing :initial :state/edit-normal}
(state {:id :state/edit-normal}
;; ... all editing events ...
(on :event/route-denied :state/asking-to-discard))
(state {:id :state/asking-to-discard}
(on :event/ok :state/leaving)
(on :event/cancel :state/edit-normal)))
For the initial implementation, the simpler alias-flag approach (setting route-denied? to true) is sufficient. The child state option is available if async confirmation UX requires it.
Expression functions are in a separate namespace (form-expressions) following the statecharts file organization pattern from the patterns resource. This keeps the chart definition clean and the expressions testable.
| UISM Actor | Statechart Actor | Setup |
|---|---|---|
:actor/form | :actor/form | (scf/actor FormClass form-ident) passed in :data at start! |
The actor is set up identically. The start-form! function changes from:
;; UISM version
(uism/begin! app machine form-ident
{:actor/form (uism/with-actor-class form-ident form-class)}
params)
;; Statechart version (see session-id-convention.md for ident->session-id)
(let [session-id (ident->session-id form-ident)]
(scf/start! app {:machine (or (comp/component-options form-class ::statechart) ::form-chart)
:session-id session-id
:data {:fulcro/actors {:actor/form (scf/actor form-class form-ident)}
:fulcro/aliases {:confirmation-message [:actor/form :ui/confirmation-message]
:route-denied? [:actor/form :ui/route-denied?]
:server-errors [:actor/form ::form/errors]}
::create? (tempid/tempid? id)
:options params}}))
Session ID strategy: Use (ident->session-id form-ident) to produce a keyword from the form ident. See session-id-convention.md for details. This mirrors the UISM approach where form-ident is the asm-id, and allows send! to target the form's session directly.
| UISM Alias | Statechart Alias | Resolves To |
|---|---|---|
:confirmation-message | :confirmation-message | [:actor/form :ui/confirmation-message] in Fulcro state via actor ident |
:route-denied? | :route-denied? | [:actor/form :ui/route-denied?] |
:server-errors | :server-errors | [:actor/form ::form/errors] |
Aliases are defined in the :fulcro/aliases key of the start data. They are read directly from data (the Fulcro data model automatically resolves all aliases into the data map in expressions -- see CC-5 in critique). They can also be read explicitly via scf/resolve-aliases. They are written via fops/assoc-alias which takes keyword-argument pairs (variadic & {:as kvs}).
;; UISM: Reading
(uism/alias-value env :server-errors)
;; Statechart: Reading -- aliases resolve directly on `data` map
(let [errors (:server-errors data)] ...)
;; Or explicitly:
(let [{:keys [server-errors]} (scf/resolve-aliases data)] ...)
;; UISM: Writing (single value)
(uism/assoc-aliased env :server-errors [{:message "error"}])
;; Statechart: Writing -- keyword-argument pairs (can set multiple at once)
[(fops/assoc-alias :server-errors [{:message "error"}])]
;; Multiple aliases in one call:
[(fops/assoc-alias :server-errors [] :route-denied? false)]
Every UISM event maps 1:1 to a statechart event:
| UISM Event | Statechart Event | Triggered By |
|---|---|---|
:event/exit | :event/exit | abandon-form!, form-will-leave |
:event/reload | :event/reload | undo-via-load! |
:event/mark-complete | :event/mark-complete | mark-all-complete! |
:event/loaded | :event/loaded | Load ok-event callback |
:event/failed | :event/failed | Load error-event callback |
:event/attribute-changed | :event/attribute-changed | input-changed! |
:event/blur | :event/blur | input-blur! |
:event/route-denied | :event/route-denied | :route-denied component option |
:event/continue-abandoned-route | :event/continue-abandoned-route | continue-abandoned-route! |
:event/clear-route-denied | :event/clear-route-denied | clear-route-denied! |
:event/add-row | :event/add-row | add-child! |
:event/delete-row | :event/delete-row | delete-child! |
:event/save | :event/save | save! |
:event/saved | :event/saved | Save mutation ok-event |
:event/save-failed | :event/save-failed | Save mutation error-event |
:event/reset | :event/reset | undo-all! |
:event/cancel | :event/cancel | cancel! |
:event/ok | (removed) | Was only used in :state/asking-to-discard-changes |
Each UISM handler becomes a statechart expression function. Expression functions receive (fn [env data event-name event-data]) (4-arg Fulcro convention) and return a vector of operations.
apply-action -> fops/apply-action;; UISM
(uism/apply-action env fs/mark-complete* form-ident)
;; Statechart
[(fops/apply-action fs/mark-complete* form-ident)]
assoc-aliased -> fops/assoc-alias;; UISM
(uism/assoc-aliased env :server-errors [])
;; Statechart
[(fops/assoc-alias :server-errors [])]
store/retrieve -> ops/assign / direct data access;; UISM
(uism/store env :options event-data)
(uism/retrieve env :options)
;; Statechart
[(ops/assign :options event-data)]
;; Reading: (:options data)
activate -> statechart transitionsIn UISM, activate is called within a handler to change state. In statecharts, state changes are declared as transitions on the chart itself, not in expressions. For cases where the handler conditionally activates different states, we use:
:cond predicates on the chartstart-load-expr(defn start-load-expr
"Expression for loading an existing form entity."
[env data _event-name _event-data]
(let [FormClass (scf/resolve-actor-class data :actor/form)
{:keys [ident]} (get-in data [:fulcro/actors :actor/form])
form-ident ident]
[(fops/load form-ident FormClass
{::sc/ok-event :event/loaded
::sc/error-event :event/failed})]))
attribute-changed-expr(defn attribute-changed-expr
"Expression for handling field value changes."
[env data _event-name event-data]
(let [{:keys [form-key form-ident old-value value]
::attr/keys [cardinality type qualified-key]} event-data
;; Value normalization (same logic as current)
many? (= :many cardinality)
ref? (= :ref type)
value (cond
(and ref? many? (nil? value)) []
(and many? (nil? value)) #{}
(and ref? many?) (filterv #(not (nil? (second %))) value)
(and ref? (nil? (second value))) nil
:else value)
path (when (and form-ident qualified-key) (conj form-ident qualified-key))
;; Resolve trigger functions
form-class (some-> form-key rc/registry-key->class)
on-change (some-> form-class rc/component-options ::form/triggers :on-change)]
(cond-> [(fops/assoc-alias :server-errors [])
(fops/apply-action fs/mark-complete* form-ident qualified-key)]
(and path (nil? value))
(conj (fops/apply-action update-in form-ident dissoc qualified-key))
(and path (some? value))
(conj (fops/apply-action assoc-in path value))
;; on-change and derive-fields require special handling - see open questions
)))
;; UISM
(uism/trigger-remote-mutation env :actor/form save-mutation
{::uism/error-event :event/save-failed
::uism/ok-event :event/saved
::form/master-pk master-pk
::form/id (second form-ident)
::m/returning form-class
::form/delta delta})
;; Statechart -- note: first arg is a Fulcro txn vector, not a bare symbol
[(fops/invoke-remote [(save-form {::form/master-pk master-pk
::form/id (second form-ident)
::form/delta delta})]
{:returning :actor/form ;; resolves actor class
:ok-event :event/saved
:error-event :event/save-failed})]
;; UISM
(uism/load env form-ident FormClass
{::uism/ok-event :event/loaded
::uism/error-event :event/failed})
;; Statechart
[(fops/load form-ident FormClass
{::sc/ok-event :event/loaded
::sc/error-event :event/failed})]
The current form_machines.cljc namespace is empty (just a docstring). It was intended to hold helper functions for custom form machines. In the statechart version, this namespace should provide:
Expression helper functions that custom charts can compose:
clear-server-errors-ops - Returns ops to clear errorsundo-all-ops - Returns ops to revert formsave-ops - Returns ops to trigger savestandard-load-ops - Returns ops to load form entityReusable chart fragments that custom charts can include:
global-event-transitions - Vector of transitions for exit/reload/mark-completeediting-event-transitions - Vector of transitions for the editing state(ns com.fulcrologic.rad.form-machines
"Helper functions and chart fragments for writing custom form statecharts."
(:require
[com.fulcrologic.statecharts.elements :refer [transition script]]
[com.fulcrologic.statecharts.convenience :refer [on handle]]
[com.fulcrologic.rad.form-expressions :as fex]))
(def global-transitions
"Reusable transitions for exit, reload, and mark-complete. Include these
in any state that should support the standard global form events."
[(on :event/exit :state/exited)
(on :event/reload :state/loading (script {:expr fex/start-load-expr}))
(handle :event/mark-complete fex/mark-all-complete-expr)])
Dirty tracking uses Fulcro's com.fulcrologic.fulcro.algorithms.form-state (fs) namespace, which is independent of UISM. The statechart integration does not change how dirty tracking works:
fs/add-form-config* - Adds form config metadata to state (called on create and load)fs/mark-complete* - Marks fields as "checked" for validationfs/dirty? - Checks if form has unsaved changes (used in UI for button states)fs/dirty-fields - Computes the delta for savefs/pristine->entity* - Reverts form to last-saved state (undo/cancel)fs/entity->pristine* - Marks current state as pristine (after save)All of these operate directly on the Fulcro state map and are called via fops/apply-action:
;; In expression functions:
[(fops/apply-action fs/add-form-config* FormClass form-ident {:destructive? true})
(fops/apply-action fs/mark-complete* form-ident)
(fops/apply-action fs/entity->pristine* form-ident)]
The fs/dirty? check for UI rendering (button enabled/disabled states) remains a pure function call in the component render, unchanged from current behavior.
The current form integrates with Fulcro's dynamic routing via:
form-will-enter - Creates dr/route-deferred and calls start-form!form-will-leave - Checks if UISM is running, triggers :event/exitform-allow-route-change - Returns true if form is not dirty:route-denied component option - Triggers :event/route-denied on the UISMroute-target-ready - Signals to the router that a deferred route target is readyWith statecharts routing, forms become rstate or istate elements in the routing chart:
;; In the routing chart definition:
(require '[com.fulcrologic.statecharts.integration.fulcro.routing :as scr])
;; A form becomes a route state with a co-located statechart
(scr/rstate {:id ::account-form
:route/segment ["account" :action :id]
:route/target AccountForm}
;; The form's own statechart runs as an invoked child
;; via the routing-options/statechart option on the component
)
The form component uses routing options instead of DR hooks:
(defsc-form AccountForm [this props]
{fo/id account/id
fo/attributes [...]
fo/route-prefix "account"
;; NEW: statechart routing options
sfro/statechart form-chart ;; co-located chart definition
sfro/busy? (fn [env data] ;; replaces allow-route-change?
(let [{:actor/keys [form]} (scf/resolve-actors env :actor/form)]
(fs/dirty? form)))
sfro/initialize :once})
Key differences:
will-enter/will-leave: The routing statechart handles entry/exit lifecycleroute-deferred: Loading happens in the form's own statechart, routing shows the form immediately (or the form shows a loading indicator)busy? replaces allow-route-change?: The routing system checks busy? before allowing navigation awayroute-denied? for async confirmation UISee macro-rewrites.md for the full specification of defsc-form macro changes. Summary:
The convert-options function (lines 528-578) must be updated to:
:will-enter, :will-leave, :allow-route-change?, :route-denied generationsfro/statechart, sfro/busy?, and sfro/initialize :always to component options[::uism/asm-id '_] from the query (no longer needed):route-segment -- route segments live only on istate in the routing chartfo/statechart as either a statechart definition or a pre-registered chart ID keyword:will-enter is overridden in user options| Current | New |
|---|---|
form-will-enter | Removed. Routing chart handles entry |
form-will-leave | Removed. Routing chart handles exit |
form-allow-route-change | Replaced by sfro/busy? |
start-form! | Still exists for non-routed / embedded forms. For routed forms, the routing system starts the chart |
edit! / create! / view! | Change to use scr/route-to! instead of rad-routing/route-to! |
All expression functions live in com.fulcrologic.rad.form-expressions:
(ns com.fulcrologic.rad.form-expressions
"Statechart expression functions for the RAD form chart. These are the
executable content that runs inside form statechart states and transitions."
(:require
[com.fulcrologic.fulcro.algorithms.form-state :as fs]
[com.fulcrologic.fulcro.algorithms.merge :as merge]
[com.fulcrologic.fulcro.algorithms.normalized-state :as fns]
[com.fulcrologic.fulcro.algorithms.tempid :as tempid]
[com.fulcrologic.fulcro.raw.components :as rc]
[com.fulcrologic.statecharts :as sc]
[com.fulcrologic.statecharts.data-model.operations :as ops]
[com.fulcrologic.statecharts.integration.fulcro :as scf]
[com.fulcrologic.statecharts.integration.fulcro.operations :as fops]
[com.fulcrologic.rad.form :as form]
[taoensso.timbre :as log]))
Each expression is a (fn [env data event-name event-data] ops-or-nil). The Fulcro integration ALWAYS calls expressions with 4 args (per install-fulcro-statecharts! docs). The last two args (event-name and event-data) are also available in data under the :_event key as a convenience. Use (fn [env data & _]) when the event name/data are not needed.
Convention: All expressions in this spec use the 4-arg form. When event-name and event-data are unused, they are bound as _event-name _event-data or elided with & _.
on-change trigger: Decision: Option A (clean break). The new on-change signature is:
(fn [env data form-ident changed-key old-value new-value]
;; env - statechart env (contains :fulcro/app, etc.)
;; data - statechart session data (aliases auto-resolved)
;; form-ident - the ident of the form being edited
;; changed-key - the qualified keyword of the changed field
;; old-value - previous value
;; new-value - new value
;; Returns: vector of operations (fops/apply-action, fops/assoc-alias, ops/assign, etc.) or nil
[(fops/apply-action assoc-in (conj form-ident :derived/field) (compute-derived new-value))])
This is a breaking change from the UISM signature (fn [uism-env form-ident k old new] -> uism-env). The key difference: returns ops vector instead of threaded env.
started/saved/save-failed triggers: Decision: Change to statechart expression signature. New signature: (fn [env data event-name event-data] ops-vec) matching the standard 4-arg expression convention. This is a breaking change.
Session ID as form-ident: Resolved. Vector idents are NOT valid ::sc/id values (spec only allows uuid, number, keyword, string). See session-id-convention.md for the deterministic ident->session-id conversion that produces a namespaced keyword from a Fulcro ident. Use (form-session-id form-instance) or (ident->session-id form-ident) throughout.
Embedded forms: Forms with :embedded? true skip routing. How do embedded forms start their statechart?
start-form! function should work for this case, starting the chart directlyCustom form machines: Users who have overridden fo/statechart with a custom UISM will need migration guidance. Their custom UISMs won't work with the new system.
Query changes: The current query includes [::uism/asm-id '_] for the UISM. The statechart equivalent query inclusion (if any) needs to be determined. The statechart session is at [::sc/session-id session-id] but may not need to be in the component query if we don't render based on chart state.
Transition ordering for save validation: The :event/save handler currently validates, then either saves or marks-complete-and-stays. In the statechart, we have two transitions: one with :cond form-valid? targeting :state/saving, and a second (unconditional) handling the invalid case. This relies on document-order evaluation of transitions.
src/main/com/fulcrologic/rad/form.cljc - Major rewrite: remove UISM, add statechart setupsrc/main/com/fulcrologic/rad/form_machines.cljc - Populate with statechart helperssrc/main/com/fulcrologic/rad/form_expressions.cljc - NEW: expression functionssrc/main/com/fulcrologic/rad/form_chart.cljc - NEW: chart definition (or inline in form.cljc)src/main/com/fulcrologic/rad/form_options.cljc - Add statechart-related options, deprecate UISM onesfo/statechart workview-mode? rewritten to read from statechart session data instead of UISM internal storage (breaking internal change)fops/assoc-alias to show keyword-argument pairs pattern(fn [env data event-name event-data] ...) with & _ conventionfops/invoke-remote first arg to be a txn vector [(mutation-call {...})]view-mode? as requiring full rewrite (not unchanged):state/asking-to-discard-changes as child state optiondata map (CC-5)Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |