Date: 2026-02-21 Scope: Public API surface of fulcro-rad-statecharts from a user's perspective Reviewer: api-critic agent
The library succeeds at its primary goal: users can create RAD forms and reports without understanding statecharts. The defsc-form and defsc-report macros produce sensible results, the routing layer is clean, and the demo app proves the system works end-to-end. However, the migration story is complicated by duplicate APIs (old stubs coexisting with new implementations), the UISM machinery still visibly leaking through report internals, and several missing features that old RAD users will expect.
Overall Grade: Good — Solid foundation, but needs cleanup and documentation polish before release.
Answer: Yes, mostly.
(ns myapp.ui.account-form
(:require
[myapp.model.account :as account]
[com.fulcrologic.rad.form :as form]
[com.fulcrologic.rad.form-options :as fo]))
(form/defsc-form AccountForm [this props]
{fo/id account/id
fo/attributes [account/name account/email account/active?]
fo/title "Edit Account"})
This is clean and declarative. The user never mentions statecharts. The macro handles:
fo/idsfro/statechart and sfro/initialize :alwayssfro/busy? (auto-generated make-form-busy-fn)fs/form-config-joinPositive: The attribute-centric declaration is preserved. A user familiar with old RAD forms will recognize this immediately.
Issue: fo/route-prefix is still defined in form_options.cljc but the macro no longer generates :route-segment or :will-enter. Its purpose in the new world is unclear. The demo uses it (fo/route-prefix "account") but it's not consumed by the macro's convert-options. This is confusing — either document what it does now, or remove it.
;; Save, undo, cancel — all work via rendering env
(form/save! {::form/master-form this})
(form/undo-all! {::form/master-form this})
(form/cancel! {::form/master-form this})
These are clean and unchanged from old RAD. They internally delegate to scf/send! with session IDs, but the user never sees that.
(form/start-form! app entity-id AccountForm {:on-saved some-txn})
This is well-documented and intuitive. The params map supports :on-saved, :on-cancel, :on-save-failed, and :embedded?.
Verdict: Form creation is clean. A RAD user can be productive without any statechart knowledge.
Answer: Yes.
(report/defsc-report InventoryReport [this props]
{ro/columns [item/name category/label item/price item/in-stock]
ro/row-pk item/id
ro/source-attribute :item/all-items
ro/run-on-mount? true
ro/paginate? true
ro/page-size 20})
Clean, declarative, no statechart concepts visible. The macro generates the query, ident, initial-state, and wires up sfro/initialize :once and sfro/statechart.
(report/run-report! this)
(report/sort-rows! this column-attr)
(report/filter-rows! this)
(report/goto-page! this 3)
All clean, all delegate to scf/send! internally.
Issue: report/start-report! uses ::machine as the option key to find a custom statechart:
(let [machine-key (or (comp/component-options report-class ::machine) ::report-chart)
But the macro generates sfro/statechart (or sfro/statechart-id) from ro/statechart. The ::machine key is the old deprecated name. This means: if a user sets ro/statechart on a report and then calls start-report! directly (outside of routing), the custom chart will be ignored because start-report! reads ::machine, not ro/statechart. This is a bug.
Verdict: Report creation is clean. The statechart abstraction is well-hidden.
Answer: Yes, with caveats.
The routing API in the demo is exemplary:
;; Define routes declaratively
(def routing-chart
(statechart {:initial :state/route-root}
(scr/routing-regions
(scr/routes {:id :state/root :routing/root `Routes}
(scr/rstate {:route/target `LandingPage})
(rroute/report-route-state {:route/target InventoryReport})
(rroute/form-route-state {:route/target AccountForm
:route/params #{:account/id}})))))
;; Navigate
(scr/route-to! this InventoryReport)
(rroute/create! this AccountForm)
(rroute/edit! this AccountForm entity-id)
The form-route-state and report-route-state helpers are excellent abstractions. They handle on-entry/on-exit lifecycle without the user writing statechart expressions.
Two routing APIs: Users must choose between rroute/route-to! (RAD compat wrapper) and scr/route-to! (direct statecharts routing). The demo itself uses BOTH — scr/route-to! for reports and rroute/create! for forms. This is confusing. The RAD routing ns should be the single entry point, or the statecharts routing should be the recommended API. Pick one.
form/view!, form/edit!, form/create! are STUBBED in form.cljc (lines 1558-1594) — they just log warnings. But routing/edit! and routing/create! ARE implemented (lines 119-132). This means there are TWO create functions with the same purpose, one broken and one working, in different namespaces. Users will find the broken one first since it's in the form namespace.
Route denial UX: The demo shows route denial handling in Root. This is good, but there's no built-in helper component for this. Every user will need to write their own modal. A (rroute/route-denied-modal app-ish {:on-cancel fn :on-continue fn}) helper or at least a documented pattern would reduce boilerplate.
back! is thin: routing/back! delegates to scr/route-back!, but there's no routing/forward! — only routing/route-forward!. The naming inconsistency (back! vs route-forward!) is a minor paper cut.
Good:
fo/idsfro/busy?, sfro/initialize, sfro/statechart:will-enter is specifiedIssue: The macro still passes options to convert-options at runtime, not compile time. This means errors like missing fo/id or fo/attributes are runtime errors, not compile-time. The old RAD had the same limitation, but since this is a rewrite, it would be an improvement to catch these at compile time.
Good:
BodyItem not specifiedsfro/initialize :once and sfro/statechartIssue: The macro emits sfro/statechart from ro/statechart, but start-report! reads ::machine (see issue in section 2).
Good:
sfro/initialize :once integrationMinor: The docstring says "If you want this to be a route target, then you must add :route-segment" — but :route-segment is a Dynamic Router concept that no longer applies. This should be updated.
form-route-state / report-route-state the Right Abstraction?Answer: Yes, this is the best part of the new API.
These functions perfectly encapsulate the "routing lifecycle hooks for RAD components" pattern. They:
scr/rstate with the right on-entry/on-exitrstate options (:route/target, :route/params)The pattern is also extensible — users can create their own route-state helpers for custom components.
One suggestion: Add a container-route-state helper for consistency, even if it's trivial:
(defn container-route-state [props]
(scr/rstate props
(entry-fn [{:fulcro/keys [app]} _data _event-name event-data]
(container/start-container! app (comp/registry-key->class (:route/target props)) event-data)
nil)))
Based on the demo, the minimal setup is:
;; 1. Define attributes (unchanged from old RAD)
;; 2. Define forms/reports with defsc-form/defsc-report (nearly unchanged)
;; 3. Define routing chart (NEW)
;; 4. Bootstrap:
(rad-app/install-statecharts! app {:event-loop? true})
(rad-app/start-routing! app routing-chart)
(rad-app/install-url-sync! app) ;; CLJS only
(swap! (::app/state-atom app) assoc :ui/ready? true)
Comparison to old RAD:
;; Old RAD bootstrap:
(rad-app/install-ui-controls! app sui/all-controls)
(app/mount! app Root "app")
;; (routing was implicit via Dynamic Router)
Assessment: The new bootstrap is more explicit (3 calls instead of 1), but each call is well-named and documented. The explicit routing chart declaration is actually an improvement — it makes the route structure visible and auditable.
Missing: install-ui-controls! still exists in application.cljc but the demo doesn't use it (headless plugin auto-registers via multimethods). This should be documented — when do users need it vs. when do plugins auto-register?
Good: Well-documented with docstrings for every option. The defoption pattern provides both documentation and a stable keyword reference.
Issues:
fo/route-prefix — purpose unclear in new system (see section 1)fo/cancel-route — docstring references :back and route history, but the old routing/history system is removed. Does this still work? The form chart would need to handle it.fo/machine — marked deprecated, suggests statechart. Good.Good: Consistent with form_options.
Issue: ro/route still exists — same confusion as fo/route-prefix.
The split between form_options, form_render_options, control_options, and report_options is reasonable. However, users now also need to know about sfro options (sfro/initialize, sfro/busy?, sfro/statechart). These are NOT in the RAD options files — they're in the statecharts library. This creates a documentation gap. Users will look in fo/* for all form options but won't find the routing-integration ones there.
Recommendation: Add fo/initialize, fo/busy? as aliases that delegate to sfro/*, or at minimum add docstring cross-references in form_options.cljc.
| Aspect | Old RAD | New RAD | Verdict |
|---|---|---|---|
| Routing declaration | Implicit via DR route-segment | Explicit statechart | Better — visible, auditable |
| Route lifecycle | will-enter/will-leave + allow-route-change? | sfro/initialize + sfro/busy? | Better — declarative |
| Form/report startup | UISM begin! inside will-enter | start-form!/start-report! | Better — explicit |
| Route denial | DR-specific | Built into routing statechart | Better — unified |
| Custom form behavior | Override UISM machine | Override statechart | Better — statecharts more expressive |
| Rendering plugin | install-*! functions | defmethod multimethods | Better — standard Clojure |
| Testability | CLJS-only in browser | CLJC headless | Much better |
| Aspect | Issue |
|---|---|
| Bootstrap complexity | 3 calls instead of implicit setup |
| Namespace knowledge | Must know rroute, scr, sfro, scf in addition to form, report |
| Routing chart boilerplate | Must manually declare every route target |
| Authorization | Completely removed (was in old RAD) |
| Blob/file upload | Completely removed |
| Dynamic generation | dynamic.generator removed |
| Pathom 3 | No support (only Pathom 2) |
form/view! — Stubbed, not functional. Old RAD had read-only form viewing.form/edit! and form/create! in form.cljc — Stubbed. Working versions exist in routing.cljc but under different signatures.dynamic.generator removed. No replacement.rad_hooks — Hook system removed. No replacement.container-route-state — Missing (form and report have route-state helpers, container doesn't).form/create! vs routing/create! — Both exist, only the routing version works. Users will find the form version first.::machine vs ro/statechart — Macro writes sfro/statechart, but start-report! reads ::machine. Bug.fo/route-prefix and ro/route — Still defined but no longer consumed by macros for route-segment generation. Purpose unclear.scr/route-to! vs rroute/route-to! — Both work, demo uses both. Which should users prefer?report-machine, global-events, all the UISM handler functions are still present. This is confusing for someone reading the source to understand how reports work.Fix start-report! to read ro/statechart instead of (or in addition to) ::machine. This is a correctness bug — custom report statecharts set via the macro-supported ro/statechart option are silently ignored.
Remove or redirect form/view!, form/edit!, form/create! stubs. Either:
routing/edit!, routing/create!, orrouting/edit! and routing/create!
Current behavior (logging a warning and doing nothing) is the worst option — silent failure.Pick one routing API and document it clearly. Recommendation: rroute/route-to! should be the blessed API for RAD users, with scr/route-to! documented as the lower-level alternative. Update the demo to be consistent.
Clean up fo/route-prefix and ro/route. Either:
Add container-route-state helper to routing.cljc for consistency.
Add cross-references in form_options.cljc and report_options.cljc pointing to sfro/* options for routing integration.
Document when install-ui-controls! is needed vs. when multimethod registration is sufficient.
Remove UISM report-machine and associated UISM handler code from report.cljc. It's dead code that confuses readers.
Add a route-denied modal helper or documented pattern.
Create a migration guide documenting the old-to-new API mapping, especially for:
will-enter / will-leave → sfro/initialize / sfro/busy?form/create! → routing/create!install-routing! → start-routing!Consider adding rroute/view! as a convenience function alongside rroute/edit! and rroute/create!.
Rename routing/route-forward! to routing/forward! for symmetry with routing/back!.
The headless plugin (rad.rendering.headless.plugin) registers renderers via multimethods, which is clean. The demo shows it working for forms and reports.
Gap: There's no documentation on how a user would write their own headless tests for a RAD app. The test infrastructure exists (E2E tests in the test suite prove it), but there's no user-facing guide explaining:
This is a significant documentation gap, since headless testing is one of the key advantages of the new system.
| Metric | Value | Notes |
|---|---|---|
| Form API compatibility | ~85% | Core form CRUD works; view!, edit!, create! in form ns broken |
| Report API compatibility | ~90% | Works well; ::machine bug is the main issue |
| Container API compatibility | ~90% | Works; missing route-state helper |
| Routing API compatibility | ~60% | Completely different paradigm (improvement, but breaking) |
| Missing features | 7 | Auth, blob, dynamic gen, hooks, Pathom3, view!, history params |
| Namespace count for basic usage | 6-7 | form, fo, report, ro, routing, scr, sfro |
| Bootstrap steps | 3-4 | install-statecharts!, start-routing!, install-url-sync!, mount |
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 |