-
-
Save lilactown/e93a1a0ab25d40df006d77f405c1e535 to your computer and use it in GitHub Desktop.
(ns helix-example | |
(:require | |
[helix.core :refer [defnc $ <>]] | |
[helix.hooks :as hooks] | |
[helix.dom :as d] | |
["react-dom" :as rdom])) | |
(defnc Greeting | |
"A component which greets a user. The user can double click on their name to edit it." | |
[{:keys [name on-name-change]}] | |
(let [[editing? set-editing?] (hooks/use-state false) | |
input-ref (hooks/use-ref nil) | |
focus-input #(when-let [current (.-current input-ref)] | |
(.focus current))] | |
(hooks/use-layout-effect | |
:auto-deps ;; automatically infer deps array from body; stand in for `[editing?]` | |
(when editing? | |
(focus-input))) | |
(d/div | |
"Hello, " (if editing? | |
(d/input {:ref input-ref | |
:on-change #(on-name-change (.. % -target -value)) | |
:value name | |
:on-blur #(set-editing? false)}) | |
(d/strong {:on-double-click #(set-editing? true)} name) | |
"!"))) | |
(defnc App [] | |
(let [[state set-state] (hooks/use-state {:name "Helix User"}) | |
;; annotate with `:callback` metadata to automatically wrap in | |
;; `use-callback` and infer dependencies from local context | |
on-name-change ^:callback #(set-name assoc :name %) | |
;; annotate with `:memo` metadata to wrap in `use-memo` and infer deps as well | |
name ^:memo (:name state)] | |
(<> (d/h1 "Welcome!") | |
($ Greeting {:name name})))) | |
(rdom/render ($ App) (js/document.getElementById "app")) |
Good questions. I'll try and answer them all, but also include a little bit of background:
Components and elements
defnc
creates a standard functional React component that accepts a JS object and returns React Elements$
is a macro which creates a React element - you can give it any component type, e.g.($ "span" "hi")
. It will shallowly rewrite props maps to a JS obj, so you can use it with any React component (including external JS)- The
helix.dom
helpers are simple factories built on top of$
. E.g.(d/div "hi")
is a macro for($ "div" ~@args)
In this way, there are no difference between an external JS component and a helix component, and no difference between a helix element and a React element created any other way (e.g. through hiccup parsing, or via JSX).
A thing that I think people will want to do (or at least I found myself doing) is creating factory macros for my components:
(defmacro greeting [& args]
`(helix.core/$ Greeting ~@args))
That way you can use it like so:
(d/div
{:foo "bar"}
(greeting {:name "Orestis"}))
Re: compile-time checking of types passed into $
, yes that's a good idea!
Reader tags etc. are outside the scope of this lib, but you can easily use something like thump in place of the $
macro; the only requirement is that components return React elements, how they are generated is up to you. Helix gives you $
and helix.dom
to start but you can use thump, sablono, reagent, or any other solution for creating the elements.
React interop is very similar to how hx
is now, where you simply create the element the same as any other component. Render props work nicer when you're not using a dynamic hiccup parser. Here's an example:
($ lib/SomeComponent
{:someProp "someValue"
:nestedValues #js {:value "won't be converted from a map to JS"}}
(fn [value]
(d/div "This is the value: " (d/strong (pr-str value)))))
Hooks
The use-effect
/ use-memo
/ use-callback
inference works by taking all the symbols inside the body passed to it and looking for any local bindings to those symbols. If it doesn't find a local binding for it, it won't include it. This in general is what you want, but sometimes you want to ignore certain values, in which case you can provide your own vector of deps. e.g.: (use-effect [foo] (+ foo bar))
To clarify, ^:callback
and ^:memo
annotations are only for the expressions that are annotated; it wraps them in use-callback
and use-memo
hooks, and infers their dependency array. It does not have to do with React.memo
(which memoizes a component).
Example:
^:memo (:name state)
;; will be expanded to
(hooks/use-memo :auto-deps (:name state))
;; which will expand further to
(react/useMemo (fn [] (:name state)) #js [state])
Pulling a key from a map is pretty contrived and doesn't need to be memoized, but it's a common thing that comes up when optimizing renders of child components and wanting to ensure that we keep referential identity when data hasn't changed.
Cool, thanks for the explanation. Another question:
Is there a story on interop where JS components will give you already-in-JS prop maps, which you need to use from CLJS? My current solution is to wrap them in bean
so that they can be passed to a an hx
component, perhaps doing a merge
with some other props too. Would be helpful if $
recognised plain JS maps and didn't try to convert them. A function for prop merging could also help.
An example of how this looks like from a real component, integrating react-beautiful-dnd -- which brings in Draggable:
(defnc DraggableSelectableRow [{:keys [id index value cells-component]}]
(let [selected? (is-selected? id)]
[Draggable {:draggableId id
:key id
:index index}
(fn [provided snapshot]
[:tr (merge {:ref (.-innerRef provided)
:class [(when selected? "table--selected")
(when (.-isDragging snapshot) "table-row-dragging")]}
(bean (.-draggableProps provided)))
[table/SelectionCell {:id id :is-selected selected?}]
[cells-component {:value value}]
[:td (merge {:class "table-row--draggable"}
(bean (.-dragHandleProps provided)))
[:span {:class "icon move"} [:i]]]])]))
Dynamic props are tough.
What I've done so far is introduce an idea of spread props in the $
macro (this includes helix.dom
macros too). So you can do something like this:
(let [props {:on-click #(js/alert "clicked!")}]
($ MyComponent {:style {:color "red"} & props}))
This way, props are always written as a literal map and you can opt-in to dynamically setting props when needed. This also handles merging; props passed in via the &
key will be merged into the JS object generated by the literal props and override them.
So the way that you would handle your case would be like:
(defnc DraggableSelectableRow [{:keys [id index value cells-component]}]
(let [selected? (is-selected? id)]
($ Draggable {:draggableId id
:key id
:index index}
(fn [provided snapshot]
(d/tr {:ref (.-innerRef provided)
:class [(when selected? "table--selected")
(when (.-isDragging snapshot) "table-row-dragging")]
& (bean (.-draggableProps provided))}
($ table/SelectionCell {:id id :is-selected selected?})
($ cells-component {:value value})
(d/td {:class "table-row--draggable" & (bean (.-dragHandleProps provided))}
(d/span {:class "icon move"} (d/i)))))))
Some additional logic could be added to check if spread props are a map?
and if not, treat it like a JS object and merge it. That would remove the need for the bean
wrappers.
Thanks for exploring this -- some observations to see if I understand this correctly.
defnc
is still the main way to make components. It seems to handle props converting etc and also the:callback
and:memo
metadata.:callback
metadata is straightforward to me, does the:memo
metadata apply it to the wholeApp
component? Looks neat and scary but it might be just a mindset :)helix/dom
functions. They acceptprops
(optional) andchildren
as usual.$
function (macro?), passing incomponent
,props
(optional) andchildren
.$
when wanting to use plain React elements? What abouthx/defnc
elements?<>
is the Fragment syntaxhooks
namespace provides a single point for all React hooks, plus some special sauce for automatically inferring dependencies where needed. I take it that you can also bypass the automatic dependency if needed. This kinda scares me for obscure bugs when the dependencies aren't picked up correctly, though I think this should be avoidable if the macro throws at compile time when it's uncertain.And some questions and wild ideas:
$
function used also for built-in elements? e.g($ :div {:foo "bar"} ($ :span "hi"))
:div
,:span
to calls tod/div
,d/span
?($ MyComponent)
?$
do compile-time checking to make sure thatGreeting
is an actual proper React element (avoiding the usual case of "cannot have objects as React components) etc.$
?