Nexus
Data-driven action dispatch for Clojure(Script): Build systems that are easier to test, observe, and extend
Install / Use
/learn @cjohansen/NexusREADME
Nexus
noun<br> a means of connection; tie; link.
The UI is a pure function of application state — but event handling can be messy. It doesn’t have to be. Good event handling is declarative, minimal, and keeps side-effects well contained.
Nexus is a small, zero-dependency library for dispatching actions — data describing what should happen — with mostly pure functions.
no.cjohansen/nexus {:mvn/version "2025.11.1"}
Replicant provides a data-driven and functional solution to rendering. By making event handlers representable as data, it provides you with just enough infrastructure to build a declarative action dispatch system. Nexus is designed to be that system.
Table of contents
- Nexus at a glance
- Getting started
- Development tooling
- Convenience API
- Rationale
- Nomenclature
- Error handling
- Interceptors
<a id="first-example"></a>
Nexus at a glance
Here's a compact showcase of using Nexus with Replicant. Read on for a detailed introduction to how it works.
(require '[nexus.registry :as nxr])
(require '[replicant.dom :as r])
(defn save [_ store path value]
(swap! store assoc-in path value))
(defn increment [state path]
[[:effects/save path (+ (:step state) (get-in state path))]])
(defn render [state]
[:div
[:p "Number: " (:number state)]
[:div
[:label "Step size: "]
[:input
{:value (:step state)
:on
{:input
[[:effects/save [:step] [:fmt/number [:event.target/value]]]]}}]]
[:button.btn
{:on {:click [[:actions/inc [:number]]]}}
"Count!"]])
;; App state
(def store (atom {}))
;; Handle user input: register effects, actions and placeholders.
;; If you don't like registering these globally, the next section
;; shows how to use nexus.core, which has no implicit state.
(nxr/register-effect! :effects/save save)
(nxr/register-action! :actions/inc increment)
(nxr/register-placeholder! :event.target/value
(fn [{:replicant/keys [dom-event]}]
(some-> dom-event .-target .-value)))
(nxr/register-placeholder! :fmt/number
(fn [_ value]
(or (some-> value parse-long) 0)))
(nxr/register-system->state! deref)
;; Wire up the render loop
(r/set-dispatch! #(nxr/dispatch store %1 %2))
(add-watch store ::render #(r/render js/document.body (render %4)))
;; Trigger the initial render
(reset! store {:number 0, :step 1})
<a id="getting-started"></a>
Getting started
Nexus actions are data structures that describe what your system should do. They're processed as effects—functions that perform side-effects. Actions can originate from user events, timers, network responses, or other sources.
Actions are vectors of an action type (keyword) and optional arguments:
[:task/set-status "tid33" :status/in-progress]
Effects are functions that process actions and perform side effects on your system. They are called with two or more arguments: a context map (we'll look at this later), your system, and any arguments from the action.
Nexus makes no assumptions about what the system is—you will pass it when
dispatching actions. In this example we'll use an atom, called the store.
Implementing an effect
Put action implementations in your nexus map:
(def nexus
{:nexus/effects
{:task/start-editing
(fn [_ store task-id]
(swap! store assoc-in [:tasks task-id :task/editing?] true))}})
When the action is triggered, e.g. by a DOM event, dispatch it with the nexus
map and your system:
(require '[nexus.core :as nexus])
(def store (atom {:tasks [,,,]}))
(nexus/dispatch nexus store {} [[:task/start-editing "tid33"]])
This separates what happens from how, but we can do better—by separating pure action logic from effectful execution.
In Kanban, there are limits to how many tasks you can have in each column at the
same time, meaning that the :task/set-status action is not just a mere
assoc-in. This action needs to check how many tasks we already have with the
desired status, and either update the task or flag an error:
(defn get-tasks-by-status [state status]
(->> (vals (:tasks state))
(filter (comp #{status} :task/status))))
(defn get-status-limit [state status]
(get-in state [:columns status :column/limit]))
(def nexus
{:nexus/effects
{:task/start-editing
(fn [_ store task-id]
(swap! store assoc-in [:tasks task-id :task/editing?] true))
:task/set-status
(fn [_ store task-id status]
(if (< (count (get-tasks-by-status @store status))
(get-status-limit @store status))
(swap! store assoc-in [:tasks task-id :task/status] status)
(swap! store assoc :errors [:errors/at-limit status])))}})
Holy swap, Batman! That's a lot of side-effects in one place. Our goal is to isolate logic in pure functions, so we can test, reuse, and compose behavior without changing the world. Let's fix that.
Pure actions
We will first introduce a low-level effect to update the application state:
(def nexus
{:nexus/effects
{:effects/save
(fn [_ store path v]
(swap! store assoc-in path v))}})
Our two actions can now be expressed in terms of this one effect. We do that by
implementing them as actions instead of effects. Actions are pure functions
that return lists of actions — transforming intent into more low-level
implementations. They're called with an immutable snapshot of your system. This
means we need to tell Nexus how to acquire the system snapshot. Since our system
is an atom, deref will do the job just fine:
(def nexus
{:nexus/system->state deref ;; <==
:nexus/effects {,,,}
:nexus/actions ;; <==
{:task/start-editing
(fn [state task-id]
[[:effects/save [:tasks task-id :task/editing?] true]])
:task/set-status
(fn [state task-id status]
(if (< (count (get-tasks-by-status state status))
(get-status-limit state status))
[[:effects/save [:tasks task-id :task/status] status]]
[[:effects/save [:errors] [:errors/at-limit status]]]))}})
Now we also have clean separation between pure business logic and the side-effects. Your app will only ever need a handful of effect implementations; as your app grows you'll be adding action implementations. Nothing but pure functions all the way, baby!
It's also worth noting that the UI doesn't need to know whether the actions it dispatches are directly processed as effects, or if it goes through one or more pure transformations. This allows you to start small and grow your system on demand, with very little boilerplate.
Using dispatch data
When using Replicant event handler data, you include actions in the rendered hiccup. However, some actions rely on data that isn't available until they dispatch.
Consider this action that updates the task title:
[:input
{:placeholder "Task title"
:name "task/title"
:on {:input [[:task/update-title task-id ???]]}}]
^^^
To dispatch this we need the value from the input field at the time of
dispatch. Placeholders solve this problem in a declarative way:
[:input
{:placeholder "Task title"
:name "task/title"
:on {:input [[:task/update-title task-id [:event.target/value]]]}}]
[:event.target/value] is a placeholder to be resolved during dispatch.
Placeholders are implemented by the keyword:
(def nexus
{:nexus/system->state deref
:nexus/effects {,,,}
:nexus/actions {,,,}
:nexus/placeholders ;; <==
{:event.target/value
(fn [dispatch-data]
(some-> dispatch-data :dom-event .-target .-value))}})
Where does dispatch-data come from? It is the third argument to
nexus.core/dispatch:
(nexus/dispatch nexus store {:dom-event ,,,}
[[:task/update-title "tid33" [:event.target/value]]])
Calling event methods
Dispatch data is also available to effect functions. Let's say we have a form to
edit the task. Instead of controlling each input, it will use the form submit
action. To avoid a page refresh on submit we must call .preventDefault on the
event:
[:form
{:on
{:submit
[[:effects/prevent-default]
[:task/edit task-id [:event.target/form-data]]]}}
,,,]
Effect functions receive dispatch-data as part of their first argument, the
aforementioned context map:
(def nexus
{,,,
:nexus/effects
{,,,
:effects/prevent-default
(fn [{:keys [dispatch-data]} _]
(some-> dispatch-data :dom-event .preventDefault))}})
Clock time
You may want to record the time of the last edit. However, getting the current time in the action handler means it's no longer pure (and will make it much harder to test). You can solve this by passing in the current time.
One way to achieve this is to use another placeholder:
(def nexus
{,,,
:nexus/placeholders
{,,,
:clock/now
(fn [{:keys [now]}]
now)}})
(nexus/dispatch nexus store {:dom-event ,,,
:now (js/Date.)}
[[:task/update-title "tid33" [:event.target/value] [:clock/now]]])
Note that you could write this placeholder very succinctly:
(def nexus
{:nexus/placeholders
{:clock/now :now}})
Another option is to make sure the state always has the current time on it:
(def nexus
{,,,
:nexus/system->state
(fn [store]
(assoc @store :clock/now (js/Date.)))
:nexus/actions
{:task/edit
(fn [state task-id data]
(into [[:effects/save [:tasks task-id :task/updated-at] (:clock/now state)]]
(for [[k v] data]
[:effects/save [:tasks task-id k] v])))}})
Nested placeholder
You may have wondered why the placeholder keyword is wrapped in a vector:
[:event.target/value]. The vector allows placeholders t
Related Skills
node-connect
346.8kDiagnose OpenClaw node connection and pairing failures for Android, iOS, and macOS companion apps
frontend-design
107.6kCreate distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
openai-whisper-api
346.8kTranscribe audio via OpenAI Audio Transcriptions API (Whisper).
qqbot-media
346.8kQQBot 富媒体收发能力。使用 <qqmedia> 标签,系统根据文件扩展名自动识别类型(图片/语音/视频/文件)。
