Advanced Minimalist Fulcro Tutorial
Author: Jakub Holý & contributors
A follow-up to the Minimalist Fulcro Tutorial that focuses on a few non-essential yet often necessary topics in the traditional minimalist way.
This is work in progress at an early stage. You can subscribe to this issue to get notified of significant updates. |
Tips for learning Fulcro
Read the docstrings of Fulcro’s functions and vars. They are typically very good and insightful.
Don’t be afraid to read the code of Fulcro itself. Even if you won’t understand everything, you will gleam valuable insights. It is OK to only skim over and focus on the parts you can understand. Have a look at defsc
and defrouter
and the underlying functions, transact!
and load!
etc. You will be better for it.
Troubleshooting Fulcro
It is essential for your productivity that you get good at troubleshooting Fulcro, so that you can detect why something does not work as expected. A key part of that is understanding Fulcro enough to know where to "zoom in" and leveraging Fulcro Inspect as much as possible - especially DB, DB Explorer, Transactions, and Network. I’d claim that:
In Fulcro, the UI is a true function of data and in 90% cases you only need to look at the data and their connections - and perhaps transactions - to understand what is wrong. In 90% of these cases that beginners encounter, the problem is a broken connection between data1.
1) Examples of breaking connections: Not including
get-query
in the parent’s query, not setting/propagating up initial state where it is necessary.
Hopefully you have got somewhat proficient with Fulcro Inspect when doing the Fulcro exercises. Remember to play with Fulcro Inspect and dig around your application with it to really understand the app while it is working well and as you are changing it so your skills will be sharp when you need them. The Learning Fulcro - troubleshooting demos - a series of short screencasts demonstrating how to troubleshoot various problems in a Fulcro app - can help you.
Finally, remember to consult the Fulcro Troubleshooting Decision Tree, which guides you to get from a problem to the most appropriate troubleshooting steps. I would also highly recommend that you use my library fulcro-troubleshooting to receive feedback about possible Fulcro problems early and visibly. Also watch out for Fulcro warnings in the browser Console.
Remember that only rarely should you be clicking around your UI and observing its changes. In Fulcro, virtually all user actions can be triggered from the REPL using transact! . It is also possible to observe and manipulate app data directly with the same functions that Fulcro uses - for example to simulate turning a query into the props tree via db→tree . It is also easier to observe effects on the data in the client DB than in the UI itself. Use these tools instead of the UI - they are more more efficient and allow you to zoom in on the problem much more precisely.
|
What to do when UI Components and Data Entities do not match 1:1
In the simplest case, each UI Component such as Player and Team corresponds to a data entity (player and team, respectively). But often that is not the case. What if I want to wrap Player in a CoolAnimationThingy, which has no data entity? What if I want to show only a PlayerSummary inside the Team and only display PlayerDetails in a popup after the summary is clicked? Here both the components display different views of the same data entity.
The article Fulcro Explained: When UI Components and Data Entities Diverge explains this well, and you should go and read it now. A brief summary follows:
- A UI-only ("stateless") component, such as the CoolAnimationThingy, with no query of its own
-
Solution: The nearest stateful (with a query) ancestor component (Team in our example) becomes the true parent of any stateful children of the stateless component, composes their query and initial state, creates them and passes them to the stateless component as its children.
- A Data-only component (a.k.a. a query component)
-
This is mostly only necessary when describing to Fulcro the data returned by a mutation, if it does not match an existing component. Solution (since Fulcro 3.5): use
rc/nc
; ex.:(com.fulcrologic.fulcro.raw.components/nc [:user/id :user/email])
. - Multiple UI views of a single Data Entity
-
This the case of the PlayerSummary and PlayerDetails and it is simple: they both use the same
:ident
(here,:player/id
) and query for whatever they need. - A Data Entity spread across multiple sibling components
-
Imagine you have a large data entity with many attributes. Displaying it in a single component would make it huge and hard to understand so you want to split the UI into multiple components, each displaying only a part of the entity. It differs from the "multiple views" by that they are all children of the <Entity> component and you want to load all the data at once. The solution is actually in Pathom: for each of these "virtual" children, include their query in the actual entity component as usual but under a "made-up" join key starting with
:>/
(the habitual Pathom "placeholder" ns). The query[.. {:>/dummy [:person/id :person/name]} ..]
is effectively the same as`[.. :person/id :person/name ..]
. - Accessing top-level data from a nested component
-
Sometimes you have a piece of data that you need at multiple places in the UI tree, loaded at the top of the client DB, for example
:current-user
(to display her name in the top bar and to only show the buttons allowed by her permissions everywhere). The simple solution is link query: instead of including in your query[… :current-user …]
(which would only work in the root component), you would include[... [:current-user '_] ...]
, which is an ident with the magical value_
meaning "give me the property’s whole value as-is". In more extreme cases, you might need to split your UI into multiple independent parts, using Fulcro’s multiple-roots-renderer. - Sharing data between diverse components on the page
-
If you have a piece of data that multiple components, at different parts of the UI tree, need to access then the simplest solution is to put the data or a reference to the data into the root of the client DB and to use Link Query to access it from those components.
- Inserting a stateful UI component between a parent-child entities
-
This section essentially explains why inserting a router component into your UI does not break the query. You should not need to do this yourself but it will help you understand routers.
Routing
In a single page application, you want to enable the user to navigate to parts of the application and to only display these "active" parts. You might also want to only load the necessary data when you are are about to display the relevant part of the UI. Often you also want to bind the route to the URL. All this is the task of routing.
Dynamic Routing is not always the only or best solution. Sometimes it is simpler to implement "routing" manually by having something like (case my-route :target1 (ui-target1 ..) …) in the "routing" component. If you want to reflect the do-it-yourself route in the URL, you can do it manually e.g. using pushy. You could even transit- and base64-encode your manual routing state and put it inside a query param. And if you want to load data on-demand, you can still do that - for example in the :onClick handler that triggers the route change. Dynamic Routing is truly necessary when you want to avoid over-fetching data from the client DB and/or when you only want to load code for the active route.
|
Fulcro’s Dynamic Routing enables you to:
-
Make Routers that have multiple target components and only display the active one (i.e. the one you have routed to)
-
The routers can be nested, e.g. to display a particular "page" and then a particular "tab" within it
-
Bind the route to the URL (Fulcro’s routing is fully independent of the URL but the two can be hooked together)
-
Load the data just before the component is about to be displayed
-
The target component can dynamically decide whether to allow the user to route to it or to route out of it (e.g. you do not want to allow the user to leave a half-filled form until she either saves it or cancels the edit)
-
Split the code so that parts of it will only be loaded when the part of the application they define is going to be displayed
-
Only query for the props of its current target from the client DB, which is important for the performance of bigger apps
-
Display a loading indicator after a short delay and to mark the route as failed if it does not complete within a time limit
The bare essentials of Fulcro routing
To add routing to your application, you need to:
-
Create a router using
dr/defrouter
and give it the list of the target components, only one of which will be displayed at a time. Notice that there is no magic indefrouter
- it just produces an old gooddefsc
-
Give each target component a unique route segment so that you can route to this target. You can think of the route segment as a relative URL. It starts with a non-empty literal string and may contain other strings and placeholders (keywords) for capturing dynamic route parameters such as IDs. They do compose, if you have multiple levels of nested routers
-
The component can use the
:will-enter
component option function to access the route parameters (which are always strings) and to translate them into the actual ident via(dr/route-immediate <ident>)
orroute-deferred
-
-
Initialize everything and then route to the target you want displayed via
dr/change-route
Let’s explore a minimalist example of routing. This is the UI that we are creating:
We show either all the people or just a single person. This is the code:
(defsc AllPeople [_ {:keys [all-people]}]
{:ident (fn [] [:component/id ::AllPeople])
:query [{:all-people [:person/id :person/name]}] ; (1)
:initial-state {} ; (1)
:route-segment ["all"]} ; (2)
(dom/div
(dom/h3 "All People")
(dom/ul
(mapv (fn [{:person/keys [id name]}] (dom/li {:key id} name))
all-people))))
(defsc Person [_ {:person/keys [id name biography]}]
{:ident :person/id
:query [:person/id :person/name :person/biography] ; (1)
:initial-state {} ; (1)
:route-segment ["person" :person-id] ; (3)
:will-enter (fn [app route-params] ; (4)
(dr/route-immediate ; BEWARE: `db :person/id <id>` must already exist
[:person/id
(js/parseInt (:person-id route-params))]))}
(dom/p (str "Person #" id ": ") (dom/strong name) " - " biography))
(dr/defrouter MyRouter [_ _] {:router-targets [AllPeople Person]}) ; (5)
1 | A router target must always have a query (use the "give me everything" query ['*] if it doesn’t need any) and a non-nil initial state (so at least an empty {} ) |
2 | A target’s :route-segment assigns it a routing path (relative to an ancestor target, if any) |
3 | The :route-segment may also contain placeholders to capture parameters from the route (here :person-id ),
which can then be accessed in :will-enter under that keyword |
4 | A target may use the :will-enter component option function to access route params and to tell Fulcro which ident
to route to (here via dr/route-immediate ) |
5 | defrouter creates the router component. It needs at least the list of its targets in :router-targets |
will-enter can be called multiple times as part of the route resolution algorithm and MUST NOT side-effect in its body. Use dr/route-deferred and do any necessary side-effects in the completion function passed to it, which is guaranteed to be called only once.
|
(def ui-my-router (comp/factory MyRouter))
(defsc Root [this {:ui/keys [router]}]
{:query [{:ui/router (comp/get-query MyRouter)}] ; (1)
;:query [{:ui/router (comp/get-query MyRouter)}
; [::uism/asm-id ::MyRouter]] ; (8)
:initial-state {:ui/router {}}} ; (2)
;(let [router-state (or (uism/get-active-state this ::MyRouter) :initial)] ; (8)
; (if (= :initial router-state)
; (dom/div :.loading "Loading...")
(dom/div
(dom/p (dom/button {:onClick #(dr/change-route! this ["all"])} "All") ; (3)
(dom/button {:onClick #(dr/change-route! this ["person" "123"])} "Person 123"))
(ui-my-router router))) ;))
(defn init [app]
;; Avoid startup async timing issues by pre-initializing things before mount
(app/set-root! app Root {:initialize-state? true})
(dr/initialize! app) ; (4)
(run! #(merge/merge-component! app Person %
:append (conj (comp/get-ident AllPeople {}) :all-people))
[#:person{:id 100 :name "Kamča" :biography "..."}
#:person{:id 123 :name "Doubravka" :biography "..."}])
(dr/change-route! app ["person" "123"]) ; (5)
;; Note: 👆🏿 will warn https://book.fulcrologic.com/#warn-uism-sm-not-in-state; ignore! (7)
;; or: (dr/change-route! app (dr/path-to Person "123")) ; (6)
;; or: (dr/change-route! app (dr/path-to Person {:person-id "123"})) ; (6)
(app/mount! app Root "app" {:initialize-state? false}))
1 | You must compose the router’s query into its parent, under an arbitrary name (the :ui/ ns is practical so that we do not unnecessarily include it in queries sent to the server) |
2 | You must compose the router’s initial state into its parent (notice we use the template mode of initial state so the {} actually means "include the router’s initial state") |
3 | We use dr/change-route! with the absolute route (concatenation of all route segments along the way from Root) to display the desired component |
4 | When we start the application, we need to initialize the dynamic routing subsystem with dr/initialize! |
5 | It is highly advisable to also make sure each router in the app is "routed" and thus in a well-defined state before it is ever displayed. Here we achieve that by routing to it in the init function. This can only be done after the application’s state is initialized, i.e. either app/set-root! with {:initialize-state? true} or app/mount! But it can still cause a "flickering" of the UI since the effect of the change-route! call is asynchronous. Below in the point (8) you can see how to postpone displaying the UI until the route has been applied. |
6 | Alternatively, instead of hard-coding the route as a vector of strings, we can use (path-to & targets-and-params) with component names to get the route - which is easier to navigate and to refactor. In the map form, there is just a single map with all the route’s params at the very end. |
7 | Note: As of Fulcro 3.5.6, you are likely to get the Attempt to get an ASM path… error for the router because dr/initialize! is asynchronous and does not finish before the dr/change-route! call. You can safely ignore it. |
8 | The Fulcro book advises: “An even better approach is to use state to block rendering until such time as a route or load is ready just by looking at the current state of your top-most router’s state machine.” We can do that by querying for the top router’s UI state machine’s state and displaying something appropriate such as "Loading…" while it is :initial or nil . (In some cases you might instead want to maintain a custom :ui/ready? flag on the Root component. You can leverage the fact that all transact! calls are executed sequentially so if you transact a mutation that sets it to true _after you have called dr/change-route! then you can be sure that it happens after the route is applied.) |
See the full code here.
Binding the route to the URL
Reflecting the current route in the URL and changing the URL according to the route is simple. We just need to hook into the HTML 5 History API to call change-route!
when the URL changes and to create our own routing function that also updates the URL before calling change-route!
. Mapping from the URL to the route and vice versa just requires replacing /
with spaces and vice versa. You can look at Fulcro RAD’s routing/html5-history
for inspiration, namely at how it updates the URL via .pushState
and how it uses .addEventListener
for the "popstate"
event to get notified of URL changes.
Sometimes you might want the URL to differ from the Fulcro route, primarily to make it nicer for the user. Remember that a route segment must start with a non-empty string and it might be better to ensure that all route segments are unique so that no two route segments would match the same route. For example you might have the routes ["all"] and ["id" :student-id"] but want to display them as the URLs / and /<student id> . You should also always route to a leaf target (discussed later on), such as ["company-page" "apple" "overview-tab"] but might not want to force the user to type the whole URL, so that /company-page/apple would actually route to its overview tab. In these cases you need to add extra logic to your URL <> route mapping code so that the user-friendly URL is translated to the stricter route and vice versa.
|
The simplest solution is to include fulcro-rad as a library and to use its com.fulcrologic.rad.routing/route-to!
instead of change-route!
. You also need to install-route-history! during app initialization and perhaps also set the route at startup (and not only when it is changed thereafter). Remember that RAD is just an addon library to Fulcro and you can choose and pick whatever parts you want.
If you do not want to include RAD or if you like to learn via video, have a look at Fulcro – Part 15: Sessions and UI Routing that shows how to do the integration manually (though with a helper history library). You may also find useful these time-stamped notes from the video by Alex Eberts.
Chris O’Donnell has a nice, short, clear post about how to bind the URL and Fulcro routing together using Pushy.
To make sure the user can reload the URL from the server, make sure that any path other than images, JS etc. falls back to the application’s HTML page - see how fulcro-rad-demo does it. |
Loading data when a component is routed to (for the first time)
Often we only want to load data when it is actually necessary, i.e. when the component that needs them is being displayed. In React it is achieved via the componentWillMount
hook. In Fulcro we have a better solution if the component is a router target - namely its :will-enter
component option function. A key disadvantage of componentWillMount is "cascading loads", when a component A loads its data and renders its child B, triggering B’s own load etc. In Fulcro we route to a leaf target and thus it and all intermediaries can start loading data in parallel. Let’s see how it looks.
(defsc Person [_ props]
{:ident :person/id
:query [:person/id :person/name]
:initial-state {}
:route-segment ["person" :person-id]
:will-enter (fn [app route-params] ; (1)
(let [ident [:person/id (-> route-params :person-id js/parseInt)]] ; (2)
(if (get-in (app/current-state app) ident) ; (3)
(dr/route-immediate ident)
(dr/route-deferred ; (4)
ident
;; Load the data (or mutation that merges them in) in a fn:
#(df/load! app ident Person
{:post-mutation `dr/target-ready
:post-mutation-params {:target ident}})))))}
...)
1 | Use :will-enter , which is called when it is decided to route to the target (and must return route-immediate or route-deferred ). Beware that it may be called multiple times and must not side-effect. |
2 | Construct the ident of the component that will be displayed, leveraging the provided route-params
(as defined by the :route-segment ) |
3 | If the data has already been loaded previously then route to the component immediately. Otherwise load it first. |
4 | Return route-deferred with the ident and a completion function that will be executed shortly by Fulcro, and which needs to eventually trigger the dr/target-ready post-mutation to tell Fulcro that the component is ready. Here the completion function does load the data and uses load! 's :post-mutation and :post-mutation-params to make sure that target-ready is transacted after the load. |
This is also called deferred routing and you can read more about it in the Fulcro book. Here is a complete, working example you can play with.
On router timeouts and errors
What happens when loading data in a deferred route takes too long? What if it fails? The router has you covered:
(dr/defrouter MyRouter [_ {:keys [current-state route-factory route-props]}]
{:router-targets [AllPeople Person]}
;; The body of the router is displayed only when the target is not ready,
;; i.e. in one of the states below (unless you set `:always-render-body?`)
(case current-state ; (1)
nil (println "MISTAKE: MyRouter is displayed but has never been routed to yet") ; (2)
:pending (dom/div "Loading...") ; (3)
:failed (dom/div "Failed!") ; (4)
(println "Should never come here:" current-state))) ; (5)
1 | The body of the router is only displayed when the target is not ready (unless you set :always-render-body? true ). Thus the only router states we will ever see here are those listed below. |
2 | current-state = nil is the initial state, before the router has been routed to - i.e. when you failed to initialize it properly via change-route! , as described above (there is also the :inital state but I have not seen it in practice) |
3 | :pending is active for a deferred route, after the :deferred-timeout passed but before the :error-timeout - it also ends if dr/target-ready is transacted for the target ident |
4 | :failed if the deferred route has not become ready (as indicated by dr/target-ready ) within the :error-timeout since the route event |
5 | We do not expect to see any other states, namely :routed - because in this state the target itself is displayed and not the router’s body (unless we set :always-render-body? true ) |
Key points to know:
-
There are two timeouts that control the router’s behavior with respect to deferred routes,
:deferred-timeout
(default 20ms) and:error-timeout
(default 5s). You can override the default values when callingdr/change-route!
, see its docstring. -
You can use the body of the router and its
current-state
. It is only displayed when the target is not "ready" to be displayed -
The router switches from displaying the previous target to displaying the router’s body while waiting for a pending target only after the
:deferred-timeout
has passed (but it switches to the target immediately when it gets ready) -
If the mutation
(dr/target-ready {:target <ident>}
is not triggered within:error-timeout
ofchange-route!
then the state becomes:failed
(the target can still become "ready" later, if the load eventually finishes, and the target will be displayed) -
Thus we can use
:pending
to display a loading indicator, if it takes longer than the:deferred-timeout
. We can use the:failed
state to show an error message telling the user it is either taking too long or failed
Lazy loading elsewhere in Fulcro
Lazy-loading data in :will-enter
is just one of possibilities. You can always use :without
and load-field!
to limit what is loaded when and use events such as the user hovering over an element or triggering navigation to load additional data. You can either use your custom mutations for the relevant user actions and add the load there or/and make your own version of change-route!
that does custom data loading. You can leverage load markers to track whether the load is ongoing or has failed.
Nesting routers
Having a UI tree with multiple levels of nested routers like this:
Root PageRoute WelcomePage SportPage, route `["sport" :sport]` Teams Team TeamRouter TeamStats, route ["team-stats"] Player, route ["player" :player-id]
we can route to a leaf router including the full route:
(dr/change-route! app-or-component ["sport" "hockey" "player" "123"])
;; We could also leverage path-to:
(dr/path-to app-or-component SportPage "hockey" Player "123")
; => ["sport" "hockey" "player" "123"]
(dr/path-to app-or-component SportPage Player {:sport "hockey", :player-id "123"})
; => ["sport" "hockey" "player" "123"]
Somewhere under a router target, we can also use relative routing:
;; e.g. inside Teams:
(dr/change-route-relative! teams-this ["player" "123"])
We can even route from one target to another sibling target by prefixing the relative route with :…
:
;; Inside Player:
(dr/change-route-relative! player-this [:... "team-stats"])
IMPORTANT: It is essential that each router in the app is in a routed state before it is displayed. Always route to a leaf target! (Or do so at least once, e.g. during app initialization.) (A router will display its first target by default but it is not the same as routing to it manually and some things will not work.) See 20.6.1. Partial Routes for details.
You can hae a look at the nested_dynamic_routing_tree demo included in Fulcro’s source. |
Multiple routers side by side
Aside of nesting routers inside each others, you might also in some cases want to have multiple sibling routers. This is a topic too rare and advanced even for this tutorial, so I will just refer you to the Fulcro Dev Guide’s 20.8. Simultaneous On-Screen Routers for details.
A router inside a newly load!
ed component
Imagine you have the non-singleton component Person, with an ident like [:person/id "<some-value>"]
. Inside Person, you want to show routable tabs with different groups of information about the person (job info, health history, family, …). So you decide to use a router for this.
But there is a problem. It is likely the component’s data wasn’t part of the initial state of the application and only was load!-ed later. Thus it will not be "linked" properly to the router, i.e. its data in the client DB will be missing something like :<the router prop you made up> [::dr/id :<YourRouterComponentName>]
. For components that exist at the application start, this link is established by including the router property in their initial state and composing it up to the Root, as we discussed above, but here you need to establish the link yourself. You add the "link" manually using :pre-merge
, which is a component option holding a function called by Fulcro before normalizing and merging incoming data. Pre-merge gets the loaded, denormalized data and returns the same denormalized data, with you custom modifications. See here:
;; Somewhere in the app: `(df/load! app [:person/id 123] MyPerson)`
(defsc MyPerson [_ props]
{:ident :person/id
:query [:person/id :ui/person-router ...]
:initial-state {:ui/person-router {}} ; (1)
:pre-merge (fn [loaded-data-tree :data-tree, current-state-map :state-map}] ; (2)
(merge (comp/get-initial-state MyPerson) ; (3)
{:ui/person-router (get-in current-state-map (comp/get-ident PersonRouter {}))} ; (4)
loaded-data-tree))}
(ui-person-router (:ui/person-router props)))
1 | The parent of a router must compose its initial state into its own (and so on all the way up to the Root) |
2 | Pre-merge gets the (denormalized) tree of data from df/load! and the current value of the client DB (a.k.a. state map) |
3 | We include the component’s initial state, which composes the initial state of the router |
4 | We make sure to preserve any state the router might already have (which we must denormalize) |
Read more in the Fulcro Book, 20.4. Composing the Router’s State into the Parent.
Routing-related component options
We have already seen :will-enter
but there are also other optional component options you might want to know about: :route-cancelled
, :will-leave
, :allow-route-change?
, :route-denied
.
Advanced queries: recursive, union
Union queries
BEWARE: Combining recursion and unions (e.g. a parent with a union query and a child with a recursive one) might not do what you expect - the union parent, not the child will be the target of the recursion.
So unions are a bit fiddly. They are really mean to address the common case of heterogeneous lists of things, and since there is really only one thing in the database for a given resolution, but two components (the union and the child) there is a problem dealing with recursive things. Should the recursion be the union or child? Unions ended up being a relatively lightly used feature, and recursion on unions even less so. As a result this question never got more attention. It is perfectly valid to want either. But the system is coded to assume you probably want the way it is used, because UI recursion is usually a tree of the top-level heterogeneous thing.
Avoiding unions:
I typically avoid unions in this kind of scenario and just make a component that queries for all the possible things the nodes might have, and generalize to a :node/id for normalization, and a type field for switching up rendering within the component. (A multimethod is good for the latter.)
Don’t nest unions. It is complicated, untested, might blow up. |