Preface

This blogpost took over a month to write, it’s what I like to call exploratory writing where I explore some ideas that I have, or a new topic that I don’t know about and share it with you. It isn’t as polished as I’d like it to be. I also need to find a better way to present UI code, like having a live UI to interact with. The writing is quite long, there is a lot of code, but hopefully you’ll make it through.

If you’re in a hurry the really interesting part starts here Creating the date range component and definitely don’t skip the Related blogposts section.

Introduction

“Yeah, sure, your job is challenging. I can never center a div” I mocked a frontend engineer. I’ve always thought frontend folks are superficial, they only care about making things look good (which is very important, honestly), but backend development is where its really at. Kinda like that engineer that says “true programmers code in assembly”. I was in for a rude awakening. Turns out, centering a div is not the hardest thing in frontend development. An innocent-looking form is the villian of this story.

It all started with a humble-input box, but first let’s create a new project (or read along if you don’t prefer to get your hands dirty). The code for the project is here and the finished app is here

lein new re-frame date_field
cd date_field
npm install
npx shadow-cljs watch app

This input box is a date field. You wrote data as text.

modify index.html to include bulma. We want things to look good.


<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset='utf-8'>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
    <title>date_field</title>
  </head>
  <body>
    <noscript>
      date_field is a JavaScript app. Please enable JavaScript to continue.
    </noscript>
    <div id="app"></div>
    <script src="js/compiled/app.js"></script>
  </body>
</html>

Building a date field input box

;; views.cljs
(ns date-field.views)

(defn main-panel
  []
  [:div {:class ["container" "my-6"]}
   [:input {:class ["input"]
            :type "text"
            :placeholder "DD-MM-YYYY"}]])

it should look like this input box without button

let’s also add a submit button.

I’ve also extracted the input into a date-field function

(ns date-field.views)


(defn submit-button
  []
  [:button {:class ["button" "my-3" "is-dark"]}
   "submit"])


(defn date-field
  []
  [:input {:class ["input"]
           :type "text"
           :placeholder "DD-MM-YYYY"}])


(defn main-panel
  []
  [:div {:class ["container" "my-6"]}
   [date-field]
   [submit-button]])

input box with button

Adding input validation to the date-field

It has been pretty simple so far. But let’s add some input validation. We can rely on html input validation or write something in cljs. let’s go with cljs. We also want to visually indicate to the user that the text they are typing is correct or not. For that we need to store the value of the input the user is typing. This can be done with a reagent atom.

(ns date-field.views
  (:require
    [reagent.core :as r])) ; new import


(defn submit-button
  []
  [:button {:class ["button" "my-3"  "is-dark"]}
   "submit"])


(defn date-field
  []
  (let [date-value (r/atom "")] ;; new 
    (fn []                 ;; new 
      [:input {:class ["input"]
               :type "text"
               :placeholder "DD-MM-YYYY"}])))


(defn main-panel
  []
  [:div {:class ["container" "my-6"]}
   [date-field]
   [submit-button]])

But we aren’t modifying the value of date-value in this code, we want it to change everytime the user is changing the text. There’s a browser event called onInput that gets fired everytime the user changes the input.

(defn date-field
  []
  (let [date-value (r/atom "")]
    (fn []
      (prn @date-value) ; so we see the date-value actually change
      [:input {:class ["input"]
               :type "text"
               :placeholder "DD-MM-YYYY"
               :on-input (fn [x]
                           (reset! date-value (-> x .-target .-value)))}])))

We aren’t anywhere close to input validation yet, but we are getting there. Now, everytime the state of the component changes, we can check for validity and if invalid, we visually indicate it to the user.

(ns date-field.views
  (:require
    [reagent.core :as r]))

;; everything else is the same as before 

(defn date-field
  []
  (let [date-value (r/atom "")]
    (fn []
      [:input {:class ["input" (if (valid-date-format? @date-value) nil "is-danger")] ;; new
               :type "text"
               :placeholder "DD-MM-YYYY"
               :on-input (fn [x]
                           (reset! date-value (-> x .-target .-value)))}])))

input box with validation

Disabling the submit button when form input is invalid

If the input isn’t valid, we want to disable the submit button. In order for the submit button to “know” that it needs to be disabled, we need date-field to “communicate” that it’s in an invalid state. So now we need to share state between these two components.

Let’s add a new atom to date-field, lets call it valid? and initialize it to true. And let’s use it to conditionally show the css class is-danger.

(defn date-field
  []
  (let [date-value (r/atom "")
        valid? (r/atom true)] ;; new
    (fn []
      [:input {:class ["input" (if @valid? nil "is-danger")] ;;new 
               :type "text"
               :placeholder "DD-MM-YYYY"
               :on-input (fn [x]
                           (reset! date-value (-> x .-target .-value)))}])))

but we aren’t changing the state of valid? anywhere, we want to recompute the valid? state everytime date-value changes. It can be done by using add-watch which adds a watch to the date-value atom (think observer pattern for the OOP inclined).

