Model state changes as pure transitions between immutable values, and keep atoms or other references at the boundary.
The phrase “immutable state” sounds contradictory until you separate two ideas:
Clojure keeps the values immutable and manages change by moving an identity from one value to the next.
That is the core idea behind practical application state in Clojure.
Suppose your application keeps an order in state.
1(def initial-order
2 {:order/id 1001
3 :status :draft
4 :items []})
Each business change can be represented as a pure function from old value to new value:
1(defn add-item [order item]
2 (update order :items conj item))
3
4(defn mark-submitted [order]
5 (assoc order :status :submitted))
6
7(defn mark-paid [order]
8 (assoc order :status :paid))
These functions are easy to test and reason about because they do not:
They just return the next value.
A mutable design often asks:
An immutable design asks:
That is a much cleaner question.
For example:
1(-> initial-order
2 (add-item {:sku "A-1" :qty 2 :unit-price 15M})
3 mark-submitted)
Each step returns a new order value. Earlier values remain valid snapshots.
At some point, a running application needs a place to hold “the current value.” That is where a reference type such as an atom comes in.
1(def current-order (atom initial-order))
Now the pure transition functions can still stay pure, while the atom manages the changing identity:
1(swap! current-order add-item {:sku "A-1" :qty 2 :unit-price 15M})
2(swap! current-order mark-submitted)
This is a crucial design separation:
That split is why Clojure stateful code can still remain readable and testable.
A common beginner mistake is to write all the logic inline inside swap!:
1(swap! current-order
2 (fn [order]
3 (if (seq (:items order))
4 (assoc order :status :submitted)
5 order)))
That works, but the better long-term move is usually to name the transition:
1(defn submit-if-not-empty [order]
2 (if (seq (:items order))
3 (assoc order :status :submitted)
4 order))
5
6(swap! current-order submit-if-not-empty)
This keeps your state-management layer thin and your transition logic reusable.
Here is a small end-to-end example:
1(defn order-subtotal [order]
2 (->> (:items order)
3 (map (fn [{:keys [qty unit-price]}]
4 (* qty unit-price)))
5 (reduce + 0M)))
6
7(defn price-order [order]
8 (assoc order :subtotal (order-subtotal order)))
9
10(defn ready-for-review? [order]
11 (> (:subtotal order) 1000M))
12
13(defn advance-order [order]
14 (let [priced (price-order order)]
15 (if (ready-for-review? priced)
16 (assoc priced :status :needs-review)
17 (assoc priced :status :ready-to-charge))))
This is application state logic without hidden mutation.
That does not make the application stateless. It means the state transitions are explicit and testable.
When state transitions are pure:
This is one of the main reasons Clojure systems often feel less tangled than object graphs whose state changes are spread across methods and collaborators.
Atoms are a good fit for independent synchronous state changes.
Later, you may need:
But the core lesson stays the same:
That design scales across more than one state primitive.