Fulcro Explained: When UI Components and Data Entities Diverge

  1. The standard case: a UI component also defines a Data Entity
  2. A UI-only component
  3. A Data-only component (a.k.a. a query component)
  4. Multiple UI views of a single Data Entity
  5. A Data Entity spread across multiple (sibling) components
  6. Accessing top-level data from a nested component
    1. Link Query
    2. A detached subtree via the multiple-roots-renderer
UI x data tree x Data Entities

Fulcro’s stateful components serve normally both as elements of the UI and to define the data model, i.e. data entities with their unique IDs and attributes. And that is what you want 95% of the time. But what if your UI and data model needs diverge?

We will take a look at what different kinds of divergence between the UI and data entities you might encounter and how to solve them.

Updated: 2021-08-20

The standard case: a UI component also defines a Data Entity

Most of the time a Fulcro’s defsc both defines a data entity such as "Person" and the UI to display it. It has either a static ident(ifier) such as :ident (fn [] [:component/id ::AllPeopleList]) (using the lambda form) if the data entity is a singleton or a "dynamic" (props-dependent) ident such as :ident (fn [] [:person/id (:person/id props)]) (or just :person/id in the keyword form).

A UI-only component

You might want to wrap a piece of UI into a component of its own either to reuse it elsewhere or to simplify the parent component (following the best programming practice of well-named, single-purpose chunks of code). You could achieve the same with just moving it into a separate function but sometimes you actually want a component, e.g. so that it is visible as a separate, named thing in the React world (error logs, React Dev Tools' Components view) or so that it can leverage React’s shouldComponentUpdate to avoid unnecessary re-rendering (as described in the section 5.7. Using defsc for Rendering Optimization of the Fulcro Dev Guide).

If the UI-only component has no stateful descendant than it is trivial. Simple make your defsc with no :ident, :query, :initial-state and otherwise use it as any other Fulcro component. (Obviously you don’t need to include its comp/get-query or initial-state in the parent.)

However if the component has a stateful descendant then you need to make sure that its query and, if required for the initial app state, its initial state is correctly composed (directly or indirectly) into the root component. (Components whose data is dynamically loaded typically do not have initial state.)

Perhaps the cleanest solution is to let the stateful parent of the UI-only component instantiate and control the stateful descendant, composing its query and initial state into its own and passing it as a child to the UI-only child. Thus the data tree is a subset of the UI tree. An example of a similar case, only with the UI-only defsc replaced with a pure React higher-order-component, is in 5.15.1. Fulcro and React HOC where the Root composes CCForm's query and state into its own and instantiates ui-cc-form and passes it to the wrapping ui-stripe-provider and ui-elements.

An example of a stateless Heading with a stateful HeadingUser child, provided by Heading’s parent:

(defsc Heading [this _] ; (1)
  {}
  (header
    (div :.app-name "My Awesome App")
    (div :.username
      (comp/children this)))) ; (2)

(def ui-heading (comp/factory Heading))

(defsc HeadingUser [_ {:user/keys [logged-in? username]}] ; (3)
  {:ident (fn [] [:component/id ::HeadingUser])
   :query [:user/username :user/logged-in?]}
  (div (if logged-in? username "Anonymous")))

(def ui-heading-user (comp/factory HeadingUser))

(defsc Root [_ {:root/keys [heading-user]}]
  {:query [{:root/heading-user (comp/get-query HeadingUser)}] ; (4)
   :initial-state {:root/heading-user {}}}
  (div
    (ui-heading
      (ui-heading-user heading-user)) ; (5)
    (ui-some-more-stuff ...)))
1The stateless sub-component
2Including the parent-supplied stateful child(ren) via comp/children
3The stateful descendant of the UI-only component
4The stateful parent composes the stateful descendant’s query etc. into its own
5…​ and instantiates it and passes it as a child to the UI-only component

A Data-only component (a.k.a. a query component)

It is canonical to create defsc that are only meant to define a Data Entity and not to produce any actual UI, ones that are never rendered as a child.

You would use them to df/load! a particular set of data that does not have an exact match in a UI component - .e.g because it is never used in the UI but you still need it - or to describe the data returned from a mutation (for use with m/returning).

Imagine you have a login mutation that needs to return information about the user’s session:

(defsc Session [_ _]
  {:query [::provider
           ::status
           '*]
   :ident [::authorization ::provider]})

(defmutation login [params]
  ;;...
  (remote [env]
       (m/returning env Session)))

;; Or, better, with Fulcro 3.5+:
(defmutation login [params]
  ;;...
  (remote [env]
       (m/returning env (raw.components/nc
                          [::provider ::status '*] ; or just ['*]
                          {:ident [::authorization ::provider]}))))

(This example is taken from RAD Demo.)

Another example (TODO: Find a better, more realistic example.) - for some weird reasons / optimizations, you want to know the size of each people list, enemies and friends alike, as soon as possible in your application:

;; At a suitable moment, e.g. at app start:
(df/load! app :all-lists (raw.components/nc [:list/id :list/size]))
As of Fulcro 3.5, it is simpler to use raw.components/nc to define (anonymous) query components inline.

Multiple UI views of a single Data Entity

The mapping between defsc and Data Entities does not need to be 1:1, you might have multiple components displaying different views/parts of the same data entity at different parts of the UI tree. For instance you might want to display a short PersonView in a people list and a full PersonDetails when the person in question is selected. The solution is to define the two components with the same :ident. They both will query the ID property and will typically request some different and some shared properties. The data of both will be stored at the same place in the DB. (See 9.1.5. Server Result Query Merging for details about how the data is merged.)

Example:

(defs PersonView [this {:person/keys [id fname hidden?]}]
  {:ident :person/id
   :query [:person/id :person/fname :person/hidden?]}
   (when-not hidden?
     (li (a {:onClick #(comp/transact! [(m/show-person {:id id})])}
            fname))))
(def ui-person-view (comp/factory PersonView))

(defs PersonDetails [this {:person/keys [id fname email age]}]
  {:ident :person/id
   :query [:person/id :person/fname :person/email :person/age]
   :route-segment ["person" :person/id]}
   ;; In practice we would use :will-enter with dr/route-deferred
   ;; and df/load! to load the PersonDetails data...
   (div
     (h4 fname)
     (p "Age: " age)
     (p "Email: " email)))

(defsc AllPeopleList [this {:keys [all-people]}]
  {:ident (fn [:component/id ::AllPeopleList])
   :query [{:all-people (comp/get-query PersonView)}]
   :initial-state {:all-people {}}
   :route-segment ["people"]}
   (div
     (h3 "People")
     (ul (map ui-person-view :all-people))))

(defrouter PeopleRouter [_ _]
  {:router-targets [AllPeopleList PersonDetails]})
;; ...

A Data Entity spread across multiple (sibling) components

You might want to split a single, large Data Entity over multiple UI components, each displaying a distinct part of the entity, instead of creating a single, huge component. This is similar to the Multiple UI views above but in this case you want to display all the sub-components at the same place in the UI tree.

For example you might want to split Person into PersonIdentification, displaying the name and email, PersonDemographics, displaying the age, location, and salary category, etc.

All these properties are directly a part of the same data entity, contrary to standard joins such as :person/children. So how to do this? Pathom placeholders to the rescue! As described there, we use the "magic" :> namespace for our "flat joins", i.e. to introduce an artificial level of structure to our flat data (notice that this is a Pathom invention, for Fulcro it is a join as any other):

(defs PersonDemographics [_ {:person/keys [age location salary-cat]}]
  {:ident :person/id
   :query [:person/id :person/age :person/location :person/salary-cat]}
  (div
    (p age)
    (p location)
    (p salary-cat)))
(def ui-person-demographics (comp/factory PersonDemographics))

(defsc PersonIdentification [this props]
  {:ident :person/id
   :query [:person/id :person/name :person/email]}
  ...)
(def ui-person-identification (comp/factory PersonIdentification))

(defsc Person [_ {:>/keys [demographics identification]}]
  {:ident :person/id
   :query [:person/id
           {:>/demographics (comp/get-query PersonDemographics)}
           {:>/identification (comp/get-query PersonIdentification)}
           #_...]}
  (div
    (h1 "Person")
    (ui-person-demographics demographics)
    (ui-person-identification identification)))

As regarding data fetched, the Person query will be equivalent to :person/id :person/age :person/location :person/salary-cat :person/name :person/email though the data returned will be in the requested structure, i.e. {:person/id "…​", :>/demographics #:person{:id "…​", :age 42, :location "…​" :salary-cat :10k}, :>/identification #:person{:id "…​" :name "…​" :email "…​"}}.

Accessing top-level data from a nested component

Normally only the Root component can see "global" data, i.e. data that does not belong to a Data Entity. For example, Friend is a data entity with props and an id but the set of all friends is not a data entity. You would typically do something like (df/load! app :all-friends Friend) to get the data into the client and could then query for it via {:all-friends (comp/get-query Friend)} in the Root. But you might be imagining a structure like this:

<my app, i.e. Root>
 |- MyFriends
 |   |- Friend 1
 |   \- Friend 2
 |
 |- Some Other Stuff
 ...

i.e. you want to pack all your friends into the MyFriends component and you want to make it responsible for laying out the display of them. Thus the UI-only component pattern is not applicable because you want MyFriends to control the "instantiation" of its Friends children. So MyFriends is the root of its own, separate data tree that we want to place somewhere in the total UI tree. There are multiple ways to do that.

Note: We cannot use the Pathom placeholder :>/ approach as we did above because it only works for data entities, on the side of Pathom, where it is used to introduce an extra level of nesting to a data entity. But here we are working with a non-entity.

The standard solution to access top-level data from a component anywhere in the data tree is Link Queries. From the docs:

There are times when you want to start "back at the root" node. This is useful for pulling data that has a singleton representation in the root node itself. For example, the current UI locale or currently logged-in user.

The official docs provide a good explanation that is not worth duplicating here so refer to it. In our case, we would end up with:

(defsc Friend [_ {:friend/keys [name]}]
  {:query [:friend/id :friend/name]
   :ident :friend/id}
  (dom/li name))

(def ui-friend (comp/factory Friend {:keyfn :friend/id}))

(defsc MyFriends [_ {:keys [all-friends]}]
  {:query [{[:all-friends '_] (comp/get-query Friend)}] ; (1)
   :ident (fn [] [:component/id :MyFriends])
   :initial-state {}} ; (2)
  (dom/ul
    (map ui-friend all-friends)))

(def ui-my-friend (comp/factory MyFriends))

(defsc Root [_ {:keys [friends]}]
  {:query [{:friends (comp/get-query MyFriends)}] ; (3)
   :initial-state {:friends {}}} ; (4)
  (ui-my-friend friends))

(merge/merge-component!
  APP Friend [#:friend{:id 1 :name "Ash"}, #:friend{:id 2 :name "Bo"}]
  :replace [:all-friends])
1The link query
2It is critical that the component with the link query has an initial state (though it can be empty)
3As always, the parent must include the link query component’s query in its own; the key (here :friends) is arbitrary
4Equally, the parent must also include the child’s initial state in its own

A detached subtree via the multiple-roots-renderer

The default Fulcro renderer (as of 3.4.21) is the multiple-roots-renderer, which supports such separate roots/subtrees. You can read about it in the Development Guide under Using Fulcro Component Classes in Vanilla JS (Detached Subtrees) and have a look at the multi-root example card. However a completely detached subtree may, I believe, cause problems if you want to include it in routing (as routers must be connected to the root) and perhaps have some other limitations. But I haven’t tried it and thus cannot really say much.


Tags: Fulcro Clojure ClojureScript

Are you benefitting from my writing? Consider buying me a coffee or supporting my work via GitHub Sponsors. Thank you! You can also book me for a mentoring / pair-programming session via Codementor or (cheaper) email.


Copyright © 2021 Jakub Holý
Powered by Cryogen
Theme by KingMob