Students of the Game: Reloadable Code

About two years ago I built a small ClojureScript app that looks like this:

calendar app

This app was meant to be a bite sized learning project to level up my ClojureScript interop skills.

Aside from being gorgeoous, it had only two things to do:

  • User presses the Add button and 1 new Calendar Event is created
  • User can see a List of Calendar Events

I started the app as I always do which includes running figwheel and Hot Module Reloading (HMR). As I'm jamming away on the code I started to notice some interesting behaviour.

When I pressed the Add button, instead of 1 new Calendar Event being added, 5 were added! I paused for a moment before hard refreshing the browser. Then I clicked the Add button again. Unlike the first time, everything worked as expected; only 1 new Calendar Event was added. I smiled to myself and marvelled at my incredible detective skills: obviously the problem was HMR.

Since I obviously identified the source of the problem I decided to resolve the issue by ignoring it. So any time I made a change which triggered an HMR reload I would hard refresh the browser like a savage.

Some time later I realized that, no, the problem was not HMR...it was my code. As it turns out, HMR isn't free. It requires the code author to design and build the code to be reloadable code.

So in the intrepid spirit of craftsmanship, I have resurected my Calendar App and refactored it to be reloadable code.

Intro to Hot Module Reloading

HMR is a program which runs alongside our app which watches for changes to our .cljs files and when it sees that one of these .cljs files has changed it tells the browser to fetch the latest changes and trigger a "reload". This means our running app is automatically updated with the new code and the app state is exactly where we left it.

We like HMR because it allows us to develop faster and with less friction. Specifically, HMR can make it so:

  • We don't have to manually refresh our browsers
  • We don't have to lose our applications state
  • We don't have to manually recompile our code

HMR is provided by programs like figwheel, shadow-cljs or webpack.

Having said the above, just because you use figwheel or shadow-cljs doesn't mean you can get the benefits of HMR. You also have to architect your code in a specific way. Namely, you have to control side effects. In other words, you have to write reloadale code.

"Submit" Event Listeners

If you save the calendar.cljs file 5 times and then press the Add button in the Calendar App you will see it creates 5 Calendar Events.

If we inspect the console we can see that this is because our "submit" event listener is attached 5 times to the submit event. This is why when we click the Add button, we get 5 Calendar Events added instead of the expected 1.

Why is this happening? As it turns out, if you look to the code here:

(events/listen
  (.. js/document (querySelector ".calendar-form"))
  "submit"
  handle-add-event!)

The code above is invoked in our ns meaning that it runs everytime a reload is triggered. In other words, if you trigger 5 reloads it would be as if you wrote this:

(events/listen element "submit" handle-add-event!)
(events/listen element "submit" handle-add-event!)
(events/listen element "submit" handle-add-event!)
(events/listen element "submit" handle-add-event!)
(events/listen element "submit" handle-add-event!)

Wait. Didn't we say that each reload triggers a browser refresh? Not exactly. What we mean is that the ClojureScript in your app is sent to the DOM and re-run. The effect makes it appear like the browser is refreshing. Thus, triggering a reload updates your ClojureScript and re-runs your ClojureScript, but unlike your ClojureScript, whatever you did the DOM previously prior to a reload stays done.

This is why when we write reloadable code we have to control what we do to the DOM so we don't cause unexpected behaviours like 5 Calendar Events added at a time.

So how do we control what we do to the DOM to avoid these unpredictable side effects? We write reloadable code and to do this we have to know two things:

  1. when is our code going to reload?
  2. when is our code going to finish reloading?

figwheel, as well as other programs, provides us with a mechanism so we can answer the above 2 questions and that mechanism looks like this:

(defn ^:before-load teardown []
  ; ...do stuff
  )


(defn ^:after-load setup []
  ; ...do stuff
  )

Before we go further, lets breakdown what the above is doing. teardown and setup are nothing more than clojure functions. The only part we have to care about from the above are the ^:before-load and ^:after-load words that come before teardown and setup.

^:before-load and ^:after-load are called metadata in Clojure(Script). What makes them metadata is the ^ that comes before the : (colon).

When figwheel sees these particular pieces of metdata infront of a function it knows that it has to run our functions before and after the CLJS reloads.

Now that we know we can write code that runs before and after load we just have to figure out what we should put in these functions to make our code reloadable code.

Going back to the "submit" event listener scenario, the problem is that everytime our code reloads, a new event listener is attached to the "submit" event. But what we want is to only have 1 event listener, the newest event listener, attached to our submit. So what we need to make our code do:

  • before reload: remove old "submit" event listener
  • after reload: add new "submit" event listener

The following is what the above looks like in our code:

; before reload: remove old "submit" event listener
(defn ^:before-load teardown []
  (events/removeAll
   (.querySelector js/document ".calendar-form")))

; after reload: add new "submit" event listener
(defn ^:after-load setup []
  (events/listen
    (.. js/document (querySelector ".calendar-form"))
    "submit"
    handle-add-event!))


(defonce initial-load (setup))

What we did:

  • before-load - figwheel is going to call our teardown function which deletes event listeners from submit
  • after-load - figwheel is going to call our setup function which adds event listeners to submit

