noun
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.10.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.
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})
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.
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.
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 @store status))
(get-status-limit @store 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.
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]]])
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))}})
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])))}})
You may have wondered why the placeholder keyword is wrapped in a vector:
[:event.target/value]. The vector allows placeholders to nest, so you can
transform late bound values without having to litter your code with things like
parse-long, or duplicate placeholders, e.g. :event.target/value-as-*.
Let's say you wanted to get a number from an input field:
[:input
{:on
{:input
[[:task/set-order task-id [:fmt/long [:event.target/value]]]]}}]
The :fmt/long placeholder takes an argument, which is passed to the
placeholder function:
(def nexus
{,,,
:nexus/placeholders
{:event.target/value
(fn [{:keys [dom-event]}]
(some-> dom-event .-target .-value))
:fmt/long
(fn [_ val]
(or (some-> val parse-long) 0))}})
:task/edit emits multiple :effects/save actions. With the current
implementation, this will cause several calls to swap!. If you want action
dispatch to be atomic, you can batch :effects/save. To do this, mark the
function with :nexus/batch meta data, and change its signature. It will now
receive a collection of action arguments:
(def nexus
{,,,
:nexus/effects
{:effects/save
^:nexus/batch
(fn [_ store path-vs]
(swap! store
(fn [state]
(reduce (fn [acc [path v]]
(assoc-in acc path v))
state path-vs))))}})
With this minor change, every :effects/save will be handled together,
resulting in only a single swap!.
Imagine that there is a form to add new tasks. When the form is submitted, we want to issue a command to the server to create a new task, and redirect the user to a dedicated page for it.
Here's an effect to send a command to the server:
(def nexus
{:nexus/effects
{:effects/command
(fn [ctx store command]
(js/fetch "/commands"
#js {:method "POST"
:body (pr-str command)}))}})
To redirect the user, we need to dispatch a new action when the command
completes, looking up the new location from the response. The ctx passed to
effect functions includes a :dispatch function. It lets you trigger new
actions with access to the same nexus, system and dispatch-data.
(def nexus
{:nexus/effects
{:effects/command
(fn [{:keys [dispatch]} store command]
(-> (js/fetch "/commands"
#js {:method "POST"
:body (pr-str command)})
(.then
(fn [response]
(dispatch
[[:effects/navigate
(.get (.-headers response) "Location")]])))))}})
This works, but it couples concerns more tightly than we'd like. We want as few effects as possible, meaning that they should be as general as possible. We can't assume that every command should result in a navigation. Instead we want to make this decision when issuing the command:
(nexus/dispatch nexus store dispatch-data
[[:effects/command
{:command/kind :commands/create-task
:command/data
{:task/title "Learn Nexus"
:task/priority :task.priority/high}}
{:on-success [[:effects/navigate ???]]}]])
^^^
Once again, we want to refer to a value that’s only available when the action is triggered—without writing imperative glue code. The solution: more placeholders.
(def nexus
{:nexus/placeholders
{:http.res/header
(fn [{:keys [response]} header]
(.get (.-headers response) header))}})
We can use the placeholder in the :on-success actions:
[[:effects/command
{:command/kind :commands/create-task
:command/data
{:task/title "Learn Nexus"
:task/priority :task.priority/high}}
{:on-success [[:effects/navigate [:http.res/header "Location"]]]}]]
Finally, in the command effect, we can provide additional dispatch data when dispatching new actions. This dispatch data will be merged into the original dispatch data:
(def nexus
{:nexus/placeholders
{:http.res/header
(fn [{:keys [response]} header]
(.get (.-headers response) header))}
:nexus/effects
{,,,
:effects/command
(fn [{:keys [dispatch]} store command {:keys [on-success]}]
(-> (js/fetch "/commands"
#js {:method "POST"
:body (pr-str command)})
(.then
(fn [response]
(when on-success
(dispatch on-success {:response response}))))))}})
The effect function now accepts a fourth argument that matches the extra options in the dispatched action:
[:effects/command command opts]
For a production-ready setup you should also support an :on-failure option in
the last map.
With this setup, we’ve cleanly separated concerns: the command effect remains generic, while callers can declaratively define what should happen on success. This pattern generalizes well and can be used to implement all kinds of asynchronous flows.
If you're rendering with Replicant, you can introduce it to Nexus with a one-liner:
(require '[nexus.core :as nexus]
'[replicant.dom :as r])
(def nexus ,,,)
(def store (atom {}))
(defn start [el neus store]
;; Dispatch Replicant's event data with Nexus
(r/set-dispatch! #(nexus/dispatch nexus store %1 %2))
(add-watch store ::render #(r/render el %4))
(swap! store assoc ::started-at (js/Date.)))
set-dispatch! calls its function with a map that contains, among other things,
the DOM event under the key :replicant/dom-event.
...if only you could see what I've seen with your eyes
If you are using Dataspex you can load Nexus' custom action panel like so:
(require '[nexus.action-log :as action-log])
(defn inspect-actions [nexus]
(let [log (action-log/create-log)]
(action-log/install-inspector log)
(action-log/install-logger nexus log)))
...and use the returned nexus with nexus/dispatch.
Compiling the nexus map and passing it by hand makes things explicit at the
cost of some boilerplate on your end. Maybe you're willing to swallow a little
implicitness for a lot of convenience? Then nexus.registry is for you.
nexus.registry provides some wrappers that collect actions, effects and
placeholders in a global registry:
(require '[nexus.registry :as nxr])
(nxr/register-effect! :effects/save
^:nexus/batch
(fn [_ store path-vs]
(swap! store
(fn [state]
(reduce (fn [acc [path v]]
(assoc-in acc path v))
state path-vs)))))
(nxr/register-placeholder! :event.target/value
(fn [{:replicant/keys [dom-node]}]
(some-> dom-node .-target .-value)))
(nxr/register-placeholder! :fmt/number
(fn [_ val]
(or (some-> val parse-long) 0)))
(nxr/register-action! :task/start-editing
(fn [state task-id]
[[:effects/save [:tasks task-id :task/editing?] true]]))
(nxr/register-action! :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]]])))
,,,
nexus.registry/dispatch implicitly uses this registry:
(replicant.dom/set-dispatch!
(fn [dispatch-data actions]
(nxr/dispatch store dispatch-data actions)))
Using the global registry makes the dev setup much easier, because you don't
need to coordinate on the nexus map. Just add this to your development setup:
(require '[nexus.action-log :as action-log])
(action-log/inspect)
Tutorials on the Replicant website demonstrate that rolling your own action dispatch system is not a big undertaking. So why does this library exist?
Designing a robust action dispatch system can be daunting when you’re just getting started. Even if the pieces are conceptually simple, it takes time and experience to get the architecture right, especially in a functional setting.
We’ve spent over a decade building systems like this in Clojure. Nexus is our distilled take on how to do it cleanly and consistently, so you don’t have to reinvent the wheel or get stuck figuring it out on day one.
Even for experienced developers, it’s easy to cut corners when wiring up yet another dispatch system—skipping instrumentation, half-assing some features, or going light on error handling. Nexus takes care of those finer points once and for all.
Every app needs some kind of action dispatch system. Even if we all agree that an action is a vector with a keyword identifier and some arguments, there are many ways to build the dispatch pipeline. There’s little value in making it slightly different in every project—but doing so does come with a cognitive cost.
You need some action system from the very beginning—often before you know if you’ll need to separate actions from side-effects, if actions will need to trigger other actions (maybe asynchronously), or if you’ll want placeholder interpolation.
If we can build a strong, flexible system for this flow in a small package, it can help reduce decision fatigue and free up your mental bandwidth to focus on your domain instead of your wiring.
Sure, a basic dispatch system isn’t much code—but it is code you’ll end up copying between projects. With Replicant and Nexus together, you get up and running with just a handful of lines. That means less setup, fewer errors, and more consistency across projects.
Action dispatch is one of those areas where good observability is a genuine superpower. Nexus ships with built-in integration for Dataspex, letting you inspect dispatch flows live during development.
It also exposes a fine-grained interceptor API you can extend however you want—add production tracing, connect it to custom tooling, or build your own time-travel debugger.
We’re not trying to scare you into using a library when 50 lines of code could work. But for ~160 lines, Nexus gives you a clean, tested, extensible dispatch system with excellent dev tooling built in.
Finding the right nomenclature was the most important part of the Nexus design process. Strong names empower you to build a system with the right focus, help you reach for the right tools for the job, and lightens the learning curve.
A vector beginning with a keyword (e.g. [:auth/login "alice" "secret"]) that
represents an instruction to the system. The keyword identifies the action type,
and any following values are action arguments.
Actions were deliberately not named "events" for two reasons:
Action handlers are pure functions that take the state (see below) and any arguments from the action, and return a list of new actions.
When an action reaches an effect handler to be processed for its side-effects, we call it an effect. In other words, "effect" and "action" describe the same data structure in different phases of processing.
An effect handler is a function that receives ctx, the live system and any
action arguments and uses them to perform side-effects:
[:effect/save [:number] 3]
(fn [ctx system path value])
Here path would be bound to [:number] and value to 3.
When effect functions are batched, they receive a collection of arguments instead:
[[:effect/save [:number] 3]
[:effect/save [:name] "Nexus"]]
^:nexus/batch
(fn [ctx system path-values])
In this case path-values would be:
[[[:number] 3]
[[:name] "Nexus"]]
The system contains your live, mutable application state and services. Nexus
treats this as an opaque value—it never inspects or modifies it. It is passed to
effects (to perform work) and to your :nexus/system->state function (to
extract pure data for action handlers).
The result of calling :nexus/system->state on your system. The result is
assumed to be immutable data.
Data that is only available at the time an action is dispatched. Prime examples include the DOM event that triggered the action or the target element, but can be anything a pure action handler wouldn't otherwise have access to, like the current time.
Placeholders allow actions to refer to data that isn't available until the action is dispatched. They are represented as a vector with a keyword, and possibly additional arguments. Placeholders have an implementation associated with the first keyword.
nexusA map defining your Nexus system. It includes keys like :nexus/actions,
:nexus/effects, :nexus/placeholders, :nexus/interceptors, and
:nexus/system->state. This is the central registry passed to
nexus.core/dispatch.
When using nexus.registry, Nexus maintains this registry for you.
ctxThe context map passed to interceptors. It contains data relevant to the current
interceptor phase (e.g. :action, :errors, :results) and data about other
interceptors (:queue/:stack). Interceptors can read or modify this context
to influence execution.
Nexus catches errors from handlers and interceptors, and propagates them as data. By default, a dispatch can be partially successful—some actions may complete while others fail in an action or effect handler. You can control this behavior with interceptors. Nexus ships with an opt-in fail fast strategy if you'd like all action and effect processing to halt on the first error:
(require '[nexus.strategies :as strategies])
(def nexus
{:nexus/interceptors [strategies/fail-fast]
:nexus/actions ,,,
:nexus/effects ,,,})
If you're using the registry:
(require '[nexus.registry :as nxr])
(require '[nexus.strategies :as strategies])
(nxr/register-interceptor! strategies/fail-fast)
With the fail-fast strategy, action and effect processing stops at the first
failure. dispatch itself will never throw. Errors are collected and returned
in the :errors key. If you want an exception thrown on error, you can do that
after calling dispatch:
(require '[nexus.core :as nexus])
(let [{:keys [results errors]} (nexus/dispatch nexus actions)]
(when-let [error (->> errors (keep :err) first)]
(throw error)))
Note that this will only throw the first of potentially several errors.
Interceptors let you instrument and control the flow of actions. They can run before or after dispatch, action handlers, and effect handlers.
An interceptor is a map with an :id and any number of phase functions, keyed
by:
:before-dispatch:after-dispatch:before-action:after-action:before-effect:after-effectAll interceptor functions take a single argument, ctx, and must return an
updated ctx. Its contents vary by phase, but always include:
:queue — the remaining before-phase interceptors. The handler function
appears at the end of this list.:stack — the after-phase interceptors to run once the handler completes.By modifying :queue or :stack, interceptors can short-circuit processing or
skip specific interceptors.
Specific arguments passed to the various phase functions:
:before-dispatch:system:state:dispatch-data:actions:after-dispatch:system:state:dispatch-data:actions:results:errors:before-action:before-action is only called for actions that have a registered action
handler. Any action that only has an effect handler will not be visible with
this interceptor.
:state:action:errors (errors from previous action handlers):after-action:state:actions:errors (errors from previous action handlers):before-effect:system:dispatch-data:dispatch — a function to dispatch new actions with the same nexus,
system, and dispatch-data.:effect or :effects (batched effect handler):errors (errors from previous effect handlers):results (from previous effects):after-effect:system:dispatch-data:dispatch:effect or :effects (batched effect handler):errors:resultsHere's an example of an interceptor that logs actions as they're processed as effects:
(def logger
{:id :logger
:before-effect
(fn [{:keys [effect]}]
(println "Handling effect:" (pr-str effect))
ctx)
:after-effect
(fn [{:keys [effect errors]}]
(if (seq errors)
(println "⚠️ Errors while handling" (pr-str effect) ":" (pr-str errors))
(println "✓ Successfully handled" (pr-str effect)))
ctx)})
(def nexus
{:interceptors [logger]
:actions ,,,
:effects ,,,})
You can also register interceptors with nexus.registry/register-interceptor!.
Ring middleware: ring-nexus-middleware applies Nexus to Ring handlers for clean, testable web applications. Datastar: datastar.wow uses Nexus to create extensible, testable, and data-oriented real-time applications via Datastar.
Designed by Magnar Sveen (@magnars), Christian Johansen (@cjohansen), and Teodor Heggelund (@teodorlu).
Interpolate placeholders in between each action expansion.
Copyright © 2025 Christian Johansen, Magnar Sveen, and Teodor Heggelund. Distributed under the MIT License.
Can you improve this documentation? These fine people already did:
Christian Johansen, Ovi Stoica, Brian Scaturro & Anders FursethEdit 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 |