Fulcro Lesson: Detached Root Component (Form)

I am working on a Fulcro RAD application and want to display a RAD Form in a popup, to create a new customer for an order I am making. Normally Fulcro components are "composed all the way up to the root," including their query in the parent’s and getting their props from the parent. But that does not make sense here - I want a detached form I can pop up, fill, close, and go back to editing the order. I wasn’t able to figure out how to compose this without help and thus want to record the solution.

My starting point is com.fulcrologic.rad.rendering.semantic-ui.entity-picker/ToOnePicker, which is a "stateless" component, i.e. it has neither a query nor any (initial) state. Inside its body I want to render the modal with a form.

This article could never be written without the help of Tony Kay. Many thanks for his invaluable insights!

Note: Fulcro RAD has now a use-form React hook that simplifies this particular use case. But the lessons learned are still applicable when you want your own detached root component.

Solution

This is the solution, kindly provided by Tony Kay:

ToOnePickerhooks defsc CreationContainer leveraging hooks/use-component → defsc CreationModal → the form.

The key to the detached component solution is leveraging use-component in a hooks defsc to "fetch" data from the client DB without having to get them from the parent. A crucial insight is that everything is much simpler if the child component used with use-component has a static ident.

We get the props of the detached CreationModal component like this in the parent CreationContainer: (hooks/use-component app CreationModal {:initialize? true, :keep-existing? true}). CreationContainer is a defsc so that each component has a defsc parent, as they require. And a crucial "trick" is that CreationModal has a static ident, i.e. (fn [] [:component/id ::CreationModal]), and a non-nil :initial-state {}. Thus it has a fixed, well-known, and non-nill location in the client DB and use-component will be able to initialize it.

Now, how do we get the props of the form and start it? In this case the form component isn’t known when we code the defsc because it is provided at configuration time by the library users. Tony’s solution was to use dynamic queries to add the form’s query into the parent modal. We do this in a mutation, triggered when we want to display the modal and the form:

