A toolkit for building web component based UIs in ClojureScript.
Zero builds on top of SubZero's web components, supplementing it with state management, structured data serialization, and various other utilities. The goal of the project is to provide a 'batteries included' solution to building complete UIs with SubZero's web components.
Add something akin to the following somewhere in your boot up logic:
(zero.config/install! zero.config/!default-db)
;; only for browsers, sets up the web component registry
(zero.wcconfig/install! zero.config/!default-db)
;; only for browsers, and optional, adds some DOM utilities and convenient components
(zero.dom/install! zero.config/!default-db)
The zero.config/!default-db
in the above can be substituted with any SubZero database,
Zero provides a default instance for convenience. If a non-default instanace is used,
then an extra !db
parameter needs to be passed to all of Zero's reg-*
functions,
and some others. Usually the default DB is more convenient browser-side.
Zero's state management consists primarily of three concepts: injections, actions, and bindings. The types corresponding to these concepts were built with a virtual DOM (and reconciliation) in mind, they all have value semantics.
An injection is a 'placeholder' for some value that will be injected at a later time.
(ns example
(:require
[zero.core :refer [<<] :as z]
[zero.config :as zc]))
(zc/reg-injections
::user
(fn [context & _args]
(:user context)))
(z/inject
{:user "Ray"}
["Hello, " (<< ::user) "!"])
;-> ["Hello, " "Ray" "!"]
These are most useful within actions, where they can inject DOM components and event data to be acted upon.
For the sake of convenience, there's also the zero.core/<<ctx
function that builds
in injector that resolves to a particular path within its context. For example
(<<ctx :user)
would do the same as the injector in the example above, without
the need for a custom registration.
Injector construction functions can also be chained: (<< :thing1 arg1 arg2 << :thing2 arg3)
.
Actions are packets of 'things to do at some point' in the form of effects. They can be used as event handlers in SubZero components in place of raw functions.
Actions have a few advantages over raw functions:
- Value semantics make DOM reconciliation more performant
- Built-in conveniences like logging and throttling
- Injections are resolved before effects are dispatched
The last bullet is the main reason for the existence of actions. It allows us to easily work with 'the real DOM' without breaking our component's declarative virtual DOM, by injecting raw DOM nodes into our effect handlers.
(ns example
(:require
[zero.core :refer [ctx act] :as z]
[zero.config :as zc]))
(defn my-custom-input-view
[{:keys [value]}]
[:input
:value value
:#on {:input (act [::dispatch (<<ctx ::z/host) :value (<< ::value <<ctx ::z/current)])}])
;; This is how we register effect handlers, which are invoked from our actions
(zc/reg-effects
;; This effect will dispatch an event on the given DOM node
::dispatch
(fn [^js/HTMLElement target event-type event-value]
(.dispatchEvent target
(js/CustomEvent.
(name event-type)
#js{:detail event-value})))
;; This effect will log whatever value it receives
::log
(fn [data]
(js/console.log data)))
;; An injector, which grabs the `.-value` of whatever DOM node it receives
(zc/reg-injections
::value
(fn [_ ^js/HTMLElement target]
(.-value target)))
(zc/reg-components
:my-custom-input
{:view my-custom-input-view
:props #{:value})
(comment
;; This can now be used within another component like this
[:my-custom-input
:#on{:value (act [::log (<<ctx ::z/data)])}])
This system of injecting the things we need into our effect handlers is, in my experience, much more convenient than bumbling around with refs as we'd do in most React-based libraries.
When an action is used as an event handler or called with an event (they can just be called with an explicit context) its context (the thing passed to injection handlers) is derived from the received event. The derived context is a map with the following:
:zero.core/event.target ;; the event target when the action was invoked
:zero.core/event ;; the event itself (usually stale)
:zero.core/current ;; the thing the event handler (action) is attached to
:zero.core/root (.getRootNode current) ;; usually the shadow root of the element the action was rendered within
:zero.core/host (.-host root) ;; usually the component in which the action was rendered
:zero.core/data ;; data harvested from the event, depends on the event type and (if provided) custom event harvester
:zero.core/db ;; the SubZero database instance associated with the component this action was rendered in
Actions can also take an option map to customize how they'll be dispatched, the overall usage of
zero.core/act
looks like:
(act {:as opts}? & effect-vectors)
The following options are supported only when the action is dispatched with an event:
:prevent-default?
- Prevent the event's default behaviour:stop-propagation?
- Stop the event from bubbling to parent nodes
The following options are always supported (including in JVM Clojure):
-
:log? true
Log the action, along with other useful debug data, when it's dispatched.
-
:dispatch :default|:throttle|:debounce|:immediate
Determines how the action will be dispatched. By default, the action's effects will be invoked 'sometime soon'.
- The
:immediate
dispatch strategy tells the action to invoke its effects immediately when the action is dispatched, during the same call frame. - The
:throttle
and:debouce
strategies reduce multiple action dispatched over a period of time to either a single trailing (for:debounce
) or a leading and trailing (for:throttle
) invocation of its effects.
- The
-
:delta
Gives the 'period' in milliseconds for
:throttle
and:debounce
dispatches. Ignored if a different dispatch strategy is used.
Bindings build on Clojure/Script's built-in 'watchability' of reference types to provide a more robust 'reactive programming' experience.
Essentially, a binding taps into a 'data stream', which boots up to provide a reactive stream of data while being watched; then cleans up and shuts down when no longer needed.
(ns example
(:require
[zero.core :refer [bnd]]
[zero.config :as zc]))
(zc/reg-streams
::my-random-data-stream
(fn [rx & max]
;; call `rx` to push a value into the stream
(let [interval (js/setInterval #(rx (rand-int max)) 5000)]
;; return a cleanup function
(fn []
(js/clearInterval interval)))))
;; derefing a binding while its stream is 'shut down' yields `nil`
@(bnd ::my-random-data-stream 10) ;; -> nil
;; watch the binding like you'd do with any watchable thing
(add-watch (bnd ::my-random-data-stream 10) ::my-watch-key
(fn [_ _ _ new-val]
(js/console.log new-val)))
;; then when you're done, shut it unwatch, it'll shut down when the
;; watch count reaches 0
(remove-watch (bnd ::my-random-data-stream 10) ::my-watch-key)
Since bindings adhere to the core Clojure/Script ref interfaces, and SubZero relies on these same interfaces for reactivity; we already have several ways to plug bindings into our web components. See the SubZero readme for details, but here's an example of plugging the above binding into a state prop of a SubZero component:
(zc/reg-components
:my-random-number
{:props #{:rand (bnd ::my-random-data-stream 10)}
:view (fn [{:keys [rand]}]
rand)})
Or via prop binds:
(zc/reg-components
:my-random-number
{:view (fn [{:keys [rand]}]
[:input
:#bind {:value (bnd ::my-random-data-stream 10)}])})
SubZero has some support for rendering components server-side with declarative shadow DOMs. This is quite powerful in itself, but Zero provides some extras:
- Concise Data Format (CDF), a concise readable format ideal for encoding structured
data into HTML attributes. Every component registered via
zero.config/reg-components
installs an attribute reader and writer for this format. So Zero components can wire structured data in their attributes out of the box. - The
zero.dom
namespace includes special components::zero.dom/bind
and:zero.dom/listen
, which will be output into generated HTML (when usingsubzero.plugins.html
) in place of:#on
(event listener prop) and:#bind
(bind prop) props. These components install the respective listeners/bindings when booted up on the client. In other words, we can bind to reactive state, and setup event listeners via server side (or static) rendering.
(ns example
(:require
[zero.core :refer [act bnd]]
[zero.config :as zc]
[subzero.plugins.html :as html]))
;; install Zero
(zc/install! zc/!default-db)
;; register a component
(zc/reg-components
::my-component
{:props #{:foo :bar}
:view (fn [{:keys [foo bar]}]
[:div
[:div "Foo is: " foo]
[:div "Bar is: " bar]
[:div
"Baz is: "
[:span
:#opaque? true
:#bind {:inner-html (bnd ::baz)}]]
[:button
:#on {:click (act [::do-something])}
"Click Me!"]])})
;; render it into HTML
(html/html zc/!default-db {:doctype "html"}
[:html
[:body
[::my-component :foo "FOO" :bar "BAR"]]])
Output HTML (re-formatted for readability):
<!DOCTYPE html>
<html>
<body>
<example-my-component foo="FOO" bar="BAR">
<template shadowrootmode="open">
<div>
<div>Foo is: FOO</div>
<div>Bar is: BAR</div>
<div>
Baz is: <span id="G__3340"></span>
<zero.dom-bind
sel="#G__3340"
prop=":inner-html"
ref="(bnd :user/baz)">
</zero.dom-bind>
</div>
<button id="G__3341">Click Me!</button>
<zero.dom-listen
sel="#G__3341"
evt=":click"
act="(act [:user/do-something])">
</zero.dom-listen>
</div>
</template>
</example-my-component>
</body>
</html>
The CDF format can also just be used as a general purpose structured data format via
zero.cdf/read-str
and zero.cdf/write-str
. I find it more convenient than EDN
in many instances.
There are some utilities in zero.util
to make working with watchable things more
convenient.
(ns example
(:require
[zero.core :refer [bnd]]
[zero.config :as zc]
[zero.util :as zu]))
(defonce thing-1 (atom nil))
;; watch multiple things at once
(zu/watch ::my-key
(fn [thing-1 thing-2]
;; do something
)
thing-1
(bnd ::thing-2))
;; unwatch
(zu/unwatch ::my-key)
;; watch until one of the things has a non-nil value, then run
;; the callback once and unwatch
(zu/when-any ::my-key
(fn [thing-1 thing-2]
;; do something
)
thing-1
(bnd ::thing-2))
;; unwatch early if needed
(zu/unwatch ::my-key)
;; watch until all the things have non-nil-values
(zu/when-all ::my-key
(fn [thing-1 thing-2]
;; do something
)
thing-1
(bnd ::thing-2))
;; create a stream that derives its values from several other
;; watchables
(zc/reg-streams
::my-derived-stream
(zu/derived
(fn [[thing-1 thing-2] & _args]
(+ thing-1 thing-2))
thing-1
(bnd ::thing-2)))
The zero.dom
namespaces provides utilities to help in working with the browser DOM. These
must be installed separately (zero.dom/install! zero.config/!default-db)
.
-
(<< ::zd/select-all selector & {:keys [deep? delay from]})
Grab a DOM element by selector. If
from
is provided, then searches the given node's subtree; otherwise uses:zero.core/root
from context (i.e searches the current component's DOM). Ifdeep?
is truthy, then it'll search within children's shadow DOMs as well. Thedelay
can be given as either:before-render
or:after-render
; in which case a promise which resolves before or after (respectively) the next render will be injected. This allows things which don't yet exist to be selected pre-emptively. -
(<< ::zd/select-one selector & {:keys [deep? delay from]})
Similar to
::zd/select-all
, but selects only the first matching element. -
(<< ::zd/select-closest selector & {:keys [breach? delay from]})
Similar to
::zd/select-all
except ancestors (not the subtree) is searched for matching nodes. Iffrom
is provided, this will search 'up' from that node. Otherwise:zero.core/current
from the context will be used. -
(<< ::zd/shadow element-or-promise)
Returns the
.-shadowRoot
of the element. If the input is a promise, the output will be too.
Use zero.dom/internal-state-prop
to attach an internal state prop to
components. This internal state can be updated via the :zero.dom/patch-internal-state
effect, or the zero.dom/patch-internal-state!
function. These take
patches compatible with subzero.rstore
.
(ns example
(:require
[zero.core :refer [act <<ctx]]
[zero.config :as zc]
[zero.dom :as zd]))
(zc/reg-components
::click-counter
{:props {:state (zd/internal-state-prop {:clicks 0})}
:view (fn [{{:keys [clicks]} :state}]
[:button
:#on {:click (act [::zd/patch-internal-state (<<ctx ::z/host) {:path [:clicks] :change [:value (inc clicks)]}])}
"Clicked " clicks " times!"])})
Use signals any time you need to let a component know about something that's happened externally, and can't be represented as a prop change.
(ns example
(:require
[zero.core :refer [sig act <<ctx] :as z]
[zero.config :as zc]))
(zc/reg-components
::something
{:props #{:a-sig}
:view (fn [{:keys [a-sig]}]
[:input
:#on {a-sig (act [::focus (<<ctx ::z/current)])}])})
;; elsewhere
[::something :a-sig (sig ::my-signal)]
;; elsewhere, when something happens
((sig ::my-signal))
Zero's model may not be for everyone. Don't worry, don't use it. Personally I've found that it works well for me. My Zero components are much cleaner, more flexible, and much more composable than anything I'd built in reagent or re-frame has been. But... that's just me.
Warning
Zero depends on modern browser APIs. It works on the latest versions of all major browsers... but will break in some not-ancient versions. In particular, it depends on:
Zero's undergone some huge changes, so all the demos and examples are out of date. I'll get to updating them soon.
Feel free to reach out in the #zero-lib channel on the Clojurians slack for any help, questions, feedback, etc. I'll also try to start posting real component code there whenever I work on something, so it's a good place to hang out and gain exposure.