Clojure defonce: Keep Your Apps State
One of the great strengths of Clojure is interactive development. Usually, you run a REPL and an editor that interacts with that REPL. You try out small things in the REPL, send pieces of code from the editor to the REPL, reload modified files, etc. You rarely restart the app, but keep going editing code. I’m missing that in other programming environments.
It works for Clojure due to many factors. The Lispy-ness with a distinct REPL at your disposal and tools taking advantage of it. Then the Clojure pushes you toward functional programming with immutable data structures, which then leads to a culture where the app state is usually held in very few places and the app mostly consists of functions operating on it. So when code is swapped the immutable state can be kept and be used with the new code.
The Problem: Reloading Code Wipes the App-State
Let’s assume we have a app with some state. Like this:
(ns blog-example)
(def chat-history (atom []))
(defn print-chat-messages []
(doseq [m (take-last 10 @chat-history)]
(println m)))
(defn send-to-chat [msg]
(swap! chat-history conj msg))
We load the file and try out the functions in the REPL by sending and printing messages:
(send-to-chat "Hello") => ["Hello"] (send-to-chat "I'm new here to this chat") => ["Hello" "I'm new here to this chat"] (print-chat-messages) Hello I'm new here to this chat => nil
We decide that we want a dash before each message, so we modify the print-chat-messages
function:
(defn print-chat-messages []
(doseq [m (take-last 10 @chat-history)]
(println "-" m)))
Then we reload the file and try the print-chat-messages
function again:
Loading src/blog_example.clj... done (print-chat-messages) => nil
Huh, where are the previous messages? Looks like reloading the file has wiped our state.
Defonce to the Rescue
The issue in our example is that when we reload the file, all the code in the file is evaluated again, including the
(def chat-history (atom []))
. This will define the chat-history again and
overwrite the old value with a new, empty atom.
defonce
exists to avoid exactly this issue. Defonce only defines
the name if it doesn’t exist yet. If it already exists, it will keep the old value. Therefore when you declare
the names which hold your applications state with defonce
, then you can reload code and the old state is kept.
That means you can create the app state you need and then work on the code, reloading and testing your changes
to your heart’s content. In our example, change the chat-history
to a defonce:
(def chat-history (atom []))
Now we can change code as we please, reload it and try out things:
Loading src/blog_example.clj... done (send-to-chat "Hello") => ["Hello"] (send-to-chat "Trying defonce") => ["Hello" "Trying defonce"] (print-chat-messages) Hello Trying defonce => nil Loading src/blog_example.clj... done (print-chat-messages) - Hello - Trying defonce => nil (send-to-chat "yeah, changing code") => ["Hello" "Trying defonce" "yeah, changing code"] Loading src/blog_example.clj... done (print-chat-messages) > Hello > Trying defonce > yeah, changing code => nil