Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Assorted notes from learning and experimenting with Fulcro [WIP]

Fulcro Field Notes

Assorted notes from learning and experimenting with Fulcro.

General

From Tony Kay:

[..] a guiding principle that always helps me: Rendering in Fulcro is a pure function of state. Get the state right, and rendering will follow. The query/ident/composition has a shape that exactly matches the expected structure in app state. Y ou do have to reason over that in time, but you need to learn to adapt to look at the data patterns.

Anytime I have a “bug” I first look at query/ident/initial state and see if they make sense, THEN I look at app state and see if it matches what makes sense, THEN I look at my logic and see if it is also working with that same data shape. It’s all about the data.

Tony 1/2021:

So, from a Fulcro perspective, when you want data in some particular place, just ask for it…add the prop where you want it. But Fulcro cannot magically write your server-side query or data initialization to actually make it appear there. Fulcro is very (intentionally) literal. There is no magic on the front end in Fulcro or RAD as far as this question goes. If the data is in your graph db on the front-end, it will be in the rendered component (data + query + ui tree all literally match). Fulcro is about making it easy to initialize (initial state), normalize (via query/ident), denormalize (query + normalized state), and “patch” (via load, data targeting, merge-component) that model…but the normalize/denormalize via UI query is quite literally a marshall/unmarshall kind of thing…like base64 encode/decode.

How does query + DB → props work?

The way Fulcro combines the :query of your componenets and the normalized client DB (fed by :initial-state and whatever load! you have issued) to produce a data tree and passes it down the component tree via props is simple in theory but gave me quite a few headaches in practice. This text should help my past self to avoid them.

Beware that there is no magic involved in turning a component’s query into its props - it is your duty to ensure that parents include children’s queries and initial state all the way up to the root and then pass the correct props down to the children.

A look at a Fulcro component

An example from the Book, using the verbose lambda form of for initial-state:

(defsc Root [this {:keys [friends ; (2)
                          enemies] :as props}]
  {:query         [{:friends ; (1)
                    (comp/get-query PersonList)}
                   {:enemies
                    (comp/get-query PersonList)}]
   ;; :ident (fn [] [:component/id :singleton1]) ; (6)
   :initial-state (fn [params]
                    {:friends ; (3)
                     (comp/get-initial-state
                       PersonList {:label "Friends"}) ; (4)
                     :enemies
                     (comp/get-initial-state
                       PersonList {:label "Enemies"})})}
  (dom/div
    (ui-person-list friends) ; (5)
    (ui-person-list enemies)))
  1. We include the query of the child component using an "EQL join". The key must* correspond to an attribute of the current entity/node. In the case of the Root, the entity is the state DB so there must be a top-level key :friends there.

    • *) An exception to the rule are computed properties (???) *TODO*

  2. The props, supplied by Fulcro in the case of the root component, will then contain a :friends key, which we destructure here (and Fulcro checks that the props we destructure match what we asked for in the query)

  3. Here we want to set the initial state so that we show the two lists and their labels, even if empty. We could also decide to show nothing and leave the state as the default nil

  4. We pass in the params that the child’s initial-state function needs, in this case the label

  5. We pass the relevant part of the data tree as props to the child

  6. The root must not have an ident(ificator) but child components normally have it. It is especially important for non-singleton components such as Person to tell Fulcro which of the props is the ID of the entity and thus how to normalize it: :ident (fn [] [:person/id (:person/id props)]) (or just the short "template form" :ident :person/id if the two keywords are the same, as in this case).

High-level overview of the process

  1. Fulcro initializes the client DB from the root component’s initial state, which you made to explicitly compose the initial state of its children by including …​ :some-child (comp/get-initial-state SomeChild <params>), …​ (or using the shorthand template notion). TODO Code: Get the initial state tree

  2. Fulcro gets the root component’s query which composes the queries of its children (which again compose the queries of their children, all the way to the leaves) thanks to you having explicitly included …​{:some-child (comp/get-query SomeChild)}, …​ for each of the children. The EQL query is just data, going from some root entity and describing what properties we want included. (PersonList wants a label and a list of people, Person wants a name and an age, …​ .)

  3. Fulcro walks the query and the client DB in parallel to construct the data tree corresponding to the query (esentially filling in the values).

    • Gotcha: If it hits a nil node, it won’t continue down the branch - even if a downstream components asks for root data that is there. So you want to set the parent’s initial state to {} instead of the defaul nil if you need it to go down no matter what.

  4. Fulcro sends the data tree to the root component as its props - the component is responsible for extracting the parts its children need and sending them on via their props (typically as the first argument of the defined ui-* factory function).

