Skip to content

Latest commit

 

History

History
281 lines (218 loc) · 10.2 KB

2020-07-18.md

File metadata and controls

281 lines (218 loc) · 10.2 KB

Learning Clojure in Public - Week 4 Day 6 (27/35)

Expectations

Now that I finished Clojure for the Brave and True, it will feel much harder to keep a learning trajectory. It's no longer goals right in front of me, it's a challenge of finding the knowledge rather than just acquiring knowledge in a straight line. In a sense I have to think about what I am doing in more details.

Another thing is I feel I am getting closer to potential productivity with a ClojureScript codebase (without having read anything about it yet!). It motivates me to continue ClojureFam beyond ClojureFam. There are so many more things to learn and I will be happy to continue to do so, but maybe at a slower pace, while hopefully contributing to Athens.

My expectation for today is to read the purelyfunctional.tv piece on re-frame features which I hope compliments what I learned yesterday reading the basics in the re-frame documentation (a second pass is always useful anyway).

Beyond that I want to look into our issue a little bit more and hopefully be able to understand the flickering we are seeing when moving the selected element in Athena.

What I learned

The event queue in re-frame

re-frame gives you an event queue. You can register events like this (rf/dispatch [:buy 32343]). The events are processed one at a time. I do like the separation of concerns so you can remove the logic from the component itself (in React you would have some of the login at least live with the component). You can go from this in Reagent:

