Fork me on GitHub
#hyperfiddle
<
2023-09-08
>
Jordan Calderwood04:09:09

How do I make reusable components like:

(e/defn Card
  [something body]
  …stuff 
  (dom/div body))
I don’t quite understand how to delay the evaluation of the body, it always renders first in the dom before “stuff”

henrik06:09:49

The only way I’ve found to do this currently is with macros.

henrik07:09:13

E.g.,

(defmacro button
  [{:keys [kind]
    :or   {kind :white}
    :as   props}
   & children]
  `(dom/button …
     ~@children))

xificurC07:09:26

can you give an example of a component that doesn't behave as you expect it to? (do (dom/div (dom/text "a")) (dom/div (dom/text "b"))) renders in the order a then b

xificurC07:09:51

ah, do you mean you call it like (Card. something (dom/div (dom/text "c")))? The simplest is to thunk it, i.e. send in (e/fn [] (dom/div)) and call it (dom/div (Body.))

henrik07:09:03

Yeah, this is more about composition and reusability as I understand it. Like:

(Container.
  (Section. 
    (Item.)
    (Item.)
    (Item.))
  (Section. 
    (Thing.)
    (Thing.)
    (Thing.)))
Where e.g. Section is some bundle of structure and behaviour that is agnostic to its children.

henrik07:09:21

I attempted (unsuccessfully) to write a macro to make this generic early on.

xificurC07:09:12

I think this is people looking at it with the reagent glasses on. When writing clojure code, it's obvious to everyone in (section (item) (item) (item)) items will be evaluated before section gets called

henrik07:09:17

It’s not about obviousness, it’s about getting at the pattern of reusable “components”. I understand why Item is evaluated before Section. To be fair, it’s not about Reagent specifically. It goes back to HTML, and virtually every frontend framework mimics that structure. I.e., it’s possible to place an on-screen element inside of another on-screen element, where the second one doesn’t necessarily know or care about what you place inside it.

henrik07:09:40

Electric, of course, is a different model, so it’s obvious why this doesn’t happen. I don’t think it’ll be obvious to every person that tries out Electric, and I do think it might put some constraints on what type of higher order “components” are possible for a layman to write, and therefore might impact what the ecosystem around Electric ends up looking like.

xificurC07:09:44

> It goes back to HTML, and virtually every frontend framework mimics that structure. I.e., it’s possible to place an on-screen element inside of another on-screen element, where the second one doesn’t necessarily know or care about what you place inside it. As I showed this is true for electric as well. (Section. Item Item Item) sends in 3 components and Section will call new on them, not caring about their structure

xificurC07:09:10

it's true we're using novelty budget, so users need adapting. But there's nothing stopping one from composing and building higher-order components

henrik07:09:27

Right, but say Item takes arguments. And say it’s not one level, but 3-5 levels of children. It’s possible, I’m just saying that it’s not necessarily obvious how to do it, and it’s not necessarily ergonomic.

xificurC08:09:40

if you encounter a non-obvious case, feel free to post a snippet and we'll help

henrik08:09:05

I think it’s possible to extrapolate from the above, no? Hang on, I’ll provide another example.

henrik08:09:34

Pseudocode meant to mirror something you might see in React etc.:

(Menu. {:orientation :vertical}
  (Section. {:title "Profile"}
    (Item. {:title "Edit profile"
            :image (Image. (UserImage.))}
      (dom/on "click" …))
    (Item {:title "Log out"}
      (dom/on "click" …)))
  (Section. {:title "Documents"}
    (for-by [document documents]
      (File. {:icon (DocumentIcon.)} document))))
You can get to this with macros or lambdas or a combination thereof. You can also forgo the reusable part and make ProfileSection and DocumentSection, ProfileItem and LogOutItem etc., and not compose them this way.

henrik08:09:25

Also, if you believe it to be a non-issue, I won’t press it. For me personally, it’s not a show-stopper.

henrik08:09:13

I’m just trying to shed some light on what I believe led to the original question.

xificurC08:09:31

sure, thanks! There are many ways to structure your example, some brainstormed ideas • if Menu is just dom/menu with some styles you could extract the styles in a separate helper and write (dom/menu (MenuStyles. {:orientation :vertical}) ...) • if you want to mimic the dom helpers you'd write (demacro menu [styles & body] (dom/menu (MenuStyles. styles) @body))` • you can also thunk all the way, although in this example it would get pretty verbose • the dom/on examples cannot work as-is, you really need a macro for that. Also, you're injecting behavior, which means the component isn't really a black box, suggesting the abstraction is leaking • these high-level components tend to turn into god-class-like behemoths, maybe one should just inline them and factor out the commonalities into helper functions The list resembles typical coding questions - where to put abstractions in and where to leave them out, how to factor our code, do we want to write a macro for syntax sugar etc.

👍 2
henrik08:09:35

Regarding god-class stuff, the goal is the opposite: rather than having a huge props API to some enormous thing that internally hides/shows components, flips behaviour on it’s internally constructed children, etc. in response to arguments, you try to shrink the API and expose the generic capability of being able to take children from the outside. The constraint being that the parent can’t then inject behaviour on them—this is done from the outside at the moment of composition. There’s no silver bullet, and it usually ends up being a trade-off where the practical solution is some level of props API + semi-generic ability to take some class of children. A menu component can’t take arbitrary children, obviously, only those that make sense in a menu. But it can be agnostic to the children of the children, etc., which a mega-component that controls all levels of children from the top down, internally, can’t.

Dustin Getz10:09:32

the unit of reuse is lambda, components are lambda, this appears to be a discussion of syntax , not of reuse

henrik10:09:14

Yeah, it’s mostly about ergonomics and approachability. Stuff from hyperfiddle.electric-dom2 compose declaratively, as does the stuff from ui, whereas your own “components” do not. This is surprising, hence the original question.

👀 2
👍 2
henrik10:09:38

To be absolutely clear, my points are not: • About bashing Electric: it’s fantastic • Saying that Electric’s model is wrong • Making any arguments for how you should prioritize or structure your time It’s only about trying to convey that for some, this is surprising. I then go on to speculate that this is one variable that might impact how easily libraries, third-party components etc. made for Electric proliferates. I think an “at hand” pattern for dealing with it would help. With which you might disagree.

Dustin Getz11:09:18

Ah ok specifically the change in apparent semantics between the dom macros and regular Electric code is what is surprising, that i accept as true

👍 2
Dustin Getz11:09:16

Our official stance on this is that we care much more about language semantics than having easy dom authoring syntax this year. Committing to a syntax too soon (before understanding advanced use cases) risks getting it wrong

👍 2
Dustin Getz11:09:16

as for data composition vs lambda composition, it's important to note that lambda composition is more fundamental, data can be implemented in terms of lambda

Dustin Getz11:09:32

{:a {:b 2}} vs (hash-map :a (hash-map :b 2))

Dustin Getz11:09:54

you could imagine an OOP pattern like this quick sketch, but it has issues to work out

(e/defn Div [& children]
  (e/fn []
    (let [el (goog.dom/createElement "div")]
      (.appendChild dom/node el)
      (binding [dom/node el]
        (e/for [Child children] ; dynamic traversal at runtime
          (Child.))))))

(Div. (Div.) (Div. (Div.)))

Dustin Getz11:09:39

anyway the upcoming differential Electric has implications in how lower level things are done, which is another reason why we choose not to work on syntax until Electric is more complete than it is today

👍 2
Dustin Getz11:09:28

@U02ABMYDCGK the intuition to use here is that in an effectful dom abstraction, dom effects (say dom/text ) evaluate like println , does that match what you experience?

Jordan Calderwood13:09:49

Yes. That matches what I am seeing and works nicely. I built a nice little UI but I have a lot of repeated code. I am just looking for a way to pass children into a component of my creation. Specifically a Card component with an x in the top right that takes a call-back to reset an atom and arbitrary children that will render inside the card. The effectful nature of electric dom makes that hard (for me!) to do. I don’t want to write macros for components 😝

Jordan Calderwood13:09:46

Also, thanks for building this thing. It is a ton of fun and feels magical.

Dustin Getz15:09:40

Do you have your answer? wrap your dom markup in e/fn and pass it around as lambda, call it at the place where you want to splice it in

👍 2
Jordan Calderwood16:09:02

Ah I understand now what y’all were getting at about lambda as the abstraction. Works for me! Thanks for the help.

Jordan Calderwood16:09:36

One other thing that “got me” was having to call new on the e/fn lambda.

henrik09:09:13

Shower thought: Clojure has clojurians, so it follows that Electric has… Electricians?

😄 15
tatut09:09:53

the end users of electric apps are then an electrified audience?

mattias12:09:30

If I have the following let block

[!a (atom {:k1 (some-fn) :k2 val2})
 a (e/watch a)]
and val2 is some dynamic value (e.g. derived from path , defined with e/def like in https://github.com/hyperfiddle/electric/blob/master/src/contrib/electric_goog_history.cljc#L63C50-L63C57). 1. what happens when val2 changes (in this case, due to navigating to a new page)? a. Do we get a new instance of !a or is the :k2 value of !a updated? b. If we get a new instance of !a, is some-fn also called again when val2 changes? 2. if we (swap! !a assoc :k2 123) , and then val2 gets a new value (by navigating to a new page), will (get a :k2) have that new value or 123 ?

😮 2
xificurC12:09:20

1a: yes, you get a new value. A clj(s) function is re-run when any of its arguments change. In this case the hash-map is re-built (the hash-map literal compiles down to (hash-map :k1 (some-fn) :k2 val2)), so atom re-runs as well

xificurC12:09:59

1b: no, some-fn is cached, since no arguments changed to the function call

xificurC12:09:30

2: yes, you'll see the new value, since the change of val2 builds a new atom which gets a new watch etc

xificurC12:09:13

what is the purpose of pushing a reactive value into an atom to later watch and retrieve it again?

mattias12:09:04

I kind of stumbled on this by trying something silly (i rewrote it, because it felt wrong). But since it worked, I started wondering why it works and what happens under the hood. Good chance to get a better intuition for Electric. Thanks for the explanations, all clear!

xificurC12:09:53

no problem! In cases where you have to do this it might be simpler to pull the reactive part out

(let [!a (atom {:k1 (some-fn)})]
  (swap! !a assoc :k2 val2)
  (e/watch !a))
Now the atom is stable and only the swap! re-runs

mattias13:09:24

Oh, cool, I was looking for sth like that! What about a slightly different scenario:

(let [!a (atom {:k1 1
                :k2 2})
      a (e/watch a)
      k1 (a :k1)
      k2 (a :k2)]
  (Component1. k1)
  (Component2. k2)
  (Component3. a))
If (a :k1) changes, do both Component1 and Component3 get re-rendered? Or only parts of them which actually use the k1 value (either the k1 let binding directly, which is passed down, or e.g. via (get a :k1) somewhere downstream)?

mattias13:09:52

Based on what you just said, seems like both [k1 (a :k1)] and [k2 (a :k2)] would be re-evaluated, because their input changed (I'm assuming (a :k1) is the same as (get a :k1) . And then components 1 and 3 would be re-computed, since their arguments changed, but component 2 would not since its input value didn't change?

xificurC13:09:55

the update propagates through them and only calls that depend on the value will re-run. It's a DAG

xificurC13:09:51

correct, (:k2 a) will re-run but return the same value, therefore downstream dependencies will use the cache (last run's result)

mattias13:09:13

OK, so the caching is really granular and done at the level of map values as well, rather than at the whole atom level. Really impressive 🔥

joshcho12:09:06

2 questions: is there any reason to consider sth like htmx with electric? also, what would it take to use electric for TUI?

xificurC12:09:51

htmx is built around RESTful communication. Electric abstracts the network away. I don't see a good fit here personally

❤️ 2
xificurC12:09:08

re TUI - definitely doable, one would need to build the reactive layer like we built for the DOM, <https://github.com/hyperfiddle/electric/blob/983544cb84819ab1ff3bfefe316192da648bd18a/src/hyperfiddle/electric_dom2.cljc%7Cwhich is <400loc> with some tests and deprecated fns

joshcho13:09:18

sounds like a fun project

joshcho13:09:09

electric as shared communication layer would be quite amazing

joshcho13:09:50

what library would u recommend if i want to do some dom manipulations

xificurC13:09:06

what manipulations are you thinking of?

joshcho13:09:23

now that i think about it, not much. declarative ui takes care of most things. but i was considering sth like https://github.com/plumatic/dommy for making handlers in vanilla js less painful to write

Dustin Getz16:09:16

are you asking for a nicer electric-dom authoring syntax?

joshcho23:09:11

yeah, kinda. Just manipulating the dom in event handlers

joshcho23:09:38

Vanilla javascript functions are not ergonomic

nivekuil18:09:02

is there a demo of typeahead/tag-picker somewhere? need to make a "start typing, show existing items, if none of those work then make new thing" interface

Dustin Getz19:09:14

We have several different impls spread across projects that need consolidation

Dustin Getz19:09:27

One that is bundled with electric is this, it's old though – https://electric-demo.fly.dev/(wip.tag-picker!TagPicker)

nivekuil19:09:52

hmm, getting maximum call stack size exceeded if I use tag-picker within a (for-by (dom/div ...

nivekuil19:09:08

this works

(e/for-by
       :id [{:keys [id text editing? importance base-importance]}
            (new (q '[[id ::node-type :problem]
                      [id ::node-text text]
                      [id ::editing? editing?]
                      [id ::importance importance]
                      [id ::base-importance base-importance]
                      #_[id ::blocks blocks]]))]
       (let [x (atom nil)]
         (ui/tag-picker (e/watch x)
                        (e/fn [add] nil)
                        (e/fn [disj] nil)
                        (e/fn [search-input] nil)
                        (e/fn [e] nil)))
...

nivekuil19:09:22

this doesnt

(e/for-by
       :id [{:keys [id text editing? importance base-importance]}
            (new (q '[[id ::node-type :problem]
                      [id ::node-text text]
                      [id ::editing? editing?]
                      [id ::importance importance]
                      [id ::base-importance base-importance]
                      #_[id ::blocks blocks]]))]
       (dom/div
        (let [x (atom nil)]
          (ui/tag-picker (e/watch x)
                         (e/fn [add] nil)
                         (e/fn [disj] nil)
                         (e/fn [search-input] nil)
                         (e/fn [e] nil)))  

nivekuil19:09:13

that extra layer of nesting is breaking something I guess