Articles on Clojure/Script architecture and patterns
With Fulcro the props always flow down from the base of your UI's tree structure. Thus all components/views will be refreshed, even those that are not currently visible. Introducing Fulcro's own router into your code-base is how you stop non-visible components being unnecessarily refreshed.
Re-frame doesn't have this story, so my thought was to not use any router until I hit a problem, despite the examples usually using a router. Initially I had 'navigation' app-state kept in many top level keys. Then I put them all under a key. Not much better! The third iteration, and the point of this post, has one key that describes the current path through the tree as a series of steps, from base to leaf. I used the key :at . Example two step app-state map-entry:
:at [[:tab :hamburger] [:hamburger :status]]
With this in place there's one reactive subscription for your app and one data-structure for user navigation events to alter.
You can have events that go to the base, across, or down to a new deeper level. Your views can pick apart the data-structure returned from the one subscription. Both the events and the queries upon this data-structure can be done with generic names, making the navigation flow of your app easy to read and easy to navigate from your IDE. The query functions I've needed so far are leaf-keyword, base-step, leaf-step, equals and predicate.
I'm proposing making a small Re-frame only client router library out of the code I'm using now. I say Re-frame only but this router will be easily adaptable to Reagent as well, directly using a ratom rather than a subscription. The rest of this article will be showing the code in use. It needs a name so Atrun, short for 'All the router U need'. Just a prospective name, I'll change it as soon as I find out its a lie!
Refactoring from 'something else' to this scheme can at first be done additively. The dispatch-able event in some view before:
[::r-events/inject-true [:nav :my-shop?]]
After:
[::at/down
[:hamburger :my-shop]
[[r-events/inject* [[:nav :my-shop?] true] true]]]
Explanation time. Fulcro has a convention that a function that takes and returns app-state end with a *. All reg-event-db events can call such functions and be done:
(defn inject* [st path value]
(s/setval path value st)
(reg-event-db
::inject-true
(fn [st [_ path]]
(inject* st path true)))
All the at events can take extra st->st functions. Mutating state in other ways whilst navigating is the bread and butter of any UI program. Here we are just making sure we are doing no harm in the first round of refactoring. The :nav was the legacy way of storing navigation state, so events and subscriptions around it will eventually be deleted. Here's a more expansive example to complete this explanation:
(defn check-out-button [bill-entity till-id disabled?]
[:button
{:on-click #(dispatch [::at/across :bill 1
[[wc-till-events/check-out* [:phone bill-entity till-id] true]]]
#_[::wc-till-events/check-out :phone bill-entity till-id])
:disabled disabled?
:class (->class :phone/bottom-action-button)}
"Check Out"])
Just to tidy up, for the curious, s is the alias for Specter and ->class is what I use as the entry-point for ShadowCSS. And the true that you see here:
[wc-till-events/check-out* [:phone bill-entity till-id] true]
, is saying that check-out* takes app-state as its first arg - false would mean last arg. Lastly the second arg to the event ::at/across is a number. Here the number is 1 - which means that internally there's an assert to check that :at is already at the base of the tree.
For me every change of route needing to have a URL browser representation is too much to think about whilst building an application. Atrun is simple, designed for when your application lives inside a single URL. The :at path directly represents a way through your UI's tree structure without the translation layer of mapping URLs to routes and back again. You get simpler code, fewer concepts to learn, and no impedance mismatch between your navigation state and how you actually think about your interface hierarchy.
Atrun's design makes navigation events composable with arbitrary state mutations through the st->st function mechanism. This is great for real-world UIs where navigation rarely happens in isolation - you're usually also clearing selections, loading data, or updating multiple parts of state. With Atrun, these concerns flow together naturally in a single event dispatch. The result is navigation code that reads like what it does: "go down to the checkout screen, and also trigger the checkout process with these parameters."
Is Atrun more of a navigator than a router? Sure, but it is still all the router you most likely need. And when browser history and shareable URLs become requirements, Atrun's path-based design should make it easy enough to add bi-directional synchronization between the :at vector and the URL bar - but only where it is needed.
Published: 2025-12-07
Tagged: Re-frame ClojureScript