Using React.forwardRef in Fulcro (and rendering a Fulcro component from a JS one)

How (and why) do you use React.forwardRef in Fulcro? Let’s first explore ref. When you need access to the raw HTMLElement in React - f.ex. to call .focus on it - you need to create a Ref object[1] (similar to Clojure’s atoms) and pass it to a React DOM element such as dom/div via the magical property :ref. React will then do something like "(reset! <the Ref> <the raw element>)" so that you can access the raw element in your code: (some→ <the Ref> .-current .focus). The :ref property is magical in the regard that it is "consumed" by React itself and not passed to the component. But what if you make a custom component and want it to be able to take a Ref object to attach it to its child DOM element? The simplest solution is to pass it under any other name than the reserved ref, which is exactly what this Fulcro examples does, using the custom :forwarded-ref. However, some 3rd party higher-order components insist on passing the Ref down using the reserved ref property name. To make it possibly, React invented forwardRef:

const FancyButton = React.forwardRef((props, ref) =>
  (<button ref={ref} className="FancyButton">{props.children}</button>));

const ref = React.createRef();
<FancyButton ref={ref}>Click me!</FancyButton>;

What is happening here? React.forwardRef takes a function accepting props and ref - i.e. the properties and the actual Ref object passed to the component FancyButton - and is expected to return a React element, which presumably uses of the props and the ref. React.forwardRef itself returns a component and thus needs to be turned into an element to be rendered. The component produced by forwardRef is magical and will not consume ref as normal components do, instead passing it on to the callback. (It cannot simply pass it on to the body of the component because that would violate the existing contract or mess something up, I assume.) Now, how do we use this in Fulcro?

A simple example with a ref and use-ref

First a simple example where we do not need forwardRef. We want to create a button that, when clicked, focuses an input element:

