The trouble with list components in Fulcro

Imagine you have a list of things to display in your UI. You naturally want to represent them with a list component, such as a TodoList. But that is not the way we do it in Fulcro, which beginners find confusing (I did). Here I explain why and what are the alternatives.

Still new to Fulcro? Make sure to check out my Minimalist Full-Stack Fulcro Tutorial!

The thing is that in Fulcro a component (normally) represents a "data entity" and a list is no entity, it is just a grouping of entities. It is clear on the example of children: a Parent component has some number of Child sub-components; it does not have a "list of children". This is how it looks in code:

(defsc Child [_ {:child/keys [name age]}]
  {:ident :child/id
   :query [:child/name :child/age]}
   (li name " is " age))

(defsc Parent [_ {:parent/keys [name children]}]
  {:ident :parent/id
   :query [:parent/name
           {:parent/children (comp/get-query Child)}]} ; (1)
   (div "Children of " name ":" (ul (map (comp/factory Child) children))))
1There is no "List" component; the parent queries for its children property and gets the list.

This is how the data looks in the Fulcro client DB:

{:parent/id {1 #:parent{:id 1, :name "Darth Vader",
                        :children [[:child/:id 22],...]}}
 :child/id {22 #:child{:id 22, :name "Leia", :age 21}, ...}
 ...}

I still want my List!

Let’s suppose that you actually want an explicit list component instead of displaying the children directly in the parent component. A good reason could be that the parent component is already big and the way you want to display the children is non-trivial. What to do? You have three options, in order of simplicity:

  1. Create the List as a UI-only, stateless component

  2. Ask Pathom to insert an extra level of structure for the list component

  3. Create the List as a stateful component and add the necessary "edge" to the data tree manually

1. Create the List as a UI-only, stateless component

Create the list as a UI-only, stateless component without an ident, query, or client-DB-provided props of its own. The original parent still queries for the children but passes them to the list component for rendering.

Example 1. UI-only, stateless ChildrenList (passing children as elements)
; ...
(defsc ChildrenList [this _]
  {}
  (div "ChildrenList is:"
    ;; Notice we copy the `:keyfn`-provided unique key to the div
    ;; of each of the list elements, as React requires
    (map #(div {:key (.-key %)} %)  ; (1)
      (comp/children this))))       ; (2)

(def ui-child-list (comp/factory ChildrenList))

(defsc Parent [_ {:parent/keys [name children]}]
  {:ident :parent/id
   :query [:parent/id :parent/name
           {:parent/children (comp/get-query Child)}]}
  (div "I am the terrible" name "!"
    (ui-child-list {} (map ui-child children)))) ; (3)
1Copy the unique key (provided by Child’s factory’s :keyfn) to make React happy
2Access the child React elements (= already react-ified components) via comp/children
3Only the Parent can see the children data property and instantiates the individual Child components, passing them their props; it then gives the instantiated elements to ChildrenList for rendering. The list here works as kind of a "template" with "slots" for the elements to be rendered

This is the "purest" way of passing the individual elements from Parent to ChildrenList, i.e. letting Parent to create React elements for them and passing these as React children to the list component.

Alternatively you could just pass the children prop down to the list component either inside its props or, to be 100% safe, as a computed prop (see Parent-computed Data in the Book for details). Both should work equally fine as long as you do not use the optional ident-optimized renderer. It would look like this:

Example 2. UI-only, stateless ChildrenList (passing along the prop)
; ...
(defsc ChildrenList [_ props]
  {}
  (div "ChildrenList is:"
    (map #(div {:key (:child/id %)} (ui-child %))
      (:children props))))

(def ui-child-list (comp/factory ChildrenList))

(defsc Parent [_ {:parent/keys [name children]}]
  {:ident :parent/id
   :query [:parent/id :parent/name
           {:parent/children (comp/get-query Child)}]}
  (div "I am the terrible" name "!"
    (ui-child-list {:children children})))

