Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reusable components following HTML semantics of Opional Attributes and Variadic Children #596

Open
halfzebra opened this issue Nov 15, 2023 · 3 comments

Comments

@halfzebra
Copy link

Hello friends, thanks for maintaining Reagent! 🙌

Through the last 11 months of coding Clojure, I've been writing quite a lot of Reagent code and it's mostly been a pleasant journey thanks to your effors.

I might be missing something(still quite new to Clojure), it's been a bit hard for me to achieve the familiar HTML semantics of Opional Attributes and Variadic Children.

1. Possible solutions to reusable components

I've been looking over possible designs I've seen in the internet considering their pros and cons.

1.1 Positional Attrs and Positional Children

✅ simplest API
❌ requires [:<>] wrapper for children
❌ doesn't follow Hiccup/HTML semantics
❌ introduces breaking changes

(defn button [children]
  [:button children]))

(defn button [attrs children] ; breaking change if we extend the component
  [:button attrs children]))

[button nil [:<>
              [:span "Child 1"]
              [:span "Child 2"]]

1.2 Required Attrs and Variadic Children

✅ simple API
✅ variadic children
❌ requires positional attrs, so it doesn't follow the semantics of HTML tags

(defn button [attrs & children]
  (into [:button props] children))

[button {:on-click #()} "Click me!"]
[button null
  [:span "First"]
  [:span "Second"]]

1.3 Optional Attrs and Optional Children

✅ harder to introduce a breaking change
❌ requires [:<>] wrapper for children
❌ doesn't follow Hiccup/HTML semantics

(defn button [{:keys [children] :as props}]
  (into [:button (dissoc props :children)] children))

[button
  {:on-click #()
   :children [:<>
               [:span "First"]
               [:span "Second"]]}]

1.4 Optional Attrs and Variadic Children

✅ follows Hiccup/HTML semantics
✅ harder to introduce a breaking change
❌ requires boilerplate
❌ hides the "real" arguments inside let binding

(defn button [& [attrs & children :as props]]
  (let [[attrs & children] (if (map? attrs) props (into [nil] props)]
    (into [:button attrs] children))

[button
  {:on-click #()}
  [:span "First"]
  [:span "Second"]]

2. Proposal

It could be that a better solution already exists, but I've missed it, but if not...

Here is the distillation of what could bring HTML semantics to reusable components in Hiccup and Reagent.

2.1 Optional Attrs and Variadic Children with a defcomp macro 😎 👍

(defmacro defcomp
  "Allows defining Hiccup components, which follow HTML semantics"
  [name attrs & body]
  `(def ~name (fn [& [attrs# & _children# :as props#]]
                (let [~attrs (if (map? attrs#) props# (into [nil] props#))]
                  ~@body))))

This macro enables the standard HTML semantics in re-usable Reagent components without extra boilerplate and trade-offs of solutions 1-3.

✅ follows Hiccup/HTML semantics
✅ reduces the risk of introducing a breaking change
✅ no boilerplate
✅ arguments easily readable in defcomp args
❌ relies on a macro

Example of usage:

(defcomp button [{:keys [on-click] :as attrs} & children]
  (into [:button {:on-click on-click}] children))

Please share your thoughts on this, I would really appreciate any feedback!

@Deraen
Copy link
Member

Deraen commented Nov 16, 2023

1.4 is what I'm using:

(defn parse-args
  "Given React style arguments, return [props-map children] tuple."
  [args]
  (if (map? (first args))
    [(first args) (rest args)]
    [nil args]))

(defn foobar [& args]
  (let [[props children] (parse-args args)]
    ...))

Adding a macro to define components would allow solving other big problems of Reagent, but I don't know if that is going to happen.

UIx2 and Helix handle this nice. They just enforce React way of component fns just taking the props object as parameter, nothing else. They also allow placing children into the elements directly, without into etc. But many of these things are just impossible to fix on Reagent.

@halfzebra
Copy link
Author

halfzebra commented Nov 16, 2023

1.4 is what I'm using:

Thanks for sharing Juho!

Very happy to hear I'm not alone with this pattern. 🙂

Adding a macro to define components would allow solving other big problems of Reagent, but I don't know if that is going to happen.

Can you share what other issues might be addressed?

UIx2 and Helix handle this nice.

Thanks for pointing this out, it seems like UIx2 and Helix are not using Hiccup and BOTH use a macro for reusable components!
I think it's a good solution as well to the same problem, so maybe it's better to stick with the consensus and have:

(defmacro defcomp
  [name props & body]
  `(def ~name (fn [& args#]
                (let [~props (if (map? (first args#))
                               (assoc args# :children (seq (drop 1 args#)))
                               {:children (seq args#)})]
                  ~@body))))

(defcomp button [{:keys [children on-click]}]
                    (into [:button {:on-click on-click}] children))

As of (into [:div] children), I'd say it's an acceptable tradeoff of using Hiccup.

@Deraen

@Deraen
Copy link
Member

Deraen commented Nov 17, 2023

  • Hiccup performance is horrible, but using a macro support the format fully is complex (see Sablono). UIx2 $ is much simpler.
  • Reagent can't differentiate between Reagent components (cljs functions), "functional" Reagent components and regular React components, which is why we have the mess with :> and :f>. A macro could attach a property to the created function, which Reagent can do we as we just use regular defn. (https://github.com/pitch-io/uix/blob/master/core/src/uix/core.clj#L86)
    • But even if we add the macro, we can't fix component uses without breaking all existing projects as all current code is using just regular fns.
  • A macro could make supporting React Fast Refresh easier (i.e. keep hooks state)

I'm preparing a blog post which would touch on this and other problems with Reagent.

Your macro example performance isn't going to be the best:

  1. You have to build Hiccup vector on render, and it has variable number of children (not required on React/Helix/UIx)
  2. When using the component, Reagent has to parse the vector and create React properties JS object where the button props and children are stored in Cljs vector (not the same vector as the Hiccup one you build, 2. extra datastrcture is created) #js {:argv [{:on-click ...} ...]})
  3. Reagent calls your render fn with the received prop (apply render (.-argv props)). Having to apply a list of arguments to a function is slower than just calling fn with one value.
  4. The render method from your macro destructures the function arguments and creates new datastructure.

So because Reagent is doing work to support Hiccup, you need to do extra work to transform data back to format that is closer to the original. Unfortunately there is no way to fix this in Reagent.

You can check UIx presentation by Roman for screenshot of UIx vs Reagent performance profile, Reagent call stack is 5x deeper: https://www.youtube.com/watch?v=4vgrLHsD0-I

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

No branches or pull requests

2 participants