Example 1. Using ref in Fulcro
(defsc Root [_ _]
  {:use-hooks? true}                  ; (1)
  (let [ref (hooks/use-ref nil)] ; (2)
    (div
      (dom/input {:ref ref, :value "" :type "text"})        ; (3)
      (dom/button {:onClick #(some-> ref .-current .focus)} ; (4)
        "Focus!"))))
1We need :use-hooks? true to be able to use hooks
2Create the Ref object with the initial value of nil
3Pass the Ref to the input element using the magical :ref prop so that React will capture it and set it to the raw DOM element
4Use the Ref’s current value - the raw element - to focus the input field

The smart way: passing a Ref to a child using a custom name

If we have full control over all the components involved then the simplest approach is to avoid ref and pass the Ref object under any other name. Here we have the same example as above but we have factored the button into a custom component, for reasons:

Example 2. Passing a Ref using a custom name
(defsc ChildWithRef [_ {:keys [forwarded-ref label] :as props}]
  {}
  (dom/button {:onClick #(some-> forwarded-ref .-current .focus)}
    label))

(def ui-child-with-ref (comp/factory ChildWithRef))

(defsc Root [_ _]
  {:use-hooks? true}
  (let [ref (hooks/use-ref nil)]
    (div (dom/h1 "Hello!")
      (dom/input {:ref ref, :value "" :type "text"})
      (ui-child-with-ref {:forwarded-ref ref :label "Focus, v2!"}))))

Using forwardRef to access ref passed by a parent

Sometimes we do not control all the components and one of them insist on passing a Ref using the reserved ref and we thus must use forwardRef:

Example 3. Using forwardRef
(defsc ChildWithRef [_ {:keys [forwarded-ref label] :as props}] ; (1)
  {}
  (dom/button {:onClick #(some-> forwarded-ref .-current .focus)}
    label))

(def ui-child-with-ref (comp/factory ChildWithRef))

(def child-with-ref
  (react/forwardRef                         ; (2)
    (fn [js-props ref]
      (ui-child-with-ref                    ; (3)
        (-> js-props
            (js->clj :keywordize-keys true) ; (4)
            (assoc :forwarded-ref ref))))))

(defsc Root [_ _]
  {:use-hooks? true}
  (let [ref (hooks/use-ref nil)]
    (div
      (dom/input {:ref ref, :value "" :type "text"})
      ((interop/react-factory child-with-ref) ; (5)
        {:ref ref :label "Focus, v3!"}))))    ; (6)
1Our Fulcro-based Child component receives the Ref object using a custom name, forwarded-ref (could be anything but I found :anything little unclear ;))
2We call react/forwardRef passing it a callback that takes props and the passed-in ref.
3The callback returns a React element
4We need to manually translate the props that is a JavaScript Object into a Clojure map and we add to it the passed-in ref under a custom name
5forwardRef returns a component so we need to pass it through the factory to turn it into an element (for brevity I do it inline here)
6We simulate an external component that insists on passing the Ref as :ref and also pass in additional props that we need

Passing a Fulcro component wrapped with forwardRef to a HoC JS component

Most often we need to use React.forwardRef when we are passing our component for rendering to a JavaScript higher-order component (HoC). That means that in addition to handling forwardRef correctly we also need to wrap the component with with-parent-context so that it is correctly connected to Fulcro. The code below demonstrates that:

Example 4. Using forwardRef with a JS HoC component
(defn shallow-js->clj "like js->clj but single level" [^js obj]
  (persistent!
    (reduce (fn [r k] (assoc! r (keyword k) (gobj/get obj k)))
      (transient {}) (js-keys obj))))

;; Child Fulcro component that needs a ref, rendered by a JS parent
(defsc ChildWithRef [_ {:keys [forwardedRef extra txt] :as props}]
  {:ident (fn [] [:component/id ::ChildWithRef]), :query '[*]}
  (dom/button {:onClick #(some-> forwardedRef .-current .focus)}
    (:label extra) " " txt))

(def ui-child-with-ref (comp/factory ChildWithRef))

;; Raw JS functional component adapting between the calling JS world
;; and the child Fulcro world
(defn ChildWithRefAdapter [^js js-props]                        ; (1)
  (let [fulcroProps (hooks/use-component APP ChildWithRef nil)] ; (2)
    (comp/with-parent-context                                   ; (3)
      (.-fulcroParent js-props)                                 ; (4)
      (ui-child-with-ref
        (-> js-props shallow-js->clj (dissoc :fulcroParent)
            (merge fulcroProps))))))                            ; (5)

;; Wrap it with React.forwardRef
(def ChildWithForwardRef
  (react/forwardRef                                             ; (6)
    (fn [js-props ref]
      (dom/create-element ChildWithRefAdapter
        (js/Object.assign #js {:forwardedRef ref} js-props))))) ; (7)

;; Here we fake the HoC JavaScript component
(defn FakeHigherOrderJsComponent [^js props]
  (let [ref (hooks/use-ref nil)]
    (dom/div
      (dom/input {:ref ref, :value ""})
      (dom/create-element
        (.-Component props)
        (js/Object.assign #js {:ref ref, :txt "me"}
                         (.-componentProps props))))))

(defsc Root [this _]
  {:use-hooks? true}
  (dom/create-element FakeHigherOrderJsComponent                          ; (8)
    #js {:Component ChildWithForwardRef
         :componentProps #js {:fulcroParent this
                              :extra {:label "Click"}}}))
1Create a raw JS functional component that will adapt between the calling JS world and the child Fulcro world
2Get access to the child Fulcro component’s Fulcro props (like get-in client-db <ident>)
3Wrap rendering of the component with with-parent-context so that it has access to the Fulcro app instance etc.
4Use the fulcroParent set manually in the Root for the parent context (we need a way to pass props to the child to be able to do that; alternative we could perhaps look the parent up in Fulcro’s registries)
5For convenience we combine the retrieved Fulcro props with the parent-provided ^js props
6We wrap the adapter with forwardRef so that we can get access to the passed-in ref
7We pass the ref on under the custom name forwardedRef (Note: js-props here are "immutable" so we copy them).
8We don’t use interop/react-factory to avoid the recursive clj→js processing of props; it’s faster this way. Notice that the value if :extra is and remains a Clojure data structure, which we just pick and use as-is in the child component

Summary

Sometimes you want to pass a Ref object - for example to get its values set to a raw HTMLElement by React - through a custom component. The simplest way is to use an arbitrarily named property for that. Some existing components like to use the reserved ref property for that purpose, which then requires the use of React.forwardRef to be able to get hold of the value passed in, which would otherwise be consumed by React itself.


1. By "Ref" I will refer to the atom-like, mutable content holder, contrary to "ref" meaning the magical ref property and. Confusing, I know.

Tags: Fulcro ClojureScript


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