Lesson 09 — State: cells, swap, and a graph-native cache

Goal: by the end of this lesson you can hold mutable state inside the graph — a counter, an accumulator, a cache — that survives across calls, built entirely from fn-defs with no Clojure. You'll know when state lives for one call versus forever, and the one scope limit to plan around.

Concepts introduced: :atom, :cell, :swap, :reset, :deref, :swap-conj, per-call vs per-registry lifetime, per-instance scope, the :state effect.

Why state in a graph

Everything so far has been pure: a fn takes inputs and returns a value. Sometimes you need to remember something between calls — count requests, cache an expensive result, accumulate a log.

Graphden gives you clojure.core's atom as base-fns, so the remembering is composed in the graph and visible/editable in the editor, instead of hidden in an impl.

Base-fn Clojure What it does
:atom (atom v) A box holding v, fresh each call
:cell (atom v) A box holding v, shared across calls
:deref @a Read the box's current value
:swap (swap! a f) Atomically replace the value with (f current)
:reset (reset! a v) Replace the value with v (no read)
:swap-conj (swap! a conj x) Append x (a fn-def over :swap)

:atom and :cell hold the same kind of box. The only difference is lifetime, and it is the whole story of this lesson.

:atom — one call's scratch space

An :atom fn-def hands back a fresh box on every top-level execute. Within that one call, every reference to the same :atom fn-def resolves to the same box (result-caching, lesson 04) — so several steps can share it — but the next call starts over.

That's exactly what you want for a per-request accumulator: a transaction journal, a running total for one computation. It's the box behind the :try-based rollback pattern you'll see in the CRUD package.

:cell — state that outlives the call

A :cell is allocated once and baked into the graph like a constant (lesson 03's :value), so the same box is handed back on every execute. Write to it in one call and the next call sees the write. That's what turns three primitives into a cache.

Try it: a hit counter

Three fn-defs. The middle one is the update function :swap will apply — a plain (current + 1) where :current is the box's value, supplied per-swap.

;; The persistent box, starting at 0.
{:name :hit-count
 :parent :cell
 :args  {:initial-value 0}}

;; The 1-arg update: :current in, :current + 1 out.
;; :add takes a :nums list; {:as :current} keeps that slot free so
;; :swap can feed the box's value into it each call.
{:name :_bump-one
 :parent :add
 :args  {:nums [{:as :current} 1]}}

;; Increment the cell and return the new count.
{:name :count-a-hit
 :parent :swap
 :args  {:a :hit-count :func :_bump-one}}

:swap's :func slot is [:fn {:current a} a] — a 1-arg callable a → a. :_bump-one fits: it reads :current and returns a number of the same type. Now ▶ Run (via ) on :count-a-hit (lesson 12) returns 1, then 2, then 3 — it remembers.

Swap :cell for :atom in :hit-count and it returns 1 every time: a fresh box per call.

Try it: a graph-native cache

A cache is a :cell holding a map, a read that :derefs + :gets, and a write that :resets the map with one more entry. This is exactly how the editor's own response cache is built.

;; The persistent map.
{:name :my-cache
 :parent :cell
 :args  {:initial-value {}}}

;; Snapshot the current map (used by both read and write).
{:name :_my-cache-now
 :parent :deref
 :args  {:a :my-cache}}

;; READ: (get @cell key) — nil on miss.
{:name :my-cache-get
 :parent :get
 :args  {:coll :_my-cache-now :key {:as :key} :default nil}}

;; The next map: current + one entry.
{:name :_my-cache-updated
 :parent :assoc
 :args  {:map :_my-cache-now :key {:as :key} :value {:as :value}}}

;; WRITE: install it. :reset returns the value it stored.
{:name :my-cache-put
 :parent :reset
 :args  {:a :my-cache :v :_my-cache-updated}}

on :my-cache-get with key = "a"nil. on :my-cache-put with key = "a", value = 1. Then :my-cache-get with key = "a"1, in a later call — the write persisted.

Keys come back as strings, not keywords, after the JSONB round-trip — (get m "a"), not (get m :a). See lesson 03's note on literal keys.

:swap vs :reset — which write

:swap reads-and-writes atomically: (f current) can't lose a concurrent update. :reset just overwrites; two callers racing can drop one write. Use :reset only when a lost write is harmless — an idempotent cache (same key always maps to the same value) recomputes the dropped entry next time, which is why the cache above uses it. Reach for :swap (or :swap-conj) when the update depends on the current value and must not be lost — like the counter.

The :state effect

:swap and :reset carry the :state effect (lesson 13): they mutate shared state. It shows on the effect strip of anything built on them, so a reader can see at a glance that a fn writes state and isn't pure. :deref (a read) and :cell/:atom (allocation) don't.

The one scope limit

A :cell lives in one executor process. Run several pods behind a load balancer (lesson 32's cardinality, SCALING.md) and each has its own cell — a write on pod A is invisible to pod B. That's fine for a cache of identical, recomputable data (the worst case is a recompute on the other pod), but it is not shared state. For state that must be consistent across pods you need an external store (Redis, a database row) — a :cell won't do it.

One more rule: :cell is persistent only when its :initial-value is a literal (a compile-time constant, as in the examples). Bind it to a fn-ref and there's nothing to bake once, so it quietly falls back to :atom behaviour — fresh each call.

What we glossed over

  • How :cell bakes its box once and :atom doesn't — the compile-time-value mechanism in the executor's compile pipeline. You don't need it to use them; it's toured in the developer code tour (docs/devtour, executor block).
  • The :try + :atom transaction-journal pattern in full — see the CRUD package's secret/entity write units.
  • Cross-branch behaviour: a :cell's box is per-compiled-registry, so two branches with identical graphs may share one — see VERSIONING.md.

Next

lesson 24 — Members: managing who is in your org

Prefer to be shown? This lesson exists as a guided in-editor tour: open the demo with the tour running (no sign-up), or pick “Interactive tutorial” in the editor's account menu.