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
  4. Multiple UI views of a single Data Entity
  5. A Data Entity spread across multiple (sibling) components
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.

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

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

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

(defsc ListSize
  "Data-only entity for fetching the size of a list"
  [_ _]
  {:ident :list/id
   :query [:list/id :list/size]})

;; At a suitable moment, e.g. at app start:
(df/load! app :all-lists ListSize)

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 "…​"}}.


Tags: Fulcro Clojure ClojureScript

Are you benefitting from my writing? Consider buying me a coffee or supporting my work via GitHub Sponsors. Thank you!


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