2. Ask Pathom to insert an extra level of structure for the list component

If the data you’re using is coming from Pathom then you can ask it - via a Pathom placeholder - to insert an extra layer between the original parent and the children, to fit the list. Thus the list will exist as a stateful component. That is especially useful if you want it to have state of its own, for example to keep track which of the list elements the user has selected.

Example 3. Leverage Pathom placeholders to "factor out" ChildrenList
; ...
(defsc ChildrenList [_ {:parent/keys [children]}]
  {:ident :parent/id                                   ; (1)
   :query [:parent/id
           {:parent/children (comp/get-query Child)}]} ; (2)
  (div "ChildrenList is:"
    (map #(div {:key (:child/id %)} (ui-child %)) children)))

(def ui-child-list (comp/factory ChildrenList))

(defsc Parent [_ {:parent/keys [name] :as props}]
  {:ident :parent/id
   :query [:parent/id :parent/name
           {:>/children-list (comp/get-query ChildrenList)}]} ; (3)
  (div "I am the terrible" name "!"
    (ui-child-list (:>/children-list props))))                ; (4)
1Copy the ident of the original parent component
2Copy the relevant part of the original parent component’s query
3Use the Pathom placeholder (by default >) with an arbitrary name (here children-list) and the query of the new list component
4Pass the children-list props down

3. Create the List as a stateful component and add the necessary "edge" to the data tree manually

If you want the list component to exist as a full-fledged, stateful component and the data isn’t coming from Pathom and thus you cannot leverage the placeholder facility then you can replicate it manually. It means that you need to insert an extra "edge" between the original parent and the list and move the list property from the parent into the list entity.

If the parent and list components are static (i.e. their idents do not depend on props and they exist at application start) then you can use the list’s :initial-state and its inclusion in the parent’s to establish the edge. In the case of dynamic components you would likely leverage :pre-merge or a custom mutation. You would also use either pre-merge or a mutation to move the list property from the parent into the list entity.

Example 4. Manually inserted stateful List component
; ...
(defsc ChildrenList [_ {:parent/keys [children]}]
  {; :ident nil                                                    ; (1)
   :query [{:parent/children (comp/get-query Child)}]}             ; (2)
  (div "ChildrenList is:"
    (map #(div {:key (:child/id %)} (ui-child %)) children)))

(def ui-child-list (comp/factory ChildrenList))

(defsc Parent [_ {:parent/keys [name] :as props}]
  {:ident :parent/id
   :query [:parent/id :parent/name
           {:artificial/child-list (comp/get-query ChildrenList)}] ; (3)
   :pre-merge (fn [{parent :data-tree}]                            ; (4)
                (-> parent
                    (assoc :artificial/child-list
                      (select-keys parent [:parent/children]))
                    (dissoc :parent/children)))}
  (div "I am the terrible" name "!"
    (ui-child-list (:artificial/child-list props))))               ; (5)
1Here the list component has no ident because I see no point in normalizing it (alternatively, we could give it :ident :parent/id in which case :artificial/child-list would point to itself; we wouldn’t dissoc :parent/children and do few more changes; see a fully worked out example here)
2As in the case of Pathom, we copy the relevant part of Parent’s query (though we could have renamed the key in the pre-merge and then would need to adjust the query accordingly)
3The Parent queries for the manually inserted edge, including ChildrenList’s query
4In pre-merge we restructure the data as needed, inserting the extra level of :artificial/child-list. Merging then continues recursively with the newly inserted property and the ChildrenList query
5The Parent instantiates the ChildrenList component, passing it the artificially created property / edge.

Summary

A list of entities is not an entity itself - it is just a prop on a parent entity. Thus you cannot create a dedicated component to encapsulate the rendering of the list out of the box. But there are few ways to enable you having a dedicated list component, either stateless or stateful, depending in your needs.


Tags: Fulcro ClojureScript


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