October 25, 2019

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:

blog_example.clj
(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.

Code reload shall not pass over your app state

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
Tags: Clojure Java