You might have also noticed that I snuck in a little something extra: initial-load. Remember how we said that before-load happens before a reload and after-load happens after a reload? This means those are only triggered when a reload is triggered. If we left it like that, our code would not work when we first visit the app. So we add a defonce so that the code which runs on the first, and only the first time, your app loads in the browser.

With the above in place go ahead and try to trigger some reloads. If everything worked, no matter how many time you trigger a reload only 1 Calendar Event should ever be created.

If you are lost at this point for any reason take comfort in the fact that writing reloadable code is not always straighforward and does require you to think deeply about what your code is doing. This is a skill that can take some time to learn.

"Change" Event Listeners

Here is the second opportunity for making our code reloadable code. Similar to the "submit" example above, we are invoking another event listener in our file for the change event:

(events/listen
  (.. js/document (querySelector "#event_start"))
  "change"
  update-event-end-dropdown!)

If you read the code, or even ran the code, you will see that this code is not a problem. Yes, if we reload our app 5 times it would do this:

(events/listen element "change" update-event-end-dropdown!)
(events/listen element "change" update-event-end-dropdown!)
(events/listen element "change" update-event-end-dropdown!)
(events/listen element "change" update-event-end-dropdown!)
(events/listen element "change" update-event-end-dropdown!)

But it's still not causing any bugs. The reason is because the code inside of update-event-end-dropdown! is destructive and not performing an additive effect like "submit". The worst that happens is that if we reload 5 times the option will get set 5 times because of (.-innerHTML start-time-dropdown).

So if this is not a bug, then why talk about it? Because even if the code is not causing problems now, it is stacking event listeners and if we change update-event-end-dropdown! to accidentally do something different, we are going to set ourselves up nicely for an interesting bug.

If neither of these arguments persuade you, that is fine and highlights the interesting part about writing reloadable code: many elements of designing your code in this way are going to be subjective and based on how you want your code to run.

Having said this, for this scenario I am going to play it safe and show you how we could fix the code:

(defn ^:after-load setup []
  (events/listen
    (.. js/document (querySelector ".calendar-form"))
    "submit"
    handle-add-event!)

  (events/listen
    (.. js/document (querySelector "#event_start"))
    "change"
    update-event-end-dropdown!))

You can see the above change here. In the next section we are going to explore more subjective goodness.

Populating Dropdown Options

Reading [along with the code] in Calendar App, we have another invocation when the ns loads.

(set! (.. start-time-dropdown -innerHTML) (time-option-list (time-range)))

(set! (.. end-time-dropdown -innerHTML) (time-option-list (time-range 9.25)))

All these do is populate the time options in our forms time dropdowns. So the question is, if we run this multiple times in a row what happens? Similar to the "change" event listeners, the above is performing a destructive action. each time they are run they will delete the content of the select boxes and replace them with the same options. Also, unlike event listeners these do not stack. Will there be a bug? What are the problems with this?

Each time we run this code, it clears out our dropdowns and re-adds options. What if we put this in initial-load? It would only run once meaning that if we made changes to time-option-list we would not see the effect take place. But what if we know we are 100% done with time-option-list? Perhaps we don't believe their will be more changes? Maybe this justifies only putting it into the initial-load.

However, if you are in the camp that believe we should re-run this action just to be safe, then we can put it into the initial-load and after-load. But if we do this there is something that could happen. Imagine this is what I did:

  • select a start and end time
  • realize I need to make a change to the code
  • change the code + trigger a reload

If we move the time-option-list inside the after-load everytime a reload is triggered we are going to lose our start and end time we selected. We are losing app state. If this is concerning to you, then it is not enough to add our time-option-list to the after-load we also have to add additional code to our app to potentially save the state of the application so we don't lose it. I won't walk through what that looks like, as I wanted to bring it up as a lead to what makes HMR feel like magic: app state.

Managing App State

App State is a memory of what you did in the app. When we trigger a reload we can make it so our add does not reset its app state. For example, let's pretend I am working on my add, I add a few Calendar Events and trigger a reload. Those Calendar Events I just added will be lost. We go back to 0. This does not have to be the case though. We could tell our add to not reset the state.

To make this happen all you have to do, based on how I wrote Calendar App, is this:

; update this line
(def app-state (atom []))

; to look like this
(defonce app-state (atom []))

That's it. Now, whenever your app reloads, your state is never forgotten. The way this works is explained nicely in Hot Reload in ClojureScript. The short answer: defonce means that app-state will not reset to (atom []) when the app reloads.

This is a simple example, but it should provide some ideas for what needs to be done to make our reloadable code capable of managing state.

Conclusion

Writing reloadable code can seem tricky at first, but hopefully we can see that with ClojureScript it is an achievable and worthwhile goal. For me the benefits of writing your code like this and even thinking about these things is the understanding you gain about how your code works.

The other gain is the power that ClojureScript gives you out of the box. It takes very little to setup HMR and start writing reloadable code with the tools provided by Clojure(Script) core library. You don't need additional dependencies.

Hopefully this provides a decent example set with which to start your reloadable code journey.