(defn valid-date-format?
  [date]
  (or (empty? date)
      ;; FYI this is a bad way to validate dates.
      ;; you should rely on your underlying date-time library
      ;; for this validation but for the blogpost this is
      ;; good enough
      (boolean (re-matches #"[\d]{2}\-[\d]{2}\-[\d]{4}" date))))

(defn date-field
  []
  (let [date-value (r/atom "")
        valid? (r/atom true)]
    ;; new
    (add-watch date-value
               :check-validity
               (fn [k reference old-value new-value]
                 (reset! valid? (valid-date-format? new-value))))
    (fn []
      [:input {:class ["input" (if @valid? nil "is-danger")]
               :type "text"
               :placeholder "DD-MM-YYYY"
               :on-input (fn [x]
                           (reset! date-value (-> x .-target .-value)))}])))

but we haven’t yet figured out a way to inform the submit button that something is invalid. Taking a step back, remember that these HTML elements are a part of a tree, and if you want to share a value between two nodes, you need to store it in their lowest common ancestor. What I mean by that is if you have the following tree

      7
    /  \
  5     6
 / \   / \
1   2  3  4

and you want to share something between node 1 and 2 then you need to store it in node 5, and if you need to share something between node 1 and node 3, you need to store it node 7.

Our current DOM tree can be simplified to three Nodes.

      main-panel
    /            \
date-field submit-button

Sharing state between components

We need to store some state in main-panel, let’s call the state errors?, and let’s pass a prop called enabled? to submit-button.

(defn submit-button
  [{:keys [enabled?] :or {enabled? true}}] ;; new
  [:button {:class ["button" "my-3"  "is-dark"]
            :disabled (not enabled?)} ;; new
   "submit"])

(defn main-panel
  []
  (let [errors? (r/atom false)] ;; new
    (fn []
      [:div {:class ["container" "my-6"]}
       [date-field]
       [submit-button {:enabled? (not @errors?)}]]))) ;; new

So far so good, we just need to change the errors? atom when the date-field goes into an invalid state. Let’s add an on-error handler for the date-field component, that fires when the date-field’s state of valid? is false. Yes we are going to add another watch.

(defn date-field
  [{:keys [on-error on-error-resolved]
    :or {on-error identity}}]
  (let [date-value (r/atom "")
        valid? (r/atom true)]
    ;; new
    (add-watch date-value
               :check-validity
               (fn [k reference old-value new-value]
                 (reset! valid? (valid-date-format? new-value))))
    (add-watch valid?
               :on-error ; don't worry about this keyword it's not important
               (fn [k reference old-value new-value]
                 (when (false? new-value)
                   (on-error))))
    (fn []
      [:input {:class ["input" (if @valid? nil "is-danger")]
               :type "text"
               :placeholder "DD-MM-YYYY"
               :on-input (fn [x]
                           (reset! date-value (-> x .-target .-value)))}])))


(defn main-panel
  []
  (let [errors? (r/atom false)]
    (fn []
      [:div {:class ["container" "my-6"]}
       [date-field {:on-error (fn [] (reset! errors? true))}]
       [submit-button {:enabled? (not @errors?)}]])))

but after the date-field becomes valid, we need to enable the submit button again, so we need to reset the errors? state again. Let’s create another handler on-error-resolved.

(defn date-field
  [{:keys [on-error on-error-resolved]
    :or {on-error identity
         on-error-resolved identity}}] ;;new
  (let [date-value (r/atom "")
        valid? (r/atom true)]
    ;; new
    (add-watch date-value
               :check-validity
               (fn [k reference old-value new-value]
                 (reset! valid? (valid-date-format? new-value))))
    (add-watch valid?
               :on-error ; don't worry about this keyword it's not important
               (fn [k reference old-value new-value]
                 (if (false? new-value)
                   (on-error)
                   (on-error-resolved)))) ;;new
    (fn []
      [:input {:class ["input" (if @valid? nil "is-danger")]
               :type "text"
               :placeholder "DD-MM-YYYY"
               :on-input (fn [x]
                           (reset! date-value (-> x .-target .-value)))}])))


(defn main-panel
  []
  (let [errors? (r/atom false)]
    (fn []
      [:div {:class ["container" "my-6"]}
       [date-field {:on-error (fn [] (reset! errors? true))
                    :on-error-resolved (fn [] (reset! errors? false))}] ;;new
       [submit-button {:enabled? (not @errors?)}]])))

That’s quite a bit of code to replicate what the browser gives out of the box if you rely on html forms and html input validation.

But react pays more than vanilla html, so let’s continue :)

Mocking the behaviour of submitting form data

Now, when the user clicks the button, you’d want to clear the state in the date field (and send it to the backend, but we’ll just pretend it goes to the backend here). If the button needs to access and manipulate the state of date-field we need to “pull” the state into the main-panel, and pass the value of it as a prop to date-field which means that date-field becomes stateless. Let’s refactor that first.

(defn date-field
  [{:keys [on-error on-error-resolved on-input value]
    :or {on-error identity
         on-error-resolved identity
         on-input identity}}] ;; new 
  (fn [{:keys [value]}] ;; new 
    (let [valid-input? (valid-date-format? value)]       ;; new 
      (if valid-input? (on-error-resolved) (on-error))   ;; new 
      [:input {:class ["input" (if valid-input? nil "is-danger")]
               :type "text"
               :value value
               :placeholder "DD-MM-YYYY"
               :on-input (fn [x] (on-input (-> x .-target .-value)))}]))) ;; new


(defn main-panel
  []
  (let [errors? (r/atom false)
        panel-state (r/atom {:date-field ""})] ;; new
    (fn []
      [:div {:class ["container" "my-6"]}
       [date-field {:on-error (fn [] (reset! errors? true))
                    :on-error-resolved (fn [] (reset! errors? false))
                    :value (:date-field @panel-state)
                    :on-input (fn [x] (swap! panel-state assoc :date-field x))}]
       [submit-button {:enabled? (not @errors?)
                       :on-click #(reset! panel-state {:date-field ""})}]])))

So we’ve made date-field completely stateless and moved our state into the main-panel. Let’s also display a helpful error message when the user inputs something wrong.

(defn date-field
  [{:keys [on-error on-error-resolved on-input value]
    :or {on-error identity
         on-error-resolved identity
         on-input identity}}]
  (fn [{:keys [value]}]
    (let [valid-input? (valid-date-format? value)]          
      (if valid-input? (on-error-resolved) (on-error))      
      [:div [:input {:class ["input" (if valid-input? nil "is-danger")]
                     :type "text"
                     :value value
                     :placeholder "DD-MM-YYYY"
                     :on-input (fn [x] (on-input (-> x .-target .-value)))}]
       (when (not valid-input?)
         [:p {:class "help is-danger"} "incorrect input date format, use DD-MM-YYYY"])])))

This is what your file should look like

(ns date-field.views
  (:require
    [reagent.core :as r]))


(defn submit-button
  [{:keys [enabled? on-click]
    :or {enabled? true
         on-click identity}}]
  [:button {:class ["button" "my-3"  "is-dark"]
            :disabled (not enabled?)
            :on-click on-click}
   "submit"])


(defn valid-date-format?
  [date]
  (or (empty? date)
      ;; FYI this is a bad way to validate dates.
      ;; you should rely on your underlying date-time library
      ;; for this validation but for the blogpost this is
      ;; good enough
      (boolean (re-matches #"[\d]{2}\-[\d]{2}\-[\d]{4}" date))))


(defn date-field
  [{:keys [on-error on-error-resolved on-input value]
    :or {on-error identity
         on-error-resolved identity
         on-input identity}}]
  (fn [{:keys [value]}]
    (let [valid-input? (valid-date-format? value)]
      (if valid-input? (on-error-resolved) (on-error))
      [:div [:input {:class ["input" (if valid-input? nil "is-danger")]
                     :type "text"
                     :value value
                     :placeholder "DD-MM-YYYY"
                     :on-input (fn [x] (on-input (-> x .-target .-value)))}]
       (when (not valid-input?)
         [:p {:class "help is-danger"} "incorrect input date format, use DD-MM-YYYY"])])))


(defn main-panel
  []
  (let [errors? (r/atom false)
        panel-state (r/atom {:date-field ""})]
    (fn []
      [:div {:class ["container" "my-6"]}
       [date-field {:on-error (fn [] (reset! errors? true))
                    :on-error-resolved (fn [] (reset! errors? false))
                    :value (:date-field @panel-state)
                    :on-input (fn [x] (swap! panel-state assoc :date-field x))}]
       [submit-button {:enabled? (not @errors?)
                       :on-click #(reset! panel-state {:date-field ""})}]])))

Creating the date range component

Things are about to get complicated, be prepared to pull your hair out!

Using the date-field we want to create a date-range component, which has a start and end date.

(defn date-range
  []
  [:div {:class ["my-6" "is-flex is-align-items-center "]}
   [:p {:class "pr-3"} "from"]
   [:span {:class "pr-3"} [date-field]]
   [:p {:class "pr-3"} "to"]
   [:span {:class "pr-3"} [date-field]]])


(defn main-panel
  []
  (let [errors? (r/atom false)
        panel-state (r/atom {:date-field ""})]
    (fn []
      [:div {:class ["container" "my-6"]}
       [date-field {:on-error (fn [] (reset! errors? true))
                    :on-error-resolved (fn [] (reset! errors? false))
                    :value (:date-field @panel-state)
                    :on-input (fn [x] (swap! panel-state assoc :date-field x))}]
       [date-range] ;; new 
       [submit-button {:enabled? (not @errors?)
                       :on-click #(reset! panel-state {:date-field ""})}]])))

Thinking about state-management, we need to share state between the date-range component and submit-button, because if there’s an error in the date-range component, we want to disable the submit button. The state should be stored in panel-state so change panel-state to this

panel-state (r/atom {:date-field ""
                     :date-range {:start ""
                                  :end ""}})

After storing the state, we need to also change it when on-input gets fired, so we create an on-input handler.

(defn date-range
  [{:keys [start
           end
           on-input]}]
  [:div {:class ["my-6" "is-flex is-align-items-center "]}
   [:p {:class "pr-3"} "from"]
   [:span {:class "pr-3"} [date-field {:value start
                                       :on-input (fn [x] (on-input #(assoc % :start x)))}]]
   [:p {:class "pr-3"} "to"]
   [:span {:class "pr-3"} [date-field {:value end
                                       :on-input (fn [x] (on-input #(assoc % :end x)))}]]])


(defn main-panel
  []
  (let [errors? (r/atom false)
        panel-state (r/atom {:date-field ""
                             :date-range {:start ""
                                          :end ""}})]
    (fn []
      [:div {:class ["container" "my-6"]}
       [date-field {:on-error (fn [] (reset! errors? true))
                    :on-error-resolved (fn [] (reset! errors? false))
                    :value (:date-field @panel-state)
                    :on-input (fn [x] (swap! panel-state assoc :date-field x))}]
       [date-range {:start (get-in @panel-state [:date-range :start])
                    :end (get-in @panel-state [:date-range :end])
                    :on-input (fn [f] (swap! panel-state update :date-range f))}]
       [submit-button {:enabled? (not @errors?)
                       :on-click #(reset! panel-state {:date-field ""})}]])))

as you can see the on-input for date-range is a tad complex, it took me a bit of mental gymanstics to figure out what to pass to the date-field component inside the date-range component. Readability is important.

Disabling the submit button when form input is invalid

Now, there are three date-field components on the page. 1 plain date-field and 2 inside the date-range component. Each of these can individually be valid or invalid. But even if one is invalid we need to disable the button. If we give all three components the ability to change the errors? atom, then there will be an infinite loop. Let’s say date-field-1 (df-1 from now on) is in error, df-1 will call the on-error, but df-2 and df-3 won’t be errored and will call on-error-resolved, which would flip the value of errors? and cause a re-render of components then df-1 will flip errors? to true and so on.

a better way might be to store the error state of each individual component in the errors? atom. something like this.


errors? (r/atom {:date-field false
                         :date-range {:start false
                                      :end false}})

;; you also need to modify the on-error and on-error-resolved functions for date-field

(defn main-panel
  []
  (let [errors? (r/atom {:date-field false
                         :date-range {:start false
                                      :end false}})
        panel-state (r/atom {:date-field ""
                             :date-range {:start ""
                                          :end ""}})]
    (fn []
      [:div {:class ["container" "my-6"]}
       [date-field {:on-error (fn [] (swap! errors? assoc :date-field true)) ;; changed
                    :on-error-resolved (fn [] (swap! errors? assoc :date-field false)) ;; changed
                    :value (:date-field @panel-state)
                    :on-input (fn [x] (swap! panel-state assoc :date-field x))}]
       [date-range {:start (get-in @panel-state [:date-range :start])
                    :end (get-in @panel-state [:date-range :end])
                    :on-input (fn [f] (swap! panel-state update :date-range f))}]
       [submit-button {:enabled? (not @errors?)
                       :on-click #(reset! panel-state {:date-field ""})}]])))

you also need to modify the date-range component

(defn date-range
  [{:keys [start
           end
           on-input
           on-error
           on-error-resolved]}]
  [:div {:class ["my-6" "is-flex is-align-items-center "]}
   [:p {:class "pr-3"} "from"]
   [:span {:class "pr-3"}
    [date-field {:value start
                 :on-input (fn [x] (on-input #(assoc % :start x)))
                 :on-error (fn [] (on-error #(assoc % :start true)))              ;; new
                 :on-error-resolved (fn [] (on-error #(assoc % :start false)))}]] ;; new
   [:p {:class "pr-3"} "to"]
   [:span {:class "pr-3"}
    [date-field {:value end
                 :on-input (fn [x] (on-input #(assoc % :end x)))
                 :on-error (fn [] (on-error #(assoc % :end true)))                ;; new
                 :on-error-resolved (fn [] (on-error #(assoc % :end false)))}]]]) ;; new


(defn main-panel
  []
  (let [errors? (r/atom {:date-field false
                         :date-range {:start false
                                      :end false}})
        panel-state (r/atom {:date-field ""
                             :date-range {:start ""
                                          :end ""}})]
    (fn []
      [:div {:class ["container" "my-6"]}
       [date-field {:on-error (fn [] (swap! errors? assoc :date-field true))
                    :on-error-resolved (fn [] (swap! errors? assoc :date-field false))
                    :value (:date-field @panel-state)
                    :on-input (fn [x] (swap! panel-state assoc :date-field x))}]
       [date-range {:start (get-in @panel-state [:date-range :start])
                    :end (get-in @panel-state [:date-range :end])
                    :on-input (fn [f] (swap! panel-state update :date-range f))
                    :on-error (fn [f] (swap! errors? update :date-range f))            ;; new
                    :on-error-resolved (fn [f] (swap! errors? update :date-range f))}] ;; new
       [submit-button {:enabled? (not @errors?)
                       :on-click #(reset! panel-state {:date-field ""})}]])))

the problem with modelling errors? this way is that we need to walk the tree in errors? everytime and ensure that all the leaves are false to figure out whether to disable the submit button or not.


;; don't worry about the implementation of this, that's not important
(defn contains-errors?
  [m]
  (let [child-nodes (fn this
                      [m]
                      (reduce (fn [acc el]
                                (if (map-entry? el)
                                  (let [value (val el)]
                                    (cond
                                      (boolean? value)
                                      (conj acc value)

                                      (map? value)
                                      (into acc (this value))))
                                  el))
                              []
                              m))]
    (not (every? false? (child-nodes m)))))

(defn main-panel
  []
  (let [errors (r/atom {:date-field false
                        :date-range {:start false
                                     :end false}})
        panel-state (r/atom {:date-field ""
                             :date-range {:start ""
                                          :end ""}})]
    (fn []
      [:div {:class ["container" "my-6"]}
       [date-field {:on-error (fn [] (swap! errors assoc :date-field true))
                    :on-error-resolved (fn [] (swap! errors assoc :date-field false))
                    :value (:date-field @panel-state)
                    :on-input (fn [x] (swap! panel-state assoc :date-field x))}]
       [date-range {:start (get-in @panel-state [:date-range :start])
                    :end (get-in @panel-state [:date-range :end])
                    :on-input (fn [f] (swap! panel-state update :date-range f))
                    :on-error (fn [f] (swap! errors update :date-range f))
                    :on-error-resolved (fn [f] (swap! errors update :date-range f))}]
       [submit-button {:enabled? (not (contains-errors? @errors))
                       :on-click #(reset! panel-state {:date-field ""})}]])))

I’ve also renamed errors? to errors since it’s a map and not a boolean.

Taking a step back, and looking at the bigger picture, you notice that the props (the big map with :start, :on-input keys) we pass to date-range get directly passed to the date-field component inside of it? because react re-renders a component when its props change, the date-range component and the two date-field components will be re-rendered when you change the input in one of its date-fields.

Also, since the date-field changes the panel-state and there are other components that use the panel-state like date-field (the one not inside date-range) and submit-button, those components will re-render as well when an input changes. So we are effectively re-rendering everything inside the main-panel when one of the components changes value.

This isn’t a problem in our tiny toy app but it’s not a scalable way of doing things. It only gets worse when the components are even more deeply nested. The point of react is to only re-render the part of the DOM that changed, not the entire DOM.

If you want to see which components get re-rendered, add a prn in the definition of each component and interact with it, you’ll see how many components get re-rendered on every interaction (that’s how I figured it out as well).

Props drilling

The pattern of passing props from one component only to have it be passed to another, like in the date-range field is an all too common problem in react and is called props drilling. While props drilling allows you to share state between components, it also causes a lot of unnecessary re-renders of components.

If you preserve local state in components, it becomes hard to share state between two components, which is why we moved the state to the parent components, so they could be shared. Which brings us to the crux of this blogpost: state management.

Exploring a solution to the problem of re-rendering and state sharing

If you think back to the beginning of the blogpost, where each component tried to manage their own state, and tried to synchronise state, it resembled OOP, where each object handles their own state, and then you synchronise it with other objects and so on. Re-frame talks about this exact thing.

Nothing screams “complicated dynamics” more than needing to “distribute state”. Well, other than appending “… over unreliable networks”. This is, of course, why OO can be problematic. How on earth did we ever think that deliberately distributing state into hidden little mutable packets and then having to dynamically synchronise them was a good idea? And, Your Honour, I was as guilty as the rest. re-frame puts state in the one place….

The frontend isn’t unique in having a problem with state management. The backend also has a similar problem, and we solved it using databases. The only difference is that databases are seen as solutions to the problem of data persistance, rather than state management. But when we want to share state between two backend modules or functions or api pathways, we store and retrieve it from the database. I’m not saying that using a database automatically guarantees a stateless backend, I’m just saying that it is what lets us share and manage state, and keep the code layer stateless.

The point of a database is that the state of the application is in ONE place. That’s the key idea here. If we didn’t care about data persistance we could store the entire state of an application in an in-memory object.

We were kind of moving towards that solution in the last iteration of our app. We moved all our state inside the main-panel in two atoms errors and panel-state, and passed it to all the children. But it wasn’t global state. Each component had to explicity be passed that global state.

Let’s see what happens when we extract panel to global state, lets see if it fixes our re-rendering problem.

(def panel-state
  (r/atom {:date-field ""
           :date-range {:start ""
                        :end ""}}))


(def errors
  (r/atom {:date-field false
           :date-range {:start false
                        :end false}}))


(defn main-panel
  []
  (fn []
    [:div {:class ["container" "my-6"]}
     [date-field {:on-error (fn [] (swap! errors assoc :date-field true))
                  :on-error-resolved (fn [] (swap! errors assoc :date-field false))
                  :value (:date-field @panel-state)
                  :on-input (fn [x] (swap! panel-state assoc :date-field x))}]
     [date-range {:start (get-in @panel-state [:date-range :start])
                  :end (get-in @panel-state [:date-range :end])
                  :on-input (fn [f] (swap! panel-state update :date-range f))
                  :on-error (fn [f] (swap! errors update :date-range f))
                  :on-error-resolved (fn [f] (swap! errors update :date-range f))}]
     [submit-button {:enabled? (not (contains-errors? @errors))
                     :on-click #(reset! panel-state {:date-field ""
                                                     :date-range {:start ""
                                                                  :end ""}})}]]))

This still doesn’t fix our re-rendering problem because everything that subscribes to a reagent atom will be re-rendered when that atom changes. Whereas we want only those components to re-render whose data has changed.

So if date-field changes, we only want the date-field to re-render not the others. A way around this might be to extract each state in panel-state into an individual atom like this

(def date-field (r/atom ""))

(def date-range-start (r/atom ""))

(def date-range-end (r/atom ""))

(def date-field (r/atom {:start @date-range-start
                         :end @date-range-end}))

but that will quickly become unsustainable, and hard to maintain. This long-winded exercise wasn’t for nothing though. It helps a lot to understand a problem and what kind of solutions we might come up with and the solutions that fail, because it will help develop an appreciation for the solutions that already exist.

There are three key takeways from this exercise.

  1. Global state is the easiest way to share state between two components.
  2. Storing everything in a one atom will cause all the components to re-render when any component updates the atom but is simple and scalable.
  3. Storing state separately in individual atoms (globally) can solve the issue of all components re-rendering, but is not simple and scalable.

Combining all three approaches

There is one way to combine the three approaches. We can store state in a global atom, but have the components “subscribe” to only part of that atom.

(def panel-state
  (r/atom {:date-field ""
           :date-range {:start ""
                        :end ""}}))
(defn main-panel
  []
  (fn []
    [:div {:class ["container" "my-6"]}

     ;; only needs to subscribe to the value of `date-field` in `panel-state`
     [date-field {:on-error (fn [] (swap! errors assoc :date-field true))
                  :on-error-resolved (fn [] (swap! errors assoc :date-field false))
                  :value (:date-field @panel-state)
                  :on-input (fn [x] (swap! panel-state assoc :date-field x))}]
        
     ;; only needs to subscribe to the value of `date-range` in `panel-state`
     [date-range {:start (get-in @panel-state [:date-range :start])
                  :end (get-in @panel-state [:date-range :end])
                  :on-input (fn [f] (swap! panel-state update :date-range f))
                  :on-error (fn [f] (swap! errors update :date-range f))
                  :on-error-resolved (fn [f] (swap! errors update :date-range f))}]
     [submit-button {:enabled? (not (contains-errors? @errors))
                     :on-click #(reset! panel-state {:date-field ""
                                                     :date-range {:start ""
                                                                  :end ""}})}]]))

Introducing subscriptions

One approach is to dynamically generate atoms for each “subscription” and have the components deref those atoms instead of the global state directly. Then we can also have one function that runs everytime the global state is updated, and that function figures out which parts of the global atom have changed and which subscriptions need to be changed as well.

First, let’s rewrite the components to use the global state

(def panel-state
  (r/atom {:date-field ""
           :date-range {:start ""
                        :end ""}
           :errors  {:date-field false            ;; moved errors into panel state and updated callback functions below
                     :date-range {:start false
                                  :end false}}}))


(defn main-panel
  []
  (fn []
    [:div {:class ["container" "my-6"]}
     [date-field {:on-error (fn [] (swap! panel-state assoc-in [:errors :date-field] true))
                  :on-error-resolved (fn [] (swap! panel-state assoc-in [:errors :date-field] false))
                  :value (:date-field @panel-state)
                  :on-input (fn [x] (swap! panel-state assoc :date-field x))}]
     [date-range {:start (get-in @panel-state [:date-range :start])
                  :end (get-in @panel-state [:date-range :end])
                  :on-input (fn [f] (swap! panel-state update :date-range f))
                  :on-error (fn [f] (swap! panel-state update-in [:errors :date-range] f))
                  :on-error-resolved (fn [f] (swap! panel-state update-in [:errors :date-range] f))}]
     [submit-button {:enabled? (not (contains-errors? (:errors @panel-state)))
                     :on-click #(reset! panel-state {:date-field ""
                                                     :date-range {:start ""
                                                                  :end ""}})}]]))


Let’s create a function that will create an atom for each subscription.

;; we need a place to store the subscriptions and the atoms associated with them
;; this can be a normal clojure atom.
;; no need for it to be a r/atom.
(defonce subscriptions (atom {}))


(defn subscribe
  [key_ ks] ; ks is a vector of keys 
  (let [atm (r/atom nil)] 
    ;; check if a subscription is already registered because react rerenders components, and you don't want to register 
    ;; a new subscription every time
    (if (contains? @subscriptions key_)
      (get-in @subscriptions [key_ :state])
      (do (swap! subscriptions assoc key_ {:f #(get-in % ks)
                                           :state atm})
          atm))))

Then we need to create a function that will run when the global state is updated and call the subscriptions if the global state has changed. We’ll use a technique we used previously.

(defn watch-and-call-subscriptions
  [_key _ref old-value new-value]
  (for [[subscription-fn subscription-atom] @subscriptions]
    (when (not= (subscription-fn old-value) (subscription-fn new-value))
      (reset! subscription-atom (subscription-fn new-value)))))


(add-watch panel-state :watch-global-state watch-and-call-subscriptions)

Now, lets change our components to use the new subscribe function.

(defn main-panel
  []
  (fn []
    [:div {:class ["container" "my-6"]}
     [date-field {:on-error (fn [] (swap! panel-state assoc-in [:errors :date-field] true))
                  :on-error-resolved (fn [] (swap! panel-state assoc-in [:errors :date-field] false))
                  :id :date-field
                  :value @(subscribe :date-field [:date-field])                                            ; atom 1
                  :on-input (fn [x] (swap! panel-state assoc :date-field x))}]
     [date-range {:start @(subscribe :date-range-start [:date-range :start])                              ; atom 2
                  :end   @(subscribe :date-range-start [:date-range :end])                                ; atom 3
                  :on-input (fn [f] (swap! panel-state update :date-range f))
                  :on-error (fn [f] (swap! panel-state update-in [:errors :date-range] f))
                  :on-error-resolved (fn [f] (swap! panel-state update-in [:errors :date-range] f))}]
     [submit-button {:enabled? (not (contains-errors? @(subscribe :errors [:errors])))                     ; atom 4
                     :on-click #(reset! panel-state {:date-field ""
                                                     :date-range {:start ""
                                                                  :end ""}})}]]))

If you run this code now, it works. BUT we still haven’t fixed the re-rendering problem, (this is quite hard!) from the docs of reagent

Any component that uses an atom is automagically re-rendered when its value changes.

Key takeway

If a component is derefing multiple atoms, even if those value are passed to the child components, the entire component along with the child components will re-render if even one of those atoms changes. That’s why panel-state re-renders everytime some input changes.

So the next obvious approach would be to reference the atoms in each individual component (doesn’t this give you deja vu?), instead of having main-panel pass it to the child components. There were a lot of changes to the file. But the important ones are that each component now takes two props, an :id and a :subscription-path.

(ns date-field.views
  (:require
    [reagent.core :as r]))


(defonce subscriptions (atom {}))


(def panel-state
  (atom {:date-field ""
         :date-range {:start ""
                      :end ""}
         :errors  {:date-field false
                   :date-range {:start false
                                :end false}}}))


(defn subscribe
  [key_ ks]
  (let [f #(get-in % ks)
        atm (r/atom (f @panel-state))] ; initialize atom with state from panel-state

    ;; check if a subscription is already registered because react rerenders componnents, and you don't want to register 
    ;; a new subscription every time
    (if (contains? @subscriptions key_)
      (get-in @subscriptions [key_ :state])
      (do  (swap! subscriptions assoc key_ {:f #(get-in % ks)
                                            :state atm})
           atm))))

;; not important
(defn contains-errors?
  [m]
  (let [child-nodes (fn this
                      [m]
                      (reduce (fn [acc el]
                                (if (map-entry? el)
                                  (let [value (val el)]
                                    (cond
                                      (boolean? value)
                                      (conj acc value)

                                      (map? value)
                                      (into acc (this value))))
                                  el))
                              []
                              m))]
    (not (every? false? (child-nodes m)))))


(defn submit-button
  [{:keys [id subscription-path enabled? on-click]
    :or {enabled? true
         on-click identity}}]
  (let [errors @(subscribe id subscription-path)          ;; new
        enabled? (not (contains-errors? errors))]         ;; new
    [:button {:class ["button" "my-3"  "is-dark"]
              :disabled (not enabled?)
              :on-click on-click}
     "submit"]))


(defn valid-date-format?
  [date]
  (or (empty? date)
      ;; FYI this is a bad way to validate dates.
      ;; you should rely on your underlying date-time library
      ;; for this validation but for the blogpost this is
      ;; good enough
      (boolean (re-matches #"[\d]{2}\-[\d]{2}\-[\d]{4}" date))))


(defn date-field
  [{:keys [id subscription-path on-error on-error-resolved on-input]
    :or {on-error identity
         on-error-resolved identity
         on-input identity}}]
  (fn []
    (let [value @(subscribe id subscription-path)                                            ;; new
          valid-input? (valid-date-format? value)]
      (if valid-input? (on-error-resolved) (on-error))
      [:div [:input {:class ["input" (if valid-input? nil "is-danger")]
                     :type "text"
                     :value value
                     :placeholder "DD-MM-YYYY"
                     :on-input (fn [x] (on-input (-> x .-target .-value)))}]
       (when (not valid-input?)
         [:p {:class "help is-danger"} "incorrect input date format, use DD-MM-YYYY"])])))


(defn date-range
  [{:keys [on-input
           on-error
           id
           subscription-path
           on-error-resolved]}]
  [:div {:class ["my-6" "is-flex is-align-items-center "]}
   [:p {:class "pr-3"} "from"]
   [:span {:class "pr-3"}
    [date-field {:id (keyword (str (name id) "-" "start"))                                ;; new
                 :subscription-path (conj subscription-path :start)                       ;; new
                 :on-input (fn [x] (on-input #(assoc % :start x)))
                 :on-error (fn [] (on-error #(assoc % :start true)))
                 :on-error-resolved (fn [] (on-error-resolved #(assoc % :start false)))}]]
   [:p {:class "pr-3"} "to"]
   [:span {:class "pr-3"}
    [date-field {:id (keyword (str (name id) "-" "end"))                                  ;; new
                 :subscription-path (conj subscription-path :end)                         ;; new
                 :on-input (fn [x] (on-input #(assoc % :end x)))
                 :on-error (fn [] (on-error #(assoc % :end true)))
                 :on-error-resolved (fn [] (on-error-resolved #(assoc % :end false)))}]]])


(defn watch-and-call-subscriptions
  [_key _ref old-value new-value]
  ;; (prn " watch-and-call-subscriptions")
  ;; (prn "subs" @subscriptions)
  (doseq [[_  {:keys [f state]}] @subscriptions]
    (when (not= (f old-value) (f new-value))
      (reset! state (f new-value)))))


(add-watch panel-state :watch-global-state watch-and-call-subscriptions)


;; now the main-panel doesn't reference any atom!
(defn main-panel
  []
  (fn []
    [:div {:class ["container" "my-6"]}
     [date-field {:on-error (fn [] (swap! panel-state assoc-in [:errors :date-field] true))
                  :on-error-resolved (fn [] (swap! panel-state assoc-in [:errors :date-field] false))
                  :id :date-field
                  :subscription-path [:date-field]
                  :on-input (fn [x] (swap! panel-state assoc :date-field x))}]
     [date-range {:id :date-range
                  :subscription-path [:date-range]
                  :on-input (fn [f] (swap! panel-state update :date-range f))
                  :on-error (fn [f] (swap! panel-state update-in [:errors :date-range] f))
                  :on-error-resolved (fn [f] (swap! panel-state update-in [:errors :date-range] f))}]
     [submit-button {:id :submit-button
                     :subscription-path [:errors]
                     :on-click #(reset! panel-state {:date-field ""
                                                     :date-range {:start ""
                                                                  :end ""}})}]]))

Now if you try to see the browser console with prn in each component, only those components re-render whose state has changed. So cool!

The ideas for subscribe that I used is “stolen” from re-frame. An alternative to the is reagent’s cursors

Input validation in the date-range component

The date-range component has the notion of a range, there’s a start-date and an end-date. The start date can’t be greater than the end date. We need to validate this as well. If the date-range is invalid, then we also need to show an error message, "Start date can't be after end date" or End date can't be before start date. Which to me just sounds like more hair-pulling, but this was a requirement.

Lets take a step back, and think about the behaviour. We display the error state and error message in the date-field component, but the error can be triggered by the date-range component or the date-field component. The error message can be temporarily changed by the date-range component, BUT we only show the date-range error when the date-field is valid. In other words, if the date-field input is invalid, then we want to show the "incorrect input date format, use DD-MM-YYYY" error message. Apart from all this, the date-range component also needs to tell the submit button that it has an invalid input. Now, this is an incredibly simplistic UI, we literally have 4 things in the UI from the User’s perspective but the behaviour is already quite complex, Imagine what a UI with more elements look like.

Adding validation

Honestly speaking, I don’t know what a clean solution for this looks like, all I can think of is a few hacky ways. And this blogpost is already getting long (I don’t know how many of you I’ve already lost). But let me present a wild idea I had. It is inspired by Continuation Passing Style

The idea is that date-range needs to modify the date-field component’s error state and error-message, since date-field just returns hiccup, we can traverse the hiccup and make the necessary changes.

We modify date-field to take a function called continuation

(defn date-field
  [{:keys [id subscription-path on-error on-error-resolved on-input continuation]
    :or {on-error identity
         on-error-resolved identity
         on-input identity
         continuation identity}}] ;; new
  (fn []
    (continuation  ; new
      (let [value @(subscribe id subscription-path)
            valid-input? (valid-date-format? value)]
        (if valid-input? (on-error-resolved) (on-error))
        [:div [:input {:class ["input" (if valid-input? nil "is-danger")]
                       :type "text"
                       :value value
                       :placeholder "DD-MM-YYYY"
                       :on-input (fn [x] (on-input (-> x .-target .-value)))}]
         (when (not valid-input?)
           [:p {:class "help is-danger"} "incorrect input date format, use DD-MM-YYYY"])]))))
(defn dates-are-ascending?
  [a b]
  ;; naive date comparator
  ;; converts DD-MM-YYYY to yyyy-mm-dd and compares the strings
  (let [[d-a m-a y-a]  (str/split a #"-")
        [d-b m-b y-b]  (str/split b #"-")
        comp- (compare (str/join "-" [y-a m-a d-a]) (str/join "-" [y-b m-b d-b]))]
    (or (neg? comp-)
        (zero? comp-))))

(defn date-range
  [{:keys [on-input
           on-error
           id
           subscription-path
           on-error-resolved]}]
  (let [start-date @(subscribe (keyword (str (name id) "-" "start")) (conj subscription-path :start))
        end-date @(subscribe (keyword (str (name id) "-" "end")) (conj subscription-path :end))
        valid-date-range? (dates-are-ascending? start-date end-date)
        date-field-continuation (fn [hiccup]
                                  (if @(subscribe :date-range-error [:errors :date-range :value])
                                    (cond-> hiccup  ; [:div [:input {:class []
                                      true
                                      (update-in [1 1 :class] conj "is-danger")

                                      (not= (first (last hiccup)) :p)
                                      (conj [:p {:class "help is-danger"}
                                             "Incorrect date range. Start date can't be before end date."]))
                                    hiccup))]
    (when (and (not (str/blank? start-date))
               (not (str/blank? end-date))
               (valid-date-format? start-date)
               (valid-date-format? end-date))
      (if valid-date-range?
        (on-error-resolved #(assoc % :value false))
        (on-error #(assoc % :value true))))
    [:div {:class ["my-6" "is-flex is-align-items-center "]}
     [:p {:class "pr-3"} "from"]
     [:span {:class "pr-3"}
      [date-field {:id (keyword (str (name id) "-" "start"))                                ; new
                   :subscription-path (conj subscription-path :start)                       ; new
                   :on-input (fn [x] (on-input #(assoc % :start x)))
                   :on-error (fn [] (on-error #(assoc % :start true)))
                   :on-error-resolved (fn [] (on-error-resolved #(assoc % :start false)))
                   :continuation date-field-continuation}]]
     [:p {:class "pr-3"} "to"]
     [:span {:class "pr-3"}
      [date-field {:id (keyword (str (name id) "-" "end"))                                  ; new
                   :subscription-path (conj subscription-path :end)                         ; new
                   :on-input (fn [x] (on-input #(assoc % :end x)))
                   :on-error (fn [] (on-error #(assoc % :end true)))
                   :on-error-resolved (fn [] (on-error-resolved #(assoc % :end false)))
                   :continuation date-field-continuation}]]]))

This is what it looks like

date range field with validation

While the solution kinda works, it is brittle, any change to the structure of the hiccup returned by date-field would require the continuation to change. And we’d have to add some sort of validation in the continuation function to fail when the input hiccup changes. The upside is that you wouldn’t have to extract a bunch of state from the date-field component and have date-range manipulate that state, set an error, change the error-message, and so on.

Writing UIs with continuations

I’m not sure if what I’ve written above can be considered Continuation Passing Style, but it is something I want to try out in real-world UIs and see if it is a sustainable strategy. My guess is that it will get complicated when there is a deeply nested UI, with each node passing a continuation down to the node below. And if there’s a change in any node, the whole stack of continuations being passed down would need to change.

Closing thoughts

Writing UIs is hard. I have a new appreciation for frontend development. I didn’t think there was so much complex logic involved in writing UIs. I’ve also realized that writing vanilla HTML is so much easier than using react. So, really consider if your application needs to use react or you can get by with HTML.

This wasn’t an easy read, there’s a lot of code, and the text is quite long. So if you do have corrections, ideas, suggestions, I would love to hear them. Tweet at me @the_lazy_folder or email me.

IF you’d like to participate in a discussion, you can see the Clojurians Slack

Related blogposts

Christian Johansen wrote a blogpost titled stateless data driven uis which is a follow up of this blogpost and shows us an alternate implementation

Kenneth Tilton wrote an example using his library web-mx

Will Acton (lilactown) wrote an example using helix here date-field example, it uses react-hooks for state-management, and the solution is pretty concise!