Using defonce and Static Requires for Reliable ClojureScript Hot Reload
Learn how defonce and static namespace requires let you keep state across figwheel reloads while still benefiting from advanced Closure Compiler dead code elimination.
12 Sept 2025, 13:05 UTC

Problem: Hot‑reload resets browser state
When developing a ClojureScript UI with figwheel‑main or shadow‑cljs, each code change triggers a reload. If you hold state in a regular def (e.g., a timer ID or a re‑frame subscription), the reload creates a new var, leaving the old timer running or the subscription duplicated. The UI can flicker, timers fire twice, or memory leaks appear.
Thesis: Use defonce and static namespace requires to keep state across reloads and let the Closure Compiler prune unreachable code
By declaring state‑holding vars with defonce you guarantee the initialization runs only once, even when the namespace is re‑evaluated during hot reload. Pairing this with a namespace structure that only uses static :require forms lets the compiler determine exactly which vars are reachable, so advanced optimizations can safely remove dead code and rename symbols without breaking interop.
How advanced compilation prunes code
The Closure Compiler, invoked with :optimizations :advanced, renames every local symbol and eliminates any var that is not reachable through a static ns require chain starting from the entry point. Functions marked with ^:export or annotated with ^js type hints are kept under their original names so JavaScript interop stays valid.
Structuring namespaces for static requires
Place public API vars at the top of a namespace with plain def (or defn) and keep implementation details in private vars prefixed with - or metadata ^:private. Avoid require with strings, eval, or resolve because those patterns are invisible to the static analysis and will be dropped or cause warnings under advanced mode.
Worked example: a singleton timer with defonce
- Create
src/myapp/timer.cljs: - In
src/myapp/core.cljsrequire the namespace statically: - Build with Figwheel (dev) and observe hot reload:
- Produce an advanced build to see dead code elimination:
(ns myapp.timer
^:private
;; internal state, initialized once
(defonce timer-id (js/setInterval #(js/console.log "tick") 1000))
;; exported start/stop for other namespaces
(defn ^:export start [] (reset! timer-id (js/setInterval #(js/console.log "tick") 1000)))
(defn ^:export stop [] (js/clearInterval @timer-id)))
(ns myapp.core
(:require [myapp.timer :as timer]))
# From the project root
clojure -M:dev figwheel-main
# Edit timer.cljs and save; the timer continues running without duplication.
clojure -M:release advanced
# The generated main.js contains only the exported start/stop functions (renamed if not ^:export) and the timer‑id var is kept because it is reachable via the exported functions.
Trade‑off and limitation
The approach requires discipline: all interop calls must be annotated with ^js or the symbol must be ^:export to prevent renaming. Dynamic require strings or eval‑based code will be removed, so any plugin system that relies on them must be rewritten to use static requires or provide an externs file. Additionally, defonce vars survive page reloads only during the same browser session; a full page refresh re‑runs the namespace init, which is expected.
Actionable closing
- Audit your namespaces for any dynamic
requireorresolveand replace them with static:requireforms. - Wrap state‑ful vars (timers, subscriptions, globals) in
defonce. - Mark every JavaScript interop symbol you need to keep with
^js(for inline calls) or^:export(for vars/functions you call from outside). - Verify the effect: build with
:optimizations :noneand:advanced, compare file sizes withwc -cand inspect the generated JS for preserved exported names. - Run your Figwheel process, edit a namespace containing a
defoncevar, and confirm the var’s value persists across the reload.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.