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:
(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!"))))
1 | We need :use-hooks? true to be able to use hooks |
2 | Create the Ref object with the initial value of nil |
3 | Pass 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 |
4 | Use 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:
(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:
(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)
1 | Our Fulcro-based Child component receives the Ref object using a custom name, forwarded-ref (could be anything but I found :anything little unclear ;)) |
2 | We call react/forwardRef passing it a callback that takes props and the passed-in ref . |
3 | The callback returns a React element |
4 | We 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 |
5 | forwardRef 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) |
6 | We 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:
(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"}}}))
1 | Create a raw JS functional component that will adapt between the calling JS world and the child Fulcro world |
2 | Get access to the child Fulcro component’s Fulcro props (like get-in client-db <ident> ) |
3 | Wrap rendering of the component with with-parent-context so that it has access to the Fulcro app instance etc. |
4 | Use 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) |
5 | For convenience we combine the retrieved Fulcro props with the parent-provided ^js props |
6 | We wrap the adapter with forwardRef so that we can get access to the passed-in ref |
7 | We pass the ref on under the custom name forwardedRef (Note: js-props here are "immutable" so we copy them). |
8 | We 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.
ref
property and. Confusing, I know.