Reactive computation you can fork.
Spindel is a reactive programming system for Clojure / ClojureScript in which the execution context — every signal, every cached spin result, every continuation — is a first-class value. Fork it in O(1), let two branches diverge, then commit or discard. The same shape that makes Datahike databases branchable, applied to running reactive state.
(require '[org.replikativ.spindel.core :as s
:refer [spin signal track]]
'[org.replikativ.spindel.incremental.interval :as iv]
;; For REPL determinism only — production reactive flows go
;; through render-spin! / spawn! and don't need this.
'[org.replikativ.spindel.engine.impl.simple :as engine])
(def root (s/create-execution-context))
;; A signal and a spin that tracks it.
(s/with-context root
(def counter (signal 0))
(def doubled (spin (* 2 (iv/get-new (track counter))))))
(s/with-context root
@doubled ; => 0 (initial run)
(swap! counter inc)
;; Signal changes drain asynchronously on a background thread.
;; `await-drain-complete!` blocks until the engine is idle so
;; the next read is deterministic.
(engine/await-drain-complete! root)
@doubled) ; => 2
;; Fork the whole reactive system. Mutations in the fork don't
;; reach the parent; reads fall through to parent's unchanged state.
(def fork (s/fork-context root))
(s/with-context fork
(swap! counter inc)
(engine/await-drain-complete! fork)
@doubled) ; => 4 (fork sees its own counter)
(s/with-context root
@doubled) ; => 2 (parent unchanged)
Most of the API surface ships through the convenience namespace
org.replikativ.spindel.core (aliased s above) — spin, signal,
track, await, the combinators (parallel, race, timeout, …),
fork-context, atoms, pub/sub. Two things require their own require:
iv/get-new (typed-interval accessor in incremental.interval)
and await-drain-complete! (the REPL barrier in engine.impl.simple
— production code rarely needs it).
Fork the running reactive system, not just the code. Execution contexts are values. Snapshot one, restore it later, serialize it across a network, or fork it for speculative computation — the engine guarantees O(1) forking via overlay backends with copy-on-write semantics. Forks share unmodified state structurally; mutations isolate. Compare to most FRP / signal libraries where the reactive graph is global mutable state.
CPS + effects as the extension axis.
The spin macro is a partial-CPS transform with track, await,
and yield registered as plug-in effects. Libraries register
their own — sample / observe for probabilistic programming,
custom suspension points for distributed RPC, IO bindings for
web frameworks — and the new effects participate in spin
expansion as first-class citizens. No core changes required.
Typed delta algebra end-to-end.
Reactive combinators (imap, ifilter, islice, ifor-each,
ireduce) emit algebra records carrying their composition laws
inline (sequence / map / scalar). The DOM discharge consumes the
typed shape directly — a 10 000-item virtual list only touches
the items entering or exiting the visible window per scroll, the
same shape end-to-end from the signal mutation through the DOM
patch.
islice keeps a window over a long source; each scroll
emits a single typed :seq-diff carrying the items entering and
exiting, and the DOM discharge applies only that delta.ifor-each macro for keyed lists.sample / observe / factor sites are savepoints, so
SMC particles and MCMC moves are forks of worlds, and trace addresses are
deterministic by site.defn-spin-remote
lets you define a function that executes on a remote peer with
explicit boundary-crossing arguments; pairs with
distributed-scope
for kabel-WebSocket transport.Demo applications running on Spindel in the wild:
Beta. JVM: 772 tests / 2606 assertions passing; CLJS: 363 tests / 1374 assertions passing. Public API may evolve before 1.0; we'll call out breaking changes in CHANGELOG.
;; deps.edn
{:deps {org.clojure/clojure {:mvn/version "1.12.0"}
org.replikativ/spindel {:mvn/version "0.1.0"}}}
ClojureScript builds need org.clojure/clojurescript and
shadow-cljs (or
equivalent) — see examples/shadow-cljs.edn
for a working config.
See docs/README.md for the full documentation index. The essentials:
| Guide | What it covers |
|---|---|
| Getting Started | First-spin tutorial, signals, effects, running tests. |
| Concepts | Mental model: spins, signals, checkpoints, the drain queue, glitch-free FRP. |
| API Reference | Namespace-by-namespace listing of every public function and macro. |
| Effects | await, track, yield, deferred and mailbox synchronization. |
| Custom Effects | Register your own effects with register-effect-by-symbol!. |
| Combinators | parallel, race, sleep, timeout, debounce, throttle, accumulate. |
| Incremental | Deltaable collections, typed delta algebra, Interval 3-state contract. |
| Atoms | Fork-safe runtime-backed atoms. |
| Forking | snapshot-context, restore-snapshot, fork-context, serialization. |
| Engine | State shape, addressing, CPS / trampoline, executors, drain loop, overlay backend, GC. The implementation deep-dive. |
| Engine Formalism | Algebraic properties, flow diagrams, correctness laws. |
| Pub/Sub | mult, pub, buffers, async-sequence-based fan-out. |
| Distributed | defn-spin-remote, spin-remote, spin↔channel bridge; convergent signal sync; workspace reflection + cross-system forking. |
| SCI Integration | Sandboxed spin execution via the Small Clojure Interpreter. |
| World Scopes and Search | Finite families of canonical forked worlds, Monte Carlo tree search, recursive SCI interpreters. Inference over worlds is foerster. |
For contributor patterns and AI-assistant guidance (do's/don'ts when modifying the engine, project-specific conventions), see CLAUDE.md.
Spindel is part of replikativ, a set of composable building blocks for branchable, immutable systems:
defn-spin-remote builds on.fork-context shares the spirit; yggdrasil
generalizes it to a cross-system protocol.spin macro.Three primitives that compose:
track (to subscribe
to a signal) or await (to subscribe to another spin's result).
When a tracked dependency changes, the spin re-runs.fork-context
it (overlay-backed, O(1) copy-on-write), snapshot-context it
(an immutable copy for checkpointing), or serialize-context /
deserialize-context round-trip it over the wire.A spin is not an asynchronous task — it's a cached function whose re-execution is triggered by signal changes. Forking the context gives you a private copy of all the cached results; the parent never sees your fork's mutations.
For the full mental model, see docs/concepts.md.
# JVM
clj -M:test
# ClojureScript (Node) — uses root shadow-cljs.edn :test target
npx shadow-cljs compile test # or `watch test` for re-run on save
CLJS tests run via shadow-cljs's :node-test target (jsdom-backed
DOM tests included). See docs/engine.md for the
JVM / CLJS executor differences and the platform-specific testing
patterns.
Inside a spin body, use (await x) / (track x) — never
@x. The spin macro CPS-transforms await and track into
continuation breakpoints; raw @ deref blocks the thread,
breaks the continuation chain, and silently produces wrong
results. Outside spin bodies (REPL, tests), @ is fine.
Effects work within their execution scope. Core doseq and loop
support suspension through native macro expansion, including nested doseq
bindings and modifiers. Higher-order callbacks keep their function boundaries:
put effects in a callback's own spin and consume the returned spins through
an explicit combinator such as parallel. Bare effects inside callbacks are
rejected at compilation when visible. Lazy core map and for keep their
native semantics; org.replikativ.spindel.seq.core/for provides explicit
asynchronous sequence processing.
See CLAUDE.md, Rule 2 for examples.
Large resumable functions can hit java.io.IOException: File name too long
when exporting class files (including AOT compilation). Clojure includes the
names of enclosing closures in nested closure class names; CPS transformation
can expose this limit. The threshold depends on nesting, namespace and function
names, and the filesystem. Successful REPL evaluation does not guarantee that
class-file export will succeed.
If this occurs, split the function into smaller, separately transformed async or spin helpers, composing them through the usual await/effect API. Moving an unchanged nested function into another local closure may retain the same naming problem; shortening names only postpones it.
This is tracked upstream as CLJ-1852. Clojure maintainers discussed using a fallback naming strategy when the current strategy fails, and in April 2024 listed it for consideration in 1.13. This is not a released fix or a guaranteed release commitment. We currently use stock Clojure and recommend splitting affected functions.
Copyright © 2026 Christian Weilbach.
Licensed under the Apache License 2.0 (see LICENSE).
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |