Skip to content

Instantly share code, notes, and snippets.

@lilactown
Last active December 9, 2019 19:01
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save lilactown/e93a1a0ab25d40df006d77f405c1e535 to your computer and use it in GitHub Desktop.
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"))
@orestis
Copy link

orestis commented Nov 26, 2019

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.
  • The :callback metadata is straightforward to me, does the :memo metadata apply it to the whole App component? Looks neat and scary but it might be just a mindset :)
  • To use DOM elements, we call the various helix/dom functions. They accept props (optional) and children as usual.
  • To use Helix elements, we use the $ function (macro?), passing in component, props (optional) and children.
  • Do we use the same $ when wanting to use plain React elements? What about hx/defnc elements?
  • The <> is the Fragment syntax
  • the hooks 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:

  • Why not have a single $ function used also for built-in elements? e.g ($ :div {:foo "bar"} ($ :span "hi"))
  • Assuming defnc is a macro, could it also replace instances of :div, :span to calls to d/div, d/span?
  • In a similar vein, could it also use some heuristic about upper-case symbols (like JSX does) and convert those to to ($ MyComponent)?
  • How does React interop look like (render props, etc?)
  • Can $ do compile-time checking to make sure that Greeting is an actual proper React element (avoiding the usual case of "cannot have objects as React components) etc.
  • Would it make sense / be more ergonomic to use a reader tag instead of $?

@lilactown
Copy link
Author

lilactown commented Nov 26, 2019

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.

@orestis
Copy link

orestis commented Nov 26, 2019

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]]]])]))

@lilactown
Copy link
Author

lilactown commented Nov 26, 2019

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment