Spreadsheet-like FRP for Clojure and ClojureScript.
Carbon.RX is a love child of Javelin and freactive.core. Cells hold state; reactive expressions derive values from cells and from each other. Dependencies are discovered dynamically every time an expression runs, changes propagate glitch-free, and nothing that is no longer observed is kept alive by the graph.
;; deps.edn
carbon/rx {:mvn/version "0.4.0-SNAPSHOT"}
;; project.clj
[carbon/rx "0.4.0-SNAPSHOT"](ns example
(:refer-clojure :exclude [dosync]) ; Clojure only, when referring dosync
(:require [carbon.rx :as rx :refer [cell rx lens dosync no-rx]]))
(def price (cell 10))
(def qty (cell 3))
(def total (rx (* @price @qty)))
@total ;=> 30
(add-watch total :log (fn [_ _ old new] (println old "->" new)))
(reset! qty 4) ; prints 30 -> 40
(dosync ; one batch: the watch fires once
(reset! price 20)
(reset! qty 5)) ; prints 40 -> 100The macros are available both from carbon.rx and from carbon.macros.
| Form | What it does |
|---|---|
(cell x & {:keys [meta validator]}) |
A mutable source, used like an atom (deref, reset!, swap!, compare-and-set!, swap-vals!, reset-vals! on the JVM, watches, validators). Alias: $. |
(rx & body) |
A lazy reactive expression. Every cell or expression dereferenced while body runs becomes a dependency. Alias: $$. |
(lens getter setter & [meta validator drop-fns]) |
A writable expression: reads like rx, reset!/swap! call (setter new-value). |
(rx/cursor parent path) |
A lens onto (get-in @parent path) that writes back with assoc-in (or reset! for an empty path). The same parent and path return the same cursor while it is reachable. |
(dosync & body) |
Batch writes. Dependents update and watches fire once, after the outermost dosync. |
(no-rx & body) |
Dereference without creating dependencies. |
rx/*value* |
Inside an expression body: its previous value (:carbon.rx/thunk the first time). |
(rx/add-drop x key f) / (rx/remove-drop x key) |
Call (f key x) when the expression stops being observed. |
(rx/gc x) |
Drop the cached value of an unobserved expression. |
rx/cell*, rx/rx*, rx/dosync* |
Function versions of the macros. |
Lazy. An expression runs on its first deref, not when it is created.
Observed vs. unobserved. An expression is observed when it has a watch,
or when an observed expression depends on it. Only observed expressions are
subscribed to their sources. They are updated eagerly after each write, and
their watches fire when their value changes (compared with =).
Unobserved expressions are not subscribed to anything, so a long-lived cell
never retains them. They still cache their value: a later deref recomputes
only if one of their own dependencies has changed. When an expression stops
being observed (its last watch is removed, or the expression depending on it
switches to another branch), it unsubscribes from its sources and its drop
handlers run.
Glitch-free. A write marks everything downstream as possibly stale.
Reading an expression first brings its dependencies up to date, so an
expression never sees a mix of old and new values. That holds while
dependencies change, inside dosync, and inside watch callbacks.
Batching. Inside dosync, writes to cells take effect immediately, and
reads (including of expressions) see them. Watches are deferred to the end of
the outermost batch and coalesced: one call per reference, from its value
before the batch to its value after it. An expression whose value ends up
unchanged doesn't notify. Cells notify on every batch that wrote them, like
atoms. Writes made by watch callbacks start a new round, and a feedback loop
that never settles is reported as an error rather than looping forever.
Exceptions. If dosync's body throws, writes already made are kept and
still propagated before the exception is rethrown. If an expression throws,
it keeps its previous value and its dependencies. It also starts depending on
whatever it read before failing. Reading it again rethrows, and any change to
those dependencies retries it. Errors from other expressions and from watch
callbacks don't stop the rest of a flush; the first one is rethrown at the
end.
Cycles. An expression that, directly or indirectly, reads itself throws
ex-info ("carbon.rx: detected a cycle in computation graph!") with the
chain of expressions involved.
Rules for expression bodies. Bodies should be pure functions of what they dereference. Writing to cells from inside a body is not supported.
Threads (JVM). Graph operations are serialized by a global lock, so cells
and expressions may be used from several threads, and swap! on cells and
lenses is atomic. Watches and drop handlers run while that lock is held, so
they must not block on other threads that use carbon.rx. Reading a cell
outside of an expression doesn't take the lock.
Metadata. The macros attach the source position of the form, and its
source text: always on the JVM, in goog.DEBUG builds only on
ClojureScript. Pass :meta (any expression) to add your own.
lein test # JVM tests
npm test # ClojureScript tests on Node
npm run test:release # ClojureScript tests, advanced compilation
lein with-profile +bench run # JVM benchmarks
npm run bench # Node benchmarksnix-shell provides Leiningen, a JDK and Node.
... is welcomed and very much appreciated! Feel free to ping me with questions and to make PRs.
Copyright © 2015–2026 Ruslan Prokopchuk
Distributed under the Eclipse Public License either version 1.0 or (at your option) any later version.