SkillAgentSearch skills...

Nexus

Data-driven action dispatch for Clojure(Script): Build systems that are easier to test, observe, and extend

Install / Use

/learn @cjohansen/Nexus
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

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

<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

View on GitHub
GitHub Stars105
CategoryDevelopment
Updated20d ago
Forks9

Languages

Clojure

Security Score

95/100

Audited on Mar 14, 2026

No findings