Reagent Flow
A ClojureScript library that wraps ReactFlow

reagent-flow?include_prereleases&style=flat-square.svg

Usage

You can mostly follow the ReactFlow documentation and be sure to replace "react" with "reagent" and use kebab-casing instead of camelCasing. There are some exceptions.

  • Types are prefixed with Flow and uses the original camelCasing. We recommend using the keyword equivalent instead though, so you could ignore types altogether.
  • The parameters received in node-types & edge-types are unchanged, so if you want to use them you should apply (js->clj props :keywordize-keys true). A nice pattern, is to only rely on the id from the parameters and do look-ups in your state manually.
(defn- custom-node [{:keys [id] :as props}]
     (let [node (flow/get-node-by-id @nodes id)
           data (:data node)]
       [:p (:label data)])
  • Hooks are avoided. You manage state with atoms however you please and there are events to listen for viewport-changes on the main component; reagent-flow.

You can read more about the API at cljdocs.

Please examine the examples below to get a better grasp of the aforementioned differences.

Examples

Custom Nodes

Connect the nodes, pick a color and see the nodes change interactively.

We use atoms to store nodes & edges. The nodes you see here with the types parameter are custom nodes.

(def nodes
  (r/atom [{:id          :explanation
            :connectable false
            :draggable   false
            :selectable  false
            :position    {:x 0 :y 0}
            :data        {:label "Pick a color & connect the nodes"}}
           {:id              :c1
            :type            :color-node
            :class-name      :color-node
            :position        {:x 60 :y 60}
            :data            {:color "#e6d5d0"}
            :source-position :right}
           {:id              :p2
            :type            :preview-node
            :position        {:x 300 :y 300}
            :data            {:label "Preview color"}
            :target-position :left}]))

(def edges
  (r/atom []))

This is the code for the color-node. Note that we use get-node-by-id. This will return the node with an associated index, so that we can make changes to our atom above.

(defn color-node [{:keys [id data]}]
  (let [node          (get-node-by-id @nodes id)
        default-color (-> data :color)]
    (letfn [(handle-change [event]
              (let [color (-> event .-target .-value)
                    path  [(:index node) :data]]
                (swap! nodes update-in path assoc :color color)))]
      (fn [{is-connectable :isConnectable}]
        (let [node  (get-node-by-id @nodes id)
              color (-> node :data :color)]
          [:<>
           [:input {:class         [:nodrag :color-picker] 
                    :type          :color
                    :on-change     handle-change
                    :value         color
                    :default-value default-color}]
           [handle {:type           :source
                    :position       :right
                    :id             :a
                    :is-connectable is-connectable}]])))))

As with the color-node, the preview-node uses the isConnected parameter. Note that it doesn't follow idiomatic Clojure naming as the rest of ReagentFlow. This is due to ReactFlow calling our function directly. Also note how easy it is to make a node resizable.

(defn preview-node [{id             :id
                     is-connectable :isConnectable
                     selected       :selected}]
  (let [node            (get-node-by-id @nodes id)
        {:keys [label]} (:data node)
        connection      (first (get-connections-by-node-id @edges id))
        source          (get-node-by-id @nodes (:source connection))
        color           (-> source :data :color)]
    [:<>
     [node-resizer {:is-visible selected
                    :min-width  80
                    :min-height 50}]
     [:div {:style (merge {:background-color :white
                           :display          :flex
                           :align-items      :center
                           :justify-content  :center
                           :border-radius    :5px
                           :height           "100%"
                           :padding          :1em}
                          (when connection {:background-color color}))}
      [:strong {:style {:color  color
                        :filter "invert(100%) grayscale(1)"}} label]]
     [handle {:type           :target
              :position       :left
              :id             :b
              :is-connectable is-connectable}]]))

(defonce node-types
  {:color-node   color-node
   :preview-node preview-node})

As with ReactFlow, we need to define our event-handlers outside of it's render-loop.

(defn- main []
  (letfn [(handle-node-changes [changes]
            (reset! nodes (apply-node-changes changes @nodes)))
          (handle-edge-changes [changes]
            (reset! edges (apply-edge-changes changes @edges)))
          (handle-connect [connection]
            (reset! edges (add-edge connection @edges)))]
    (fn []
      [reagent-flow {:nodes                @nodes
                     :edges                @edges
                     :node-types           node-types
                     :fit-view             true
                     :on-nodes-change      handle-node-changes
                     :on-edges-change      handle-edge-changes
                     :on-connect           handle-connect
                     :connection-line-type :smoothstep
                     :default-edge-options {:animated true
                                            :type     :smoothstep}}
       [background {:style {:background-color "#ffffff"}}]])))

Drop it like it’s hot

Drag & drop nodes from a top panel and onto the graph. Edges can be connected and disconnected per usual.

Here you can note the use of on-viewport-change which is not part of the original ReactFlow component. In ReactFlow, this is available as a hook, but we try to avoid hooks for simplicity. The handler receives a map with x, y & zoom values, just as the hook equivalent.

(defn- main []
  (let [flow      (atom nil)
        provider  (atom nil)
        viewport  (r/atom {:x 0 :y 0 :zoom 1})
        data-type "application/reagentflow"]
    (letfn [(handle-drag [event]
              (let [data-transfer (-> event .-dataTransfer)]
                (.setData data-transfer data-type "default")
                (set! (-> data-transfer .-effectAllowed) "move")))
            (handle-node-changes [changes]
              (reset! nodes (apply-node-changes changes @nodes)))
            (handle-edge-changes [changes]
              (reset! edges (apply-edge-changes changes @edges)))
            (handle-connect [connection]
              (reset! edges (add-edge connection @edges)))
            (handle-drop [event]
              (.preventDefault event)
              (when-let [node-type (.getData (-> event .-dataTransfer) data-type)]
                (let [{:keys [screen-to-flow-position]} @provider
                      flow-el           (-> flow .-state .-firstChild) 
                      rect              (.getBoundingClientRect flow-el)
                      position          (screen-to-flow-position {:x (.-clientX event)
                                                                  :y (.-clientY event)})]
                  (swap! node-id inc)
                  (swap! nodes conj {:id       (str "node-" @node-id)
                                     :type     node-type
                                     :position position
                                     :data     {:label (str "Node #" @node-id)}}))))
            (handle-drag-over [event]
              (.preventDefault event)
              (set! (-> event .-dataTransfer .-dropEffect) "move"))]
      (fn []
        [:<>
         [:menu.node-palette
          [:div.node {:draggable     true
                      :on-drag-start handle-drag} "Node"]]
         [reagent-flow {:ref                  #(reset! flow %)
                        :id                   :drop-it-like-its-hot
                        :nodes                @nodes
                        :edges                @edges
                        :fit-view             true
                        :on-init              #(reset! provider %)
                        :on-nodes-change      handle-node-changes
                        :on-edges-change      handle-edge-changes
                        :on-connect           handle-connect
                        :on-drop              handle-drop
                        :on-drag-over         handle-drag-over
                        :on-viewport-change   #(reset! viewport %)
                        :connection-line-type :smoothstep
                        :default-edge-options {:type :smoothstep}}
          [background {:style {:background-color "#ffffff"}}]]]))))
(defonce root (atom nil))

(defn error-boundary [& children]
  (let [error (r/atom nil)]
    (r/create-class
     {:display-name                 "ErrorBoundary" 
      :get-derived-state-from-error (fn [e] #js {})
      :component-did-catch          (fn [err info] (reset! error [err info])) 
      :reagent-render
      (fn [& children]
        (if (nil? @error)
          (into [:<>] children)
          (let [[_ info] @error]
            [:pre.error
             [:code (pr-str info)]
             [:br]
             [:button {:on-click #(.error js/console info)} "Output stacktrace"]])))})))

(defn ^:export init [element]
  (when (nil? @root)
    (reset! root (rdom/create-root element))
    (rdom/render @root [error-boundary [main]])))

(defn ^:export unload [element]
  (when (not (nil? @root))
    (rdom/unmount @root)
    (reset! root nil)))

Stress

This example stress-tests react-flow rendering in combination with reagent state-handling.

We create 100 nodes in total; all sorted into a grid with connections running between every node. There's one sum-node that adds together the value of each of the connected nodes. Only connections that affect the sum are animated.

(def num-nodes 100)
(def rows (/ num-nodes 10))
(def cols (/ num-nodes rows))

(defonce sum-node-value (r/atom 0))

Each node and edge can be uniquely identified and as such also modified. Try making a connection from Node #99 to the Sum node and see the value of the nodes propagate through the grid.

(defonce nodes
  (r/atom (into (->> (range 1 (inc num-nodes))
                     (mapv (fn [idx]
                             (let [x (* 200 (mod (dec idx) cols))
                                   y (* 200 (quot (dec idx) cols))]
                               {:id       (str "node-" idx)
                                :type     (if (= idx 1) :input :default)
                                :position {:x x :y y}
                                :data     {:label (str "Node #" idx)
                                           :value idx}}))))
                [{:id        :sum-node
                  :type      :sum-node
                  :deletable false
                  :position  {:x (* 200 (dec cols)) :y (* 200 rows)}}])))

(defonce edges
  (r/atom (->> (range 1 (inc num-nodes))
               (mapv (fn [idx]
                       (merge 
                        {:id (str "edge-" idx)}
                        (when (> idx 1)
                          {:source (str "node-" (dec idx))})
                        (when (< idx (inc num-nodes))
                          {:target (str "node-" idx)})))))))

We use a few helper-functions to achieve this which you can see here.

(defn- follow-source [edge connections]
  (if-let [source (get edge :source)]
    (let [sources (some #(when (= (name source) (name (:target %))) %) connections)]
      (conj (follow-source sources connections) edge))
    [edge]))

(defn- animate [connected connections]
  (map (fn [connection]
         (let [connection (dissoc connection :animated)]
           (if-let [edge (some #(when (= (:target %) (:target connection)) %) connected)]
             (assoc edge :animated true)
             connection)))
       connections))

(defn- sum [connected]
  (transduce (comp (map :source)
                (map (partial get-node-by-id @nodes))
                (map (comp :value :data)))
             + 0 connected))

(defn- sum-node-edge [connection connections]
  (or (when (= (:target connection) "sum-node") connection)
      (first (get-connections-by-node-id connections :sum-node :target))))

(defn- find-connected [connection connections]
  (sequence
   (comp (mapcat #(follow-source % connections))
      (filter some?))
   [(sum-node-edge connection connections)]))

Our sum-node is really simple, but it needs to be a function for the atom to be de-referenced upon change.

(defn- sum-node []
  [:<>
   [:pre (str "Sum: " @sum-node-value)]
   [handle {:id       :sum-handle
            :type     :target
            :position :top}]])

(defonce node-types
  {:sum-node sum-node})

And here we put the stress example together. Note that we use set-center upon initialization and how that is treated as a regular ClojureScript function.

Contributing

The repository for this library can be found on github.

As mentioned, reagent-flow is just a wrapper, so there's not much logic here. If you discover any issues, those are likely to stem from ReactFlow and should be reported there. If you are confident that you've discovered an issue with this wrapper or have some feedback, feel free to open an issue.

The wrapper is written in a literate style using org-mode; so to contribute code, the easiest path is to use Emacs for the time being. All code, tests and documentation is in index.org, from there it's about tangling and weaving the document:

  • C-c C-v t will tangle the source-code into files on disk (babel/).
  • M-x org-publish-project & reagent-flow will weave the documentation onto the filesystem (docs/).

After having done this, you should be able to build & run tests locally:

Building

npm i
clojure -M:build

Running tests

clojure -M:test

Running examples locally

First make sure that index.org and Setup.org are both tangled.

(dolist (file '("index.org" "Setup.org"))
  (org-babel-tangle-file file))

Then publish the documentation.

(org-publish-project "reagent-flow")

Then run the shadow watcher in the babel/examples directory

npm i
clj -M:watch

and follow the instructions that appear.

Publishing

Whenever a pull-request is merged into main, a github-action takes over. The action will build & run tests. If the tests pass ✓️, the library will be packed into a jar. To actually publish to Clojars and update github pages with the latest documentation, you'll have to create a tag.