Example 1. Initialization mutation
; Here `id` should be `(tempid/tempid)`, and
; `ident` the thing being created, with the id: `[:account/id id]`
(defmutation start-create [{:keys [id ident form]}]            ; (1)
  (action [{:keys [app state]}]
    (swap! state update-in [:component/id ::CreationModal]
      assoc :ui/open? true :ui/form-props ident)                ; (2)
    (comp/set-query! app CreationModal
      {:query [:ui/open?
               {:ui/form-props (comp/get-query form @state)}]}) ; (3)
    (form/start-form! app                                       ; (4)
      id form
      {:embedded? true #_#_:on-saved [(entity-added {})]})))
1We get in the ident and id of the entity being created and the form component class (e.g. AccountForm). The id must be a tempid (that’s how the form distinguishes between creation and editing).
2Importantly, we link the CreationModal component’s data to the form’s so that the query can actually find them. We end up with this in the client DB :component/id {::CreationModal {:ui/form-props [:account/id <id>] …​}, :account/id {<id> {…​}}}.
3Next we set the CreationModal query to include the query of the form, so that the props we get from use-component will include the form’s props.
4Finally we start the form, which initializes its UISM and data. Notice we can pass in callbacks for on-saved etc. :embedded? disables some unnecessary behavior, such as routing and history support.

Notice that start-form expects the form’s data at a well-known location, namely at <entity/id> <the id>. That is fine for us because we have linked our modal to that location.

The complete solution

See the code of CreationModal, CreationContainer, and ToOnePicker and how it is used in the demo’s InvoiceForm (the relevant change there is including po/creation-form AccountForm in the fo/field-options {:invoice/customer { …​}}).

My struggles

Sometimes we can learn from the mistakes of others :-).

My failed attempt lacked the static ident component and used originally a raw React functional component: ToOnePicker → hooks defn CreationContainer leveraging hooks/use-component → the form.

Using defn instead of defsc was as unnecessary complication. The child defsc requires that it has a defsc parent, which the defn breaks. I could fix it by wrapping the child with comp/with-parent-context and passing an explicit reference to the parent ToOnePicker, but why do it in such a roundabout way?

This is the half-working solution I eventually arrived at:

Example 2. My half-working solution
(defsc CreationModal [this _props]
  {:use-hooks? true}                                                      ; (1)
  (let [app (comp/any->app this)
        [id] (hooks/use-state (tempid/tempid))                            ; (2)
        _ (hooks/use-lifecycle
            #(form/start-form! app id AccountForm {:embedded? true}))    ; (3)
        fprops
        (hooks/use-component app AccountForm {:ident [:account/id id]})] ; (4)
    (ui-modal {:basic true, :open true}
              (ui-modal-header {} (dom/div "Create a new entity"))
              (ui-modal-content {} (if fprops
                                     ((comp/factory AccountForm) fprops))))))
1I use hooks, so that I can "fetch" the form’s props with use-component
2I generate a tempid for the new entity. I must use-state otherwise I would get a new tempid on every render, which would break everything (trust me, I’ve been there 😅)
3I must start the form manually (normally they are started automatically when routed to in will-enter, which doesn’t apply here). This will initialize the data under :account/id <id> in the client DB. I leverage use-lifecycle so that it happens exactly once, when the component is mounted.
4Finally I get the form’s props with use-component. Use-component requires that I pass in the ident of the component, if it is not static, as is the case here.

This solution kind of works (provided you do not forget to wrap the tempid call with use-state), but it has couple of problems:

Tempid remapping - remember that tempids are replaced with real IDs upon save and Fulcro fixes this in the client DB and queued transactions. But here the tempid is hidden inside the local state (via use-state) and Fulcro cannot replace it with the real value. This is not necessarily a problem, if we close and clear the modal on save, but it is not ideal and could lead to some head scratching. I could solve the problem by looking inside the UISM for the form and extract the id that way.

No fixed mutation target - the CreationModal has no ident and thus no fixed place in the client DB. But we need to use mutations to know when to display the modal and when the form gets saved or cancelled and to act accordingly, i.e. hide the modal and refresh the ToOnePicker’s options. Without a fixed place in the data, where should these mutations record the state? However we cannot simply give it a static ident because it is rendered by the "stateless" ToOnePicker, which is not connected to the client DB and thus can pass no props to the modal. We simply need another component between the hooks glue component and the form, i.e. we need both the hooks CreationContainer and the static ident CreationModal. (Perhaps I could use rc/nc to create the static ident component on the fly but that would perhaps just complicate stuff.)

Form re-initialization on re-mount - use-lifecycle will run again if the component is unmounted and remounted, which will re-initialize the form. This could also lead to some surprising, undesirable behavior.

Moreover, I mix the standard Fulcro state management (queries and client DB) and React’s hook-based state management, which is intertwined with the component lifecycle, namely its mounting. That is, in my opinion, a breeding ground for problems.

Another mistake I made was splitting the modal-related state between itself and the parent ToOnePicker, using local state in the picker to decide whether to display the modal or not. A much cleaner and simpler solution is to only keep all the state inside the CreationModal (or rather its place in the client DB) and always render this component inside the picker but let the modal decide whether it actually displays itself or not and use a mutation to trigger it from the picker.

Tony’s solution with the hook-based CreationContainer and CreationModal being its child with a static ident, state, and query makes all the rest much simpler.

Conclusion

Dynamic stuff can be hard to get right. There’s a lot of inherent complexity in the problem: form content lifecycle, form component lifecycle, ID management and tempid remapping.

Mixing Fulcro state management and React state management is not a good idea. Try to stick to one as much as possible.

Components with static idents make writing mutations changing their much simpler, especially if these will be transacted from outside the component (and thus lack the ref to the target component).


Tags: Fulcro


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