Students of the Game: Reloadable Code
About two years ago I built a small ClojureScript app that looks like this:
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 and1
newCalendar Event
is created - User can see a
List
ofCalendar 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:
- when is our code going to reload?
- 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 ourteardown
function which deletes event listeners fromsubmit
after-load
- figwheel is going to call oursetup
function which adds event listeners tosubmit
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
andend
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.