Server-held UI state as a materialized view.
Remuda holds a view — a plain Clojure map describing what one connected user is looking at. When that map changes, it works out the smallest set of updates needed to bring the client back in sync, and it knows how to rebuild the map from scratch if the connection or the process dies.
It renders nothing and speaks no protocol. Rendering and transport are supplied by the caller, which is why Remuda has no dependencies beyond Clojure. For a browser, Darkstar supplies both using Datastar, and Zodiac Live wires that into Zodiac.
(fn [view ctx args] -> new-view). No
reducer registry, no event bus, no effects.The nearest analogy is a materialized view: a derived representation kept in sync with a source, refreshed incrementally. Here the derived representation happens to be a user interface, and the refresh is shipped to a remote client.
(require '[remuda.diff :as diff]
'[remuda.engine :as engine]
'[remuda.render :as render])
(def counter
{:mount (fn [_ctx] {:count 0})
:render (fn [{:keys [count] ::engine/keys [id]}]
(render/boundary []
[:div {:id id}
(render/boundary [:count]
[:span {:id (str id "-count")} count])]))
:on {:inc (fn [view _ctx _args] (update view :count inc))}})
;; Resolve each changed path to the region that owns it. A real client also needs
;; something to address that region with; Darkstar emits a CSS selector here.
(defn patches-fn [{:keys [boundaries]} ops]
(keep (fn [{:keys [op path]}]
(when-let [owner (render/resolve-boundary boundaries (vec path))]
{:op op :render {:path owner}}))
ops))
(def eng
(engine/start!
(engine/engine {:components {:counter counter}
:render-fn pr-str}))) ; your renderer
(def id (engine/connect! eng :counter {:send! prn})) ; your transport
(engine/mount! eng id {})
;=> [:div {:id "c1"} [:span {:id "c1-count"} 0]]
(engine/dispatch! eng id :inc nil
{:diff-fn diff/diff :patches-fn patches-fn})
;; send! receives one instruction, for the :count region only:
;; [{:op :replace :html "[:span {:id \"c1-count\"} 1]" :root? false}]
The whole view was re-rendered, but only the [:count] region was sent.
The caller supplies the renderer, the transport, and the translation from changed paths to whatever the client can address. Remuda supplies the state, the comparison and the lifecycle.
A view is a map. diff compares two of them and reports paths, using the same
vector addressing as update-in:
(diff/diff {:count 1} {:count 2})
;=> [{:op :replace :path [:count] :value 2}]
Untouched subtrees are skipped by an identical? check. Persistent data
structures share structure, so an unchanged branch costs a pointer comparison
rather than a walk.
Tag a collection with :live/key and items are tracked by identity instead of
position, so a reorder is reported as a move rather than as every element
changing:
(def keyed #(with-meta (vec %) {:live/key :id}))
(diff/diff {:items (keyed [{:id 1} {:id 2}])}
{:items (keyed [{:id 2} {:id 1}])})
;=> [{:op :move :path [:items] :key 2 :keyed? true :before 1}]
This matters because clients preserve element state — focus, scroll position, partially typed input — for elements that move rather than being replaced.
View paths are finer-grained than the regions a client can update. [:items 2 :text] may address a text node with no addressable element of its own.
So :render marks its own updatable regions with render/boundary, and a changed
path resolves up to the nearest marked ancestor. Boundaries and their ids are read
from the render output, so a region's address is whatever the author wrote.
Boundary granularity is update granularity, and the author controls it directly.
Because the view is a projection, every field needs an answer to "where does this come from when rebuilt?" That answer is its tier:
| Tier | On rebuild |
|---|---|
:sourced | re-queried by :mount (the default) |
:recoverable | replayed from a client-held snapshot |
:disposable | reset to a default |
:derived | recomputed from other fields |
Only exceptions are declared. A component with no :state key has every field
sourced.
{:state {:draft {:tier :recoverable} ; half-typed input, no row to query
:open? {:tier :disposable :default false}
:next-id {:tier :derived ; a function of :items
:from (fn [{:keys [items]}]
(inc (reduce max 0 (map :id items))))}}}
Derived fields are recomputed whenever a view is built and are excluded from the diff, since they are functions of fields that are diffed already.
:sourced is a claim about rebuild, not about reads. :mount runs on connect
and reconnect, not per render and not per interaction. Handlers mutate sourced
fields freely; between mounts the view is a cache.
Reconnect is :mount plus replaying the recoverable fields, not a separate
mechanism. Sourced state comes back from a fresh query, so anything that changed
while the connection was down is picked up.
Snapshots are held by the client, so they are signed and validated on the way back
in, and then filtered by tier: only declared-:recoverable fields are replayed. A
validly signed snapshot cannot write a :sourced field.
Signing is the caller's job. Remuda holds no secret and takes no position on how a snapshot reaches it.
Source and PubSub are protocols you implement over your own store and bus.
PubSub messages are invalidation hints carrying no payload: "topic X changed,
re-derive." A hint is therefore safe to lose, reorder or deliver twice, and many
hints for one topic collapse into a single rebuild.
Working and tested, not released. Live child components are the main unbuilt piece.
MIT. 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 |