(defn buy-button [item-id]
  [:button
   {:on-click (fn [e]
                (.preventDefault e)
                (ajax/post (str "http://url.com/product/" item-id "/purchase")
                  {:on-success #(swap! app-state assoc :shopping-cart %)
                   :on-error #(swap! app-state update :errors conj)}))}
   "Buy"])

...to this in re-frame:

(defn buy-button [item-id]
  [:button
    {:on-click (fn [e]
                 (.preventDefault e)
                 (rf/dispatch [:buy item-id]))}
    "Buy"])

Handling events

Event handlers are registered with re-frame.core/reg-event-fx, it registers a pure function which takes the data from the event.

(rf/reg-event-fx ;; register an event handler
  :buy           ;; for events with this name
  (fn [cofx [_ item-id]] ;; get the co-effects and destructure the event
    {:http-xhrio {:uri (str "http://url.com/product/" item-id "/purchase")
                  :method :post
                  :timeout 10000
                  :response-format (ajax/json-response-format {:keywords? true})
                  :on-success [:added-cart]
                  :on-failure [:notified-error]}
     :db (update-in (:db cofx) [:cart :items] conj {:item item-id})}))

Effects

Effects are low-level details. They can be network calls, storing of data, outputting to the console. Here is how you do a network call:

(rf/reg-fx       ;; register an event handler
  :http-xhrio    ;; the name is the key in the effects map
  (fn [request]  ;; we get the value (a map) we stored at that key
    (case (:method request :get)
      :post (ajax/post (:url request))
      ...)))

Database events

Most events have the sole effect of taking in the data from the event and modifying the database with it. So there is a shortcut for it. Let's say you want to store the name of the current user in the Database:

  :save-name
  (fn [db [_ first-name last-name]]
    (update db :current-user assoc :first-name first-name :last-name last-name)))

Keeping our event handlers pure

Event handlers are supposed to be pure. So what if we want to get the date or something similar? The solution here is to use co-effects. The event handler takes a co-effect map as a first argument. We specify what co-effects we need here:

(rf/reg-event-fx
  :buy
  [(rf/inject-cofx :now)] ;; add the co-effects we need here.
  (fn [cofx [_ item-id]]
    {:http-xhrio {:uri (str "http://url.com/product/" item-id "/purchase")
                  :params {:time (:now cofx)} ;; use the time
                  :method :post
                  :response-format (ajax/json-response-format {:keywords? true})
                  :on-success [:added-cart]
                  :on-failure [:notified-error]}
     :db (update-in (:db cofx) [:cart :items] conj {:item item-id})}))

Handling co-effects

Co-effects are also declared on startup. You abstract them into their own functions, to do that one effect:

(rf/reg-cofx
  :now
  (fn [cofx _data] ;; _data unused
    (assoc cofx :now (js/Date.))))

If we wanted to have temporary id for saving to the database, we modify the co-effects map, and add the temp_id in success and failure cases:

(rf/reg-event-fx
  :buy
  [(rf/inject-cofx :temp-id)] ;; we'll define this later
  (fn [cofx [_ item-id]] ;; get the co-effects and destructure the event
    {:http-xhrio {:uri (str "http://url.com/product/" item-id "/purchase")
                  :method :post
                  :timeout 10000
                  :response-format (ajax/json-response-format {:keywords? true})
                  :on-success [:added-cart (:temp-id cofx)]       ;; start using temp-id
                  :on-failure [:notified-error (:temp-id cofx)]}  ;; here, too
     :db (update-in (:db cofx) [:cart :items] conj {:item item-id
                                                    :temp-id (:temp-id cofx)})}))

We can then define the co-effect handler:

(defonce last-temp-id (atom 0))

(rf/reg-cofx
  :temp-id ;; same name
  (fn [cofx _]
    (assoc cofx :temp-id (swap! last-temp-id inc))))

Interceptors

Interceptors let us reuse some of these event building blocks. It's a fancy way of creating a data pipeline. An interceptor is a pair of before and after functions.

Let's do that in a really basic way. Here's how it's going to work. If you put the undo Interceptor in your Event handler registration, the Interceptor will save the current value of the Database in a Ratom. Then we can make an undo event that will restore the last version of the Database. Let's make a Reagent Atom to store the last value of the Database. We'll store it in a list.

(def undos (r/atom ()))

Then we can make an interceptor:

(def undo-interceptor
  (re-frame/->interceptor
    :id :undo
    :before (fn [context] ;; we take the interceptor context
              (swap! undos conj (-> context :coeffects :db))
              context))) ;; return the context unmodified

The next step is to register the undo event:

(reg-event-fx
  :undo
  (fn [_ _]
    (let [undo-values @undos]
      (if (empty? undo-values)
        (do
          (js/console.log "No undo values, but :undo was dispatched.")
          {}) ;; return no effects
        (let [[f & rs] undo-values]
          (reset! undos rs)
          {:db f}))))) ;; update the db

Finally we can use the interceptor by putting it in the event handler:

(reg-event-db
  :add-triangle
  [undo-interceptor]
  (fn [db]
    (update db :shapes conj :triangle)))

The re-frame database

In re-frame, atoms are for component-local state. Application-global state goes in the centralized database which is already provided. It's a Reagent atom and it's a Clojure map, so it can store pretty much anything.

You want to specify exactly what data the Component needs and only re-render if that data changes, otherwise every component will re-render all the time, which is very unnecessary.

Re-frame gives you Subscriptions to do that. Subscriptions are parts of the data from the Database. Let's say you wanted to build a component to list the shopping cart items. You could define a Subscription to give you just the items out of the Database.

(rf/reg-sub
  :cart-items
  (fn [db _]
    (:items db)))

And this is how you subscribe, to only the cart items' changes. The components do not need to know the structure of the database, it only needs to know the name of the element it is subscribe to:

(defn cart []
  (let [items (rf/subscribe [:cart-items])]
    (fn []
      [:ul
        (doall
          (for [item @items]
            [:li {:key (:name item)} (:name item)]))])))

If we were to move the cart items in the database under the cart key, we would only have to modify the cart subscription, like this:

(rf/reg-sub
  :cart-items
  (fn [db _]
    (:items (:cart db))))

Reactive de-duplication

Let's say we want to display a cart icon, we would have the following component:

(defn cart-icon []
  (let [items (rf/subscribe [:cart-items])]
    (fn []
      [:span [:img {:src "/cart.png"}] " " (count @items)])))

However, it doesn't need to receive all the cart items and do the calculation. We can create a new subscription which chains the original one that will do the calculation and only re-render the component when they're an actual need:

(rf/reg-sub
  :cart-count
  (fn [_]
    (rf/subscribe [:cart-items]))
  (fn [items]
    (count items)))

;; And the component
(defn cart-icon []
  (let [count (rf/subscribe [:cart-count])]
    (fn []
      [:span [:img {:src "/cart.png"}] " " @count])))

We can also combine two subscriptions into one. For instance, you can use one to filter the other:

; New subscription
(rf/reg-sub
  :cart-filter-settings
  (fn [db _]
    (:filter-settings (:cart db))))

(rf/reg-sub
  :cart-items-filtered
  (fn [_]
    ;; return a vector of subscriptions
    [(rf/subscribe [:cart-items])
     (rf/subscribe [:cart-filter-settings])])
  (fn [[items settings]] ;; now this will be a vector of the current values
    (apply-filters settings items))) ;; actually do the filtering

Takeaways

I took another tour (covering some different elements) of re-frame building block and this is definitely useful. I feel a little bit more ready to contribute to Athens and to build my own little application. The organization makes complete sense, and doesn't feel artificial at all like in some other frameworks.

With an easy decision, you can figure out exactly where to put each bit of your code.

Unfortunately I didn't make much progress on our issue besides figuring out that imperatively there is no flickering issue.