Gotcha’s

  • A query does not fetch anything, contrary to e.g. re-frame’s subscriptions, it is just (meta)data; you must make sure that it gets included in the root component’s query (via all intermediate ancestors). The data is then passed down by you from the root, via props.

Terms

  • ident of a component/data

  • initial state

  • query

What about Routers?

Routers automatically include the query of each child (:altN (comp/get-query MyTargetN)) and pass the data of the corresponding prop to the target component as props. The router also includes any computed properties so what is happening is something like ((factory MyTargetN) (comp/computed (:alt0 props) (comp/get-computed this))) (where computed is similar to merge).

It has a dynamic query, keeping the current target under ::dr/current-route.

Fixing a Router inside a dynamically-loaded, non-singleton component

What if you have a router inside a component whose data isn’t loaded at start and that has a dynamic ident (such as :person/id[:person/id "something"])? When the component’s data is loaded, you must add the router’s data so that Fulcro can find the router. Breaking this connection to the router leads to rendering issues with the content. A way to fix it is to ensure that you fill any "holes" in the data with the component’s initial data (which should include the router’s) using :pre-merge:

(defsc Person [_ {:person/keys [name details-router]}]
  {:query [:person/name {:person/details-router (comp/get-query PersonDetailsRouter)}]
   :ident :person/name
   :initial/state {:person/details-router {}}
   :will-enter (fn [app {name :person/name}]
                 (let [ident [:person/name name]]
                   (dr/route-deferred ident
                      #(df/load! app ident Person
                                 {:post-mutation        `dr/target-ready
                                  :post-mutation-params {:target ident}}))))
   :pre-merge (fn [{:keys [data-tree]}]
                ;; Merge the data *onto* the initial state:
                (merge (comp/get-initial-state Person)
                       data-tree))}
  (dom/div
    (str "Hello, I am " name)
    (ui-details-router details-router)))
Gotchas

When you load! the data for a component that has a router - the server has no idea about the router and the data loaded will thus remove (or, if loading for a new ident, not add) the router’s initial state - and thus, when Fulcro creates the data tree from the DB and query, it will run into a nil at the router’s join and not fill in its data even though it has them in the DB.

Solution: Use :pre-merge to add the router’s initial state to the data loaded from the server - see [17.4. Initial State](http://book.fulcrologic.com/#_initial_state_2) in the Book.

Common problems and solutions

If you get this warning:

Attempt to get an ASM path [..] for a state machine that is not in Fulcro state. ASM ID: <some router component>

it is mostly harmless, indicating that you are rendering routers before you’ve started their associated UISMs (repeated on every render until the SM starts). Tony suggests:

You can use app/set-root! with initialize state, then dr/initialize! or dr/change-route!, then app/mount! with NO initialize state to get rid of most or all of those. Basically: Make sure you’ve explicitly routed to a leaf (target) before mounting.

I would also recommend to start Root with :ui/ready? false and switch it to true in a transaction that you issue after a call to dr/initialize! (which itself issues transactions to start all the routers in the query). (Remember, transactions are executed in-order.) In its body you would render "Loading.." while not ready?. (You might also want to wait for loading some initial date before switching it on.)

Corner cases & riddles

  1. Display :friends list (if you only have :list/id → :friends → data)

  2. Inside Person, have sub-components Age and Name showing parts of the entity (normally, component = entity)

  3. Accessing a root entity from a nested component (a Link Query) and the importance if init. state

  4. What about non-data components (a Router or a Grouping) in the middle of a component tree w.r.t. query composition and props propagation (see routers - make up kwds, include queries…​?)

Quiz

Given the Person - PersonList - Root components, how will the DB differ if we remove :idents from the components?

Think about it first…​

With idents:

{...
 :enemies [:list/id 1],
 :friends [:list/id 0],
 :list/id
 {0
  {:list/label "Friends",
   :list/id 0,
   :list/people [[:person/id 1] [:person/id 2]]},
  1
  {:list/label "Enemies",
   :list/id 1,
   :list/people [[:person/id 3] [:person/id 4]]}},
 :person/id
 {1 {:person/id 1, :person/name "Sally", :person/age 32},
  2 {:person/id 2, :person/name "Joe", :person/age 22},
  3 {:person/id 3, :person/name "Fred", :person/age 11},
  4 {:person/id 4, :person/name "Bobby", :person/age 55}}}

Without idents we get the initial state tree as-is because there is no normalization (and we could omit all the :*/id keys as they aren’t used for anything):

{:friends
 {:list/label "Friends",
  :list/id 0,
  :list/people
  [{:person/id 1, :person/name "Sally", :person/age 32}
   {:person/id 2, :person/name "Joe", :person/age 22}]},
 :enemies
 {:list/label "Enemies",
  :list/id 1,
  :list/people
  [{:person/id 3, :person/name "Fred", :person/age 11}
   {:person/id 4, :person/name "Bobby", :person/age 55}]}}
What is wrong?

There is no warning anywhere but an attempt to route fails with st. like "no route target" and the DB has no routing data.

:query [:tmp/router (comp/get-query OffboardingIdRouter)]

A: {..} around

Missing router props form a 2nd load

Upon initial load, everything is OK. After I load! in data, the data for the child router is suddenly not there (though it is in the query and DB).

Any idea why my router is getting nil props? This is the relevant part of the Root query:

...
:tem-organization/organization-number
 {:tem-organization/latest-bill-run
  [:bill-run/id
   {:ui/subscribers-list-router
    [:com.fulcrologic.fulcro.routing.dynamic-routing/id
     [:com.fulcrologic.fulcro.ui-state-machines/asm-id
      :minbedrift.ui/SubscribersListRouter]
     {:com.fulcrologic.fulcro.routing.dynamic-routing/current-route
      [*]}
     {:alt0
      [{:bill-run/subscribers
        [:br-subscriber/subscriber-id]}
       [:ui.fulcro.client.data-fetch.load-markers/by-id _]]}]}]}]}]

but when I resolve it against the app state (com.fulcrologic.fulcro.algorithms.denormalize/db→tree), it completely lacks the :ui/subscribers-list-router part. :ui/* keys are removed only from server-side queries, no? The data I get includes:

...
{:tem-organization/organization-number "987699321",
:tem-organization/latest-bill-run
{:bill-run/id 47143}}

The router is in my DB.

A: The load! loads the entity but somehow the key :ui/my-router, set originally correctly from the initial state, gets removed. Init DB: :bill-run/id → nil → :ui/subscribers-list-router = [::dr/id :../SubscribersListRouter] DB after the load: Same but added the loaded bill run, w/o the router: :bill-run/id → 53518 → various :bill-run/* keys, no :ui/*

Troubleshooting facilities

It has to be query/ident/destructuring composition, or that the data isn’t linked in the db. There’s really nothing else.

Get a component’s props (see The Book - The Component Registry):

(-> (comp/get-indexes app.application/SPA) :class->components :app.ui.root/OffboardingPages first comp/props)
(comp/class->any SPA root/Root)
(comp/registry-key->class :app.ui.root/Information)
(comp/registry-key->class `app.ui/Root)

(see also comp/ident→any / ident→components)

(comp/get-query Root) ;; must include children (check metadata)
(comp/get-initial-state Root) ;; e.g routers require non-nil init state to work

;; Query manually against the *client* data
(let [state (app/current-state app.application/SPA)]
    (com.fulcrologic.fulcro.algorithms.denormalize/db->tree
      (comp/get-query Root)
      state
      state))

@(::app/state-atom SPA)

(comp/get-query root/Root (app/current-state SPA))

(let [s (app/current-state SPA)]
    (fdn/db->tree [{[:component/id :login] [:ui/open? :ui/error :account/email
                                            {[:root/current-session '_] (comp/get-query root/Session)}
                                            [::uism/asm-id ::session/session]]}] {} s))

Pathom

(get env ::pc/indexes) in any resolver ⇒ what resolvers and keywords it knows

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment