Helix's philosophy is to give you a Clojure-friendly API to raw React. All Helix components are React components, and vice-versa; any external React library can be used with Helix with as minimal interop ceremony as possible.
The easiest way to create a component using Helix is with the defnc
macro.
This macro creates a new function component, handling conversion of props to a
CLJS data structure.
(ns my-app.feature
(:require [helix.core :refer [defnc]]))
(defnc my-component [{:keys [name]}]
(str "Hello, " name))
React components always expect a single argument: a JavaScript object that holds all of the props passed to it.
If we were to call our component like a normal function (which you should not do! See Creating Elements), we would need to pass in a JS object to it:
(my-component (js-obj "name" "Tatiana"))
;; => "Hello, Tatiana"
The defnc
macro takes care of efficiently converting this JS object to a type
that works with destructuring and core functions like assoc
/etc.
One thing to note is that this conversion of JS objects to CLJS data types is shallow; this means if you pass in data like a JS object, array, etc. to a prop, it will be left alone.
(defnc my-component [{:keys [person]}]
;; using JS interop to access the "lastName" property
(let [last-name (.-lastName ^js person)
first-name (.-firstName ^js person)]
(str "Hello, " first-name " " (nth last-name 0) ".")))
;; Example calling it like a function - do not do this in your app!
;; See "Creating Elements" docs.
(my-component #js {:person #js {:firstName "Miguel" :lastName "Ribeiro"}})
;; => "Hello, Miguel R."
This is an intentional design decision. It is a tradeoff - on the one hand, it is more efficient to opt not to deeply convert JS data structures to CLJS data, and it means that you do not need to learn some Helix-specific rules when interoping with external React libraries that use higher-order components or render props.
On the other hand, it does mean there are more cases where we need to use interop syntax instead of our favorite clojure.core functions and data structures.
I do not think this is particularly bad, as you will need to understand how to interop with the library either way, and between using explicit interop syntax vs. some Helix-specific way of converting to and from data passed via React libs, I think that being explicit is better.
Higher-Order Components (HOC) are functions that take a component and return a new component, wrapped with some new functionality. The most common one is React.memo, but they are also sometimes used in libraries or apps to provide an easy way to add new behavior to arbitrary components.
Helix's defnc
macro has a special metadata key you can pass to it, :wrap
,
which takes a collection of calls to higher-order components and will ensure
that the component is wrapped in them.
(defnc memoized
{:wrap [(helix.core/memo)]}
[props]
"I am memoized!")
The Memoized
component will be passed to (helix.core/memo)
(and any other HOCs
given to the vector) using the thread-first
macro.
;; This is similar to the code generated by the `defnc` macro
;; when `:wrap` is used
(defn memoized--Render [props]
"I am memoized")
(def memoized (-> Memoized--Render
(helix.core/memo)))
Like functions, sometimes we want to create anonymous inline components. For that we can
use the fnc
macro.
(let [my-button (fnc [{:keys [class on-click] :as props}]
(d/button {:class class :on-click on-click}))]
($ my-button {:class ["foo" "bar"] :on-click #(js/alert "hi")})
Note: Consider class components as a "last resort" action for cases where a function component cannot be used.
The defcomponent
macro accepts a symbol together with a list of methods or
properties. Methods are written with the syntax (name [this args] body)
.
Properties are written with the syntax (name form)
. To mark a method as
static, use the ^:static
metadata.
As of React 18, there is still no way to write an error boundary as a function component. Here is how we could implement an error boundary with Helix.
(defcomponent ErrorBoundary
;; To avoid externs inference warnings, we annotate `this` with ^js whenever
;; accessing a method or field of the object.
(constructor [^js this]
(set! (.-state this) #js {:hasError false}))
(componentDidCatch [^js this error _info]
(.setState this #js {:data error}))
^:static (getDerivedStateFromError [_this _error]
#js {:hasError true})
(render [^js this props ^js state]
(if (.-hasError state)
(d/pre
(d/code
(pr-str (.-data state))))
(:children props))))