Analysis of the current ui_routes.cljc routing system and the routing demo, leading to
a design for composable, code-splittable statechart-driven routing with externalized history.
1. ui_routes.cljc (Fulcro-integrated routing)
Full Fulcro integration with idents, dynamic queries, and normalized state.
Ident resolution in initialize-route! follows this cascade:
ro/initial-props option → called as (initial-props env data), result used with rc/get-ident{id-key id} from event data(rc/get-initial-state Target event-data) and derives identParameters stored via establish-route-params-node:
:route/params keys[:routing/parameters state-id] in data modelInternal storage locations (in statechart data model):
| Data | Location | Purpose |
|---|---|---|
| Route idents | [:route/idents <registry-key>] | Fulcro join targets |
| Route params | [:routing/parameters <state-id>] | Per-state parameters |
| Failed route | ::failed-route-event | Force-continue on denied routes |
| Invocation IDs | [:invocation/id <target-key>] | Child session tracking |
Also mutates Fulcro state externally:
merge/merge-component! — initializes component in Fulcro state atomrc/set-query! + swap! state-atom — rewires parent query joins2. Routing Demo (simple approach)
No rstate/istate. Stores everything flat in the data model:
[:ROOT :route/params] — all event data[:ROOT :current-event], [:ROOT :current-day], [:ROOT :current-menu]Renders by reading directly from ::sc/local-data in Fulcro state. No idents, no dynamic
queries, no merge-component!. Statechart data model is the single source of truth.
ui_routes.cljcThree module-level singletons:
history (volatile!) — HTML5 history integration instanceroute-table-atom (atom) — precomputed route table for URL matchingsession-id (constant ::session) — not parameterizedThis means only ONE routing statechart can exist per JVM/browser context.
The statechart is the source of truth. The URL is a projection of the chart's state. User editing the URL (back button, literal edit) is a request to the chart, not a command. If the chart refuses, the external sync system auto-fixes the URL to match.
This eliminates a huge class of SPA bugs.
ui_routesestablish-route-params-node URL-reading logicundo-url-change entirelyapply-external-route entirelystate-for-path and the route tablehistory atom and route-table-atomroute->url / url->route fns in start-routing!initialize-route! — ident resolution, Fulcro mergeupdate-parent-query! — dynamic query patchingestablish-route-params-node — reduced to just ops/assign from event databusy? / record-failed-route! / override-route! — all event-based alreadyrouting-info-state — the denied-route modal state machineroutes / routing-regions — chart structure helpersui-current-subroute, ui-parallel-route)Currently establish-route-params-node has awkward branching:
event has params? -> use event data
else URL has params? -> use URL params
else -> empty
With externalization, this collapses to: always use event data. The external layer
reads the URL and puts params into the event data before sending. The chart node just does
(ops/assign [:routing/parameters id] (select-keys event-data params)).
The failed-route mechanism is already event-based, not URL-based:
record-failed-route! stores the event (not URL)override-route! -> re-sends stored eventThe chart never touches the URL. The external layer watches configuration and maps bidirectionally.
A programmer wants (route-to! this UltimateTarget) — not manual sequencing through
chart hierarchies. And child charts should support dynamic code-split module loading.
Instead of passing the child chart to istate at build time (which defeats code splitting),
declare the symbols of reachable targets as metadata:
(istate {:route/target :my.ns/AdminPanel
:route/reachable #{:my.ns/dashboard :my.ns/users :my.ns/user-detail}}
...)
Coupling direction is correct:
routes Uses Reachable Setsroutes reads :route/reachable to auto-generate cross-chart transitions:
;; Direct targets (current behavior)
(transition {:event :route-to.my.ns/main :target :my.ns/main})
;; Composed targets (new — auto-generated from :route/reachable)
(transition {:event :route-to.my.ns/users :target :my.ns/admin-panel}
(script {:expr (fn [_ _ _ event-data]
[(ops/assign ::pending-child-route
{:event :route-to.my.ns/users :data event-data})])}))
:route-to.my.ns/users:my.ns/admin-panel, stores pending child routeistate reads pending route, passes through invoke params:my.ns/usersWith the async processor, this entire cascade completes within one process-event! call.
Parking ensures each level's async on-entry (data loading etc.) completes before the next.
Parent chart
istate {:route/target :my.ns/AdminPanel
:route/reachable #{:my.ns/dashboard :my.ns/users :my.ns/user-profile}}
Child chart (admin-panel)
rstate :my.ns/dashboard
rstate :my.ns/users
istate {:route/target :my.ns/UserManager
:route/reachable #{:my.ns/user-profile}}
Grandchild chart
rstate :my.ns/user-profile
Each istate declares the full transitive set reachable through it. Each level only needs to know "which of MY istates can reach this target?" — a simple lookup.
Child chart validates parent's declaration at startup:
(defn validate-reachable! [parent-declared actual-chart]
(let [actual (find-all-route-targets actual-chart)]
(when-not (= parent-declared actual)
(log/error "Route reachable mismatch!"
{:missing-from-parent (set/difference actual parent-declared)
:stale-in-parent (set/difference parent-declared actual)}))))
Dev-time helper generates the set:
(defn reachable-targets [chart]
(find-all-route-targets (chart/statechart-normalize chart)))
route-to! Stays Simple(route-to! this :my.ns/user-profile {:user-id 42})
One call, one event to the top-level chart. The cascade handles the rest.
The history layer has full read access to all sessions (via ::sc/local-data in the
Fulcro state atom) and controlled write access (only sends events to the top-level chart).
Read direction (state -> URL):
Walk the chain: read parent configuration -> find active istates -> read their child
session IDs (from [:invocation/id target-key]) -> read child configurations -> repeat.
The full active leaf path plus all [:routing/parameters state-id] at each level gives
everything needed to construct the URL.
Write direction (URL -> state): Always send to the top-level chart. The cascade handles reaching the target. If the chart refuses (busy guard), configuration doesn't change, history layer sees mismatch, pushes URL back to match the chart's actual state.
The parent chart doesn't need to proxy anything. Same relationship a debugger has to a running program: full read access, controlled write access through a defined interface.
The current route-table-atom exists solely for URL-to-state reverse lookup. With
externalized history, this moves to the external layer. Even without externalization,
it's an optimization not a necessity — state-for-path already does a linear scan as
first attempt, and realistic route counts (dozens, not thousands) make the scan fine.
rstate, istate, routes) are already session-agnosticprocess-event!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 |