Hot Reload in ClojureScript
|Hot Reload is a very popular concept within the ClojureScript community and refers to the automatic reloading of code during development while keeping the application state. The concept was first introducing by Bruce Hauman and his implementation in figwheel. If you haven’t watched his introductory talk you definitely should.
shadow-cljs has its own implementation for Hot-Reloading which works similarly to what figwheel
does. It does expose a few extra hooks that allow a bit more control over the process when needed but the underlying concept is very much the same.
In this post I provide a few more technical details of how it works in shadow-cljs
to remove some of the mystery since the underlying process is really simple. I’m going to frame this in the context of a SPA (Single Page Application) since the concept is most useful when combined with a virtual DOM library like React
which constructs the UI in a functional manner based on our data. It also is essential that all your data is kept in a central place since hot-reloading gets a lot more complex if data is spread all over the place. Even to the point of not being useful at all anymore.
Application Lifecycle
Any application using Hot Reload should be structured in a similar fashion where each lifecycle event is executed at the correct time and can potentially be re-executed when required. The setup I recommend uses 3 simple stages
init
: Executed only once, when the application is first loaded. Callsstart
when donestart
: Actually renders the application, called after hot-reload is appliedstop
: Optional, called before any hot-reload is applied (allows shutting down transient state started instart
)
In code terms this looks something like this:
(ns example.app)
(defn ^:dev/after-load start []
(js/console.log "start"))
(defn init []
(js/console.log "init")
(start))
;; optional
(defn ^:dev/before-load stop []
(js/console.log "stop"))
The build config should use :modules {:app {:init-fn example.app/init}}
to ensure the init
fn is called when the app is first loaded. The dummy just directly calls start
but actual apps can initialize themselves here.
The ^:dev/after-load
on start
(and ^:dev/before-load
on stop
) metadata tells shadow-cljs
to call those functions at the appropriate time in the application lifecycle.
Note that the names init/start/stop
don’t actually mean anything special so you can use any name you like instead, they just happen to be the names I chose in my projects.
On the backend the running shadow-cljs watch app
process will watch the filesystem for changes and automatically recompile namespaces on change. Once that compilation finishes the update is shipped to the runtime (ie. Browser) and it starts calling all ^:dev/before-load
functions (can be zero or more). Once those complete the recompiled code is loaded and the ^:dev/after-load
functions are called (should be one or more).
Async Lifecycle Hooks
There are also async variants of those hooks in case you need to do async work that should complete before proceeding with the reload. Suppose you are using a node
http
server where its .close
function takes a callback that is called once the server is actually closed.
(defonce server-ref (atom nil))
(defn start-server []
...)
(defn ^:dev/after-load start []
(reset! server-ref (start-server)))
(defn ^:dev/before-load-async stop [done]
(let [srv @server-ref]
(reset! server-ref nil)
(.close srv done)))
The async lifecycle hooks just receive on extra argument (ie. done
) which is a simple callback fn that should be called once the work is done. In this case it can be passed directly to the .close
fn. You must ensure this done
fn is actually called though. Hot reload will otherwise not proceed.
When using the sync version of the lifecycle hooks the before-load
would be called which would queue the .close
but given the async nature of the node
code this would actually not do anything just yet. shadow-cljs
would just immediately proceed with reloading and calling after-load
again. The .close
never had a chance to complete and your server would now likely fail to start since the old one is still running.
Hooking up React
Note that reloading the code itself doesn’t actually do anything to your UI unless you used a ^:dev/after-load
hook to actually trigger the re-render.
When using something like reagent you’d trigger the re-render like this.
(def dom-root (js/document.getElementById "app"))
(defn ui []
[:div "hello world"])
(defn ^:dev/after-load start []
(reagent/render [ui] dom-root))
Depending on your setup you may also need to actually ensure that reagent/react don’t decide to skip the re-render. Sometimes they may think that nothing has changed if you only changed something further down.
With frameworks like re-frame you should call (re-frame/clear-subscription-cache!)
. When using reagent
directly you might need to add an additional prop to signal “change”, so it doesn’t immediately skip since ui
didn’t change. As explained later due to the difference in the recompile logic this may not have been necessary in figwheel
.
(defn ^:dev/after-load start []
;; dummy prop that always changes
(reagent/render [ui {:x (js/Date.now)}] dom-root))
When using a single atom to hold all your state it might be enough to just touch and change that.
(ns example.app.db)
(defonce app-state (atom {::dummy 0}))
(ns example.app
(:require [example.app.db :as db]))
...
(defn ^:dev/after-load start []
(swap! db/app-state update ::dummy inc)
(reagent/render [ui] dom-root))
Behind the Scenes
Code reloading in ClojureScript is deceptively simple compared to hot-reload mechanisms from other languages (eg. HMR in JS) because of the way the code is structured. Everything is executed in the same scope and a tree of namespace objects is created. The above code will just create this underlying JS
goog.provide("example.app"); // creates the var example = {app: {}}; global once
example.app.start = function() {
return console.log("start");
};
example.app.init = function() {
console.log("init");
return example.app.start();
};
example.app.stop = function() {
return console.log("stop");
};
On reload this will effectively just redefine example.app.start
(and the others from that namespace) with new function definitions. If code from other namespaces is reloaded this way the namespaces using it will have accessed it via its fully qualified name so they will automatically call the new version.
Recompile Logic
On the backend the shadow-cljs watch app
process will compile a namespace when it is changed and also recompile the direct dependents of that namespace (ie. namespaces that :require
it). This is done to ensure that changes to the namespace structure are reflected properly and code isn’t using old references.
(ns example.util)
(defn foo [] ...)
(ns example.app
(:require [example.util :as util]))
(defn bar []
(util/foo))
Suppose you changed example.util
and renamed (defn foo [] ...)
to (defn bar [] ...)
. If example.app
wasn’t automatically recompiled together with example.util
you would not get a proper warning that example.util/foo
no longer exist. Given that the old definition even still exists in the runtime the code would actually keep working until you reload your browser (or restarted the watch
).
shadow-cljs
will only automatically recompile the direct dependents since in theory dependencies further up cannot be directly affected by those interface changes. This is different from figwheel
since it defaults to using :recompile-dependents true
from the ClojureScript compiler. This will recompile ALL dependents so any namespace with a :require
on the changed namespace and then all namespaces that required those and so on. This can make recompiles rather slow in larger apps and in the vast majority of cases simply isn’t necessary.
Don’t forget about the REPL
Hot Reload is sort of brute force since it always replaces the entire namespace. Depending on the size of that namespace and the amount of direct dependents this can noticeably increase the feedback time during development given the amount of time it takes to compile.
It also blindly triggers on file change which will often lead to unnecessary recompiles since you might just save a file in the midst of other pending changes. I have Cursive setup to save files when the editor loses focus so this means that a hot-reload is triggered as soon as I tab out to look up some documentation or so. This can be a bit annoying if you are in the midst of a large new feature/rewrite.
The REPL allows for much finer grained control over this entire process. When using a CLJS REPL you can replace a single function directly which will be a whole lot faster and provide feedback pretty much instantly. However if said feedback requires rendering the UI you’ll need to trigger that re-render. You could just call (example.app/start)
if you use the setup from above. Depending on your REPL setup you may want to bind that to a keyboard shortcut.
shadow-cljs
also provides a few extra REPL hooks if you still want to the convenience of doing the whole lifecycle and proper dependent namespace checks. This will let you trigger the recompile when you want it to instead of automatically on file change.
When in a CLJ REPL you can first tell the watch
to not automatically rebuild on file change via
(shadow.cljs.devtools.api/watch :app {:autobuild false})
This will cause the watch
to not trigger an automatic recompile and instead only remember the files that were changed. You can then trigger an actual recompile and the hot-reload lifecycle via
;; trigger compile for specific build
(shadow.cljs.devtools.api/watch-compile! :app)
;; or all running watches
(shadow.cljs.devtools.api/watch-compile-all!)
These are also great candidates for keyboard shortcuts.
With much more control over when things actually happen you can get rid of a lot of “noise” you might otherwise receive. I find myself using this whenever working on changes to my domain model or architecture and using the automatic reloading whenever I’m tweaking the UI (eg. styles and layout).
Things to avoid
Hot Reload can only do so much and requires some discipline in your code architecture.
Holding Code references in State
Hot Reload can only replace code in the global scope. If you hold onto references (eg. functions) directly in some other places those references won’t be replaced and will continue using old versions.
(defonce some-state (atom {:x somewhere/foo}))
This will keep a reference to foo
directly in the map which will not be updated when hot-reloading. One way to avoid this is adding an extra function.
(defonce some-state (atom {:x #(somewhere/foo %)})) ;; assuming it expects one arg
This isn’t the best approach but works in a pinch. A better idea would be to keep only actual data in atoms and then deciding what code to call on that later in the actual code
(defonce some-state (atom {:x ::foo}))
(defn do-something [{:keys [x] :as data}]
(case x
::bar
(some/bar)
::foo
(somewhere/foo data)))
(defn ^:dev/after-load start []
(do-something @some-state))
Missing :ns require
shadow-cljs
is rather strict about namespace references and things can go wrong quickly if you “cheat”. Technically CLJS allows using the fully qualified name of something to directly reference without ns alias but only the actual :require
ensures things are actually available and properly reflected when hot-reloading.
(ns example.foo)
(defn foo? [x]
(some.thing/else? x "foo"))
Don’t do this. At least add (:require [some.thing])
to the ns
, better yet with an alias. Without the :require
shadow-cljs
won’t properly recompile example.foo
when some.thing
is changed. It may also lead to other weird race conditions due to parallel compiles. Just don’t do this.
Invoking code directly in the namespace
Avoid calling code directly in the top level of your namespace since that will be executed every time your code is reloaded. Suppose we didn’t use the :init-fn
feature shadow-cljs
provides and instead used directly called init
ourselves.
(ns example.app)
...
(defn init []
(js/console.log "init")
(start))
(init)
In a release
mode app the init
would only be called once so everything would be ok but with hot-reloading the full ns will be reloaded and therefore the (init)
would execute each time. :init-fn
ensures this doesn’t happen and therefore ensures the code is only executed once as intended.
It may be OK for other code to be directly called in your namespace but be aware that it will get called every time your code is reloaded. Frameworks like re-frame
ensure that it properly updates references it creates.
Note that an exception during the load of a namespace process may break hot-reload entirely. Avoid running code as much as possible and instead use the ^:dev/after-load
hooks when needed.