Threading callbacks and atoms around in Reagent


#1

So it’s my first day of seriously trying to re-implement some of our old-school jQuery + Handlebars UI in Reagent. Due to some constraints, I currently have to mimic the current HTML/CSS structure we have.

I’ve hit my first snag that lead to some pretty hairy spaghetti code, so I hope there is a better way (I know re-frame, but I want to explore the basics first).

Imagine a reusable drop-down component. It is comprised by three elements:

  1. The top-level element, :div.parent
  2. The button that toggles it, :button
  3. The actual dropdown content :div.content

The html structure looks roughly like so:

    [:div.parent
      [:button "Open"]
      (when @is-open
        [:div.content "More stuff here"])

Now due to my constraints, when the dropdown is open, I have to set a class to the :div.parent element so it becomes :div.parent.open. At the same time, because I want to be able to close via clicking outside, or hitting Escape I need to track some refs so I can distinguish between mouse clicks on the :div.content (ignore), :div.parent (ignore, and let the parent handle it) or the background.

I also need to pass around a function close-parent-fn so that when something succeeds in the content (say you created a new widget), the dropdown closes and you don’t have to click outside. Or you could imagine an explicit close button etc etc.

This is all possible, yet a little bit hairy for a single-use component, but this general logic is reused throughout the application so I’d like to be able to separate the reusable bits (ref tracking, document event handling, etc etc) into some wrapper function, so in the end I could write something like:

(defn my-complex-content
  [:div.content "imagine a huge thing here"])

...

[:some.pretty.nested.ui
  [my-cool-dropdown
    [:div.parent
      [:button "Open"]
      [my-complex-content]]]

I.e. something that shows intent rather than the plumbing.

I tried to implement this by wrangling vectors and mangling property maps, which kinda worked right up until I want to put something more complex in there (i.e. a function call).

If I understand the issue correctly, I’m suffering by a classic React problem, that is you have to thread some top-level concern (which function to call when you want to close the dropdown) through all intermediate components until you can attach it to some :on-click handler.

I know React introduced Contexts for something similar to this, and they also mention render-props but I’m struggling to see how this maps to idiomatic Reagent.

Any pointers to documentation or projects that deal with this kind of thing in an elegant way would be very appreciated.


#2

Because reagent doesn’t have easy access to React context, this does become a pain to implement in a fully general way.

I think that you can probably use something similar to a render-prop here. I’m not sure how it maps to “idiomatic” reagent but it seems to look OK to me:

E.g. imagine this:

[my-cool-dropdown
 (fn [{:keys [toggle is-shown?]}]
   [:div {:class ["parent" (when is-shown? "open")]}
    [:button "Open" {:on-click toggle}]
     (when is-shown? [my-complex-content])]])]

You can probably massage this to give you more/less control over what’s going on in the render body.


#4

Not sure, but would the new React hooks feature allow you to write a reusable portion of your code ? @lilactown posted some experiments implementing hooks in Reagent somewhere in ClojureVerse.


#5

Reagent creates React classes, so you can’t use React hooks directly in a Reagent component. I have done some experiments with hooks using a different React wrapper I wrote myself, but that won’t help OP if they’re already using Reagent.


#6

I’ll most certainly end up using re-frame, but I’m currently exploring the fundamentals so I have a better understanding of the tradeoffs.

@lilactown Is that snippet you posted valid Reagent Hiccup? Having the second element of a vector being a function? Is that interpreted as an anonymous component? How do you pass it props?

Unfortunately the documentation of Reagent is very opaque to me. I’m not sure if it is my Google skills failing me, or that a lot of corner cases or patterns are documented in blog posts that may be obsolete, but it’s certainly frustrating. I’d happily pay for a comprehensive book on various Reagent patterns.


#7

Thinking a little bit more of my implementation of bashing vectors, I think I got carried away thinking “it’s just data” but it seems now thing are a little bit more subtle.

I’ll try and see how far stateless components (that accept render-fund children as props) can go, and then compare it to re-frame, which does have global state in the end.

My main UI experience is from iOS which just doesn’t suffer many of this kind of problems we’re trying to circumvent here, and functional web UIs still seem to be a research in progress.


#8

I have found the re-frame documentation of Reagent to be easy to understand.


#9

@orestis The basic reagent algorithm is as follows:

  1. Starting from the top-level call to r/render, reagent looks at the type of the first argument passed in.
  2. If that argument is a vector: reagent creates a react component using the function associated with the first symbol in the vector (or a native element if it is a keyword).
  3. Reagent then calls the render function using the remaining elements in the vector as arguments.
    • Technically if if the second element of the vector is a map, then that map gets passed as props. Otherwise things are passed in the children array.)
    • If the render function returns another function, then reagent uses that for subsequent updates. This is why form-2 components only get called once at the beginning of the component lifecycle.
  4. Reagent recursively processes the result of the render function starting at step #1.

There is some detail missing here, such as how it treats sequences and other types, but that’s the gist of it. Understanding this basic approach makes it possible to understand why form-2 components don’t work if you fail to repeat the arguments or if you directly invoke them instead of return a vector that refers to them.

That’s more information than you need, but yes, you can pass a function as an argument to a component no problem. I think what @lilactown is envisioning here is that my-cool-dropdown would take a single render-func as an argument and then invoke it, passing the toggle and is-shown? parameters based on the click detection and so forth.


#10

Thanks! I approached this after a night’s sleep, re-read your explanation and whatever documentation I could find, and I came up with an approach that doesn’t try to be so reusable.

Meaning, when assembling your cool dropdown you still have to know some things, so it’s not as designer-friendly as I’d hoped, but it seems that it’s probably impossible to do 100% designer friendly stuff in this space.

I did end up pushing some of the logic out to a global state, and that’s actually cleared things up much more.

The only bit of truly reusable code I ended up with was:

(defn dropdown-menu [{:keys [tag class kbd-handler mouse-handler]
                      :or {tag :div, class ""}} & children]
  (reagent/with-let
    [_ (js/document.addEventListener "mousedown" mouse-handler)
     _ (js/document.addEventListener "keydown" kbd-handler)]
    (into [tag
           (reagent/merge-props {:class "my-dropdown-menu"} {:class class})]
          children)
    (finally
      (js/document.removeEventListener "mousedown" mouse-handler)
      (js/document.removeEventListener "keydown" kbd-handler))))

and that gets used like so:

... snip ... `ref` is the top-level component
(when is-open
       [g/dropdown-menu {:tag :ul
                         :kbd-handler (utils/esc-handler close-fn)
                         :mouse-handler (utils/mouse-handler ref close-fn)}
        [g/quick-action "pencil" "Edit"]
        [g/quick-action "bin" "Delete"]])

So it’s not too bad.