reagent-project / reagent

A minimalistic ClojureScript interface to React.js
http://reagent-project.github.io/
MIT License
4.75k stars 415 forks source link

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

Closed halfzebra closed 6 hours ago

halfzebra commented 10 months ago

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 commented 10 months ago

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 commented 10 months ago

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 commented 10 months ago

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

halfzebra commented 2 days ago

@Deraen Hi Juho,

Thank you for your reply and all the materials you've referred to. πŸ™Œ

I meant to write a while back, everything you've said makes perfect sense.

You've raised good points, which led us to settle on the simplest possible solution of using a utility function.

It was exciting to explore UIx a bit, it seems like a strong contender. It definitely offers valuable features, but the benefits don't justify the migration for the project I'm working on.

(And again) Thank you for all your work πŸ‘

PS: feel free to close this issue, I'm not sure there's much to discuss here, but your input could be valuable for future development of such macro(if it will ever happen).

Deraen commented 1 day ago

Note about migration: I've worked on a large-ish (60kcloc cljs) where we chose to use UIx in parallel with Reagent for new features. At this moment there are 150 reagent.core requires, and 152 uix.core requires. (Though this might not fully reflect reagent use, because some reagent views might not need to require any reagent namespaces.) UIx works fine together with Reagent and Re-frame. In this case the app was under development still, so making the change made sense. If the app was only being maintained, it would be different thing.