Fulcro Explained: When UI Components and Data Entities Diverge

  1. Divergences and Fixes
    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
    7. Sharing data between diverse components on the page
    8. Inserting a stateful UI component between a parent-child entities
  2. Adapting backend data to the UI components' structure
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-11-6

Divergences and Fixes

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):

Pathom placeholders
Figure 1. Illustration of how Pathom processes placeholder key joins in a query
Frontend code
(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

Normally all components are composed into parent components all they way up to the Root, including their queries. However sometimes you would like to render a "detached subtree", i.e. a component that has access to Fulcro state but is not composed into the Root. As of Fulcro 3.2 you should use React Hooks and use-component for this - see 18.4. Fulcro Hook Components from Vanilla React for details. (The example there uses raw.application/fulcro-app but you can just as well use the same fulcro-app that the rest of your application uses.)

The use-component hook makes it essentially possibly to turn a component with a query into props - in a similar way as Root’s query is turned into its props by Fulcro itself.

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.

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 (as described above) to access it from those components. A typical candidate is the current user session or some user’s selection that multiple components need to see, for example "current student" in a student management webapp.

Use df/load!'s :target to relocate the data or add an extra link to it (e.g. :target [:selected-student]) (read load!'s docstring to learn about details).

Example of shared, root data (some normalized, some not)
:preferred-number 42
:selected-colors [:red :blue :green] ; beware: avoid st. looking like an ident
:locale-lookup {"no" [:locale/id "no"], "cs" [:locale/id "cs"]}
:selected-student [:student/id 123]

Then, any component’s query can be :query [... [:selected-student '_]] (and you can of course also make it a join, to select the attributes of the student you want: {[:selected-student '_] [:student/email]}).

Inserting a stateful UI component between a parent-child entities

What if you want more structure in the UI then is present in the data? For instance if you insert a Fulcro’s Dynamic Router between two components or if you want to have a separate component to render a list of children. Sometimes you can solve it with Pathom placeholder (see above), which is simplest, but sometimes you need a different way. Let’s explore that.

Imagine you have a Team that has a number of Players and, instead of showing all the players, you want to show them as a "slide show", i.e. just one at a time (likely with buttons to navigate to the next/previous). So while before you had this UI tree:

Team
  Player

now you have:

Team
  Router
    Player

How can this work? In the data, a team has a list of players but a team has no "router" (problem 1️⃣) and how would the router know what players the parent team has (problem 2️⃣)? The Team can query for [:team/id {:team/players (get-query Player)}] but if we change it to [:team/id {:ui/router (get-query Router)}] (where the Router query itself is something like [::dr/id {::dr/current-route (get-query Player)} …​]), where did the players go and how can the Player inside the Router access the player entity? A router is just a router and has no players?!

The key to understanding is that the normalized data Fulcro stores in fact represent a graph and we can easily add extra "edges" to it. In this case we add an edge from the Team to the Router under :ui/router(achieved by including the router’s initial data in the Team or via pre-merge) - answering 1️⃣ - and another one from the router to the Player it should display under ::dr/current-route (set by (dr/change-route …​), in this case together with Player’s :will-enter and its (dr/route-immediate <ident>)) - answering 2️⃣. The following figure illustrates it:

Demo of a data graph before/after inserting a router

And this is how it looks in the client DB:

:team/id   {1 {:ui/router [::dr/id ::Router], :team/players ...}}
::dr/id    {::Router {::dr/current-route [:player/id 4], ...}}
:player/id {4 {...}}

and thus the query [:team/id {:ui/router […​ {::dr/current-route [:player/id …​]}]}] can be resolved into {:team/id 1, :ui/router {::dr/current-route {:player/id 4, …​}}}.

Adapting backend data to the UI components' structure

Often the UI needs the data structured more or differently than they inherently are. We have multiple tools to do that to adapt them to the UI needs:

  • When load!-ing data, use targeting to change where it is placed or to add any number of extra "edges" to it (using append, prepend, replace)

  • Use :initial-state to establish edges between components. For example if we decide to relocate :tags from the Root to the child Menu component then we also need to make a connection between Root and the Menu component so that Fulcro will be able to find the data when fulfilling the query. Something like:

    (defsc Menu [_ {:keys [tags]}]
      {:ident (fn [] [:component/id :Menu])
       :query [:tags]
       :initial-state {}})         ; (1)
    
    (defsc Root [_ {:keys [menu]}] ; (2)
      {:query [{:menu (get-query Menu)}] ; (2)
       :initial-state {:menu {}}}  ; (2)
      (ui-menu menu))
    
    ;; Client DB will be (after data has been loaded):
    {:component/id {:Menu {:tags [..]}}
     :menu [:component/id :Menu]}  ; (3)
    1The child component must have a non-nil initial state
    2The parent component can pick whatever name it wants for the child’s data (here :menu) as long it initializes it in its :initial-state (remember we use its template form here so it is the same as :menu (get-initial-state Menu))
    3Thanks to the initial state, we get this link from the parent (here Root) to the child and Fulcro will be able to fulfill the query. Notice this edge is arbitrary, using a made-up name (:menu), it does not correspond to anything in the backend data. (It took me a while to figure out that I can add such arbitrary edges.)
  • Use a custom mutation (perhaps triggered via load!'s :post-mutation parameter) to re-shape the client DB in any way you want

  • Use Pathom placeholders (:>/any-arbitrary-keyword) as discussed above to add an extra level of structure to the backend data. (Obviously this only works if you actually load the data from Pathom - if you simulate them locally via initial-state or merge! then you need to add the structure into the data yourself.)


Tags: Fulcro Clojure ClojureScript


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