Managing Reactive UI State in ClojureScript with Reagent and Shadow-CLJS
Learn how to build reactive user interfaces in ClojureScript using Reagent atoms and Shadow-CLJS for efficient state management and hot-reloading.
06 May 2026, 08:35 UTC

The Problem: Synchronizing UI with Application State
In traditional JavaScript development, keeping the DOM in sync with the application state often requires manual updates or complex boilerplate. In ClojureScript, the challenge is bridging the gap between immutable data structures and the mutable nature of the browser's DOM. The goal is to create a UI that reacts automatically to data changes without manually calling render functions or managing complex event listeners.
The most effective way to achieve this is by combining Shadow-CLJS for the build pipeline and Reagent as a wrapper for React. Reagent allows you to use atoms—reference types that hold a value—to trigger targeted re-renders whenever the data changes.
Prerequisites
- Node.js installed (LTS version recommended).
- Basic familiarity with Hiccup (a way to represent HTML as Clojure vectors).
- A terminal with access to
npmornpx.
Project Configuration
Shadow-CLJS replaces the need for separate Webpack or Babel configurations by centralizing the build process in a single EDN file. Create a shadow-cljs.edn file in your root directory with the following configuration:
{:builds
{:app
{:target :browser
:output-dir "public/js"
:asset-path "/js"
:modules {:main {:init-fn app.core/init}}}}}
This configuration defines a build target called app that compiles the app.core/init function into a JavaScript file located in public/js.
Implementing Reactive State with Atoms
Reagent components are simply ClojureScript functions that return Hiccup. To make a component reactive, it must access a Reagent atom. When the atom's value is updated, Reagent identifies every component that accessed that atom and re-renders only those specific parts of the UI.
Example: A Reactive Counter
Create a file at src/app/core.cljs. This example demonstrates a stateful counter where the UI updates automatically when the atom changes.
(ns app.core
(:require [reagent.core :as r]
[reagent.dom :as rdom]))
;; Define a reactive atom for state
(defonce counter-state (r/atom 0))
(defn counter-component []
(let [current-val @counter-state] ; Accessing the atom marks this component for re-render
[:div
[:h1 "Reactive Counter"]
[:p "Current Count: " current-val]
[:button {:on-click #(swap! counter-state inc)} "Increment"]
[:button {:on-click #(swap! counter-state dec)} "Decrement"]]))
(defn init []
(rdom/render [counter-component] (.getElementById js/document "app")))
Technical Breakdown of the Implementation
(r/atom 0): Creates a Reagent atom. Unlike a standard Clojure atom, this notifies Reagent when its value changes.@counter-state: The@(deref) operator reads the current value. This is the "subscription" mechanism; the component is now linked to this atom.swap!: This function updates the atom by applying a function (likeinc) to the current value.Hiccup Syntax: The[:div ...]structure is converted by Reagent into actual React elements.
Execution and Verification
To run the application, execute the following command in your terminal from the project root:
npx shadow-cljs watch app
Permissions: This requires standard user permissions to execute Node.js scripts and write to the public/js directory.
Verification Steps:
- Open your browser to the local server (typically
localhost:8080or your configured static server). - Click the "Increment" button. The count should update instantly without a page refresh.
- While the
watchprocess is running, change the text"Current Count: "to"Value: "in your code and save. The browser should update the text immediately via Hot Module Replacement (HMR) without resetting the counter value.
Decision Matrix: Atoms vs. re-frame
While atoms are sufficient for small to medium components, they can lead to "spaghetti state" in large applications. Use the following table to decide your state management strategy:
| Feature | Reagent Atoms | re-frame |
|---|---|---|
| Complexity | Low (Simple atoms) | High (Event-dispatch pattern) |
| State Location | Distributed/Global | Centralized App-DB |
| Traceability | Difficult to track updates | High (Events are logged) |
| Best Use Case | Small apps, local UI state | Enterprise apps, complex data flows |
Limitations and Risks
- Direct DOM Manipulation: Never use
js/document.getElementByIdto change text inside a Reagent component. This bypasses the virtual DOM and will be overwritten the next time the atom triggers a render. - Memory Leaks: Avoid creating atoms inside the render function of a component; atoms should be defined at the namespace level or passed as props to ensure they persist across renders.
- Node Versioning: Ensure your Node.js version matches the requirements of your
shadow-cljsversion to avoid bundling errors during thewatchprocess.
Rollback Procedure
Because this implementation changes the project structure and adds dependencies, use the following steps to revert:
- Delete the
shadow-cljs.ednfile. - Remove the
public/jsdirectory. - Uninstall the
shadow-cljsandreagentpackages vianpm uninstall.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.