Managing UI State in ClojureScript with Reagent Atoms
Learn how to use Reagent atoms in ClojureScript to create reactive UIs. This guide covers state definition, component rendering, and the correct way to trigger DOM updates without manual manipulation.
13 Apr 2026, 08:33 UTC

The Problem: Synchronizing State and DOM
In traditional JavaScript development, keeping the User Interface (UI) in sync with the underlying data often requires manual DOM manipulation or complex event listeners. When data changes in one part of the application, you must explicitly find the corresponding HTML element and update its content, which is error-prone and scales poorly.
The takeaway: By using Reagent, a ClojureScript wrapper for React, you can treat your UI as a pure function of your state. By storing data in Reagent atoms, the library automatically detects changes and updates only the specific DOM elements that depend on that data, removing the need for manual DOM queries.
Prerequisites
- A ClojureScript project configured with
shadow-cljs. - The
reagentlibrary added to yourshadow-cljs.edndependencies. - Basic familiarity with Hiccup (the syntax of using vectors to represent HTML, e.g.,
[:div "Hello"]).
Implementing Reactive State
Reagent utilizes a specialized version of the Clojure atom. While a standard Clojure atom holds a value, a Reagent atom (created with reagent.core/atom) notifies the React reconciler whenever its value changes, triggering a re-render of any component that dereferenced that atom during its last execution.
1. Defining the State
Initialize your state outside your component functions to ensure it persists across re-renders. Using defonce also preserves the state during hot code reloads in development.
(ns my-app.core
(:require [reagent.core :as r]))
;; Define a reactive atom to hold the application state
(defonce app-state (r/atom {:count 0 :user "Guest"}))2. Creating the Component
Components in Reagent are functions that return Hiccup data. To make a component reactive, dereference the atom with @ inside the function. Reagent tracks this access automatically.
(defn counter-component []
(let [{:keys [count user]} @app-state]
[:div
[:h1 "Welcome, " user]
[:p "Current count: " count]
[:button
{:on-click #(swap! app-state update :count inc)}
"Increment Count"]]))3. Mounting to the DOM
Attach your component tree to an HTML element in your index.html. The exact rendering entry point differs between Reagent versions (newer releases use a React 18-style client API); check the Reagent README for the version in your shadow-cljs.edn and confirm which render function it exposes.
(r/render [counter-component]
(.getElementById js/document "app"))Comparing State Update Strategies
Depending on the shape of your update, you will use different Clojure functions to trigger UI updates. Because Reagent intercepts these calls, dependent components re-render automatically.
| Function | Use Case | Risk |
|---|---|---|
reset! | Replacing the entire state object with a new value. | Overwrites all other keys if you forget to merge existing state. |
swap! | Updating a value based on the current value (e.g., incrementing). | The update function may run more than once under contention; keep it pure. |
add-watch | Logging or reacting to state changes outside the UI. | Side effects in watchers can be hard to trace; use sparingly. |
Diagnostic Checks and Verification
- UI responsiveness: Trigger the
on-clickhandler. The count in the paragraph should update without a page reload. - Granular rendering: Use the React Developer Tools "highlight updates" option. Only the elements bound to the changed value should flash, not the whole tree.
- State inspection: From a connected REPL, evaluate
@app-stateto confirm the underlying data matches what the UI displays.
Critical Limitations
- DOM bypass: Never use
js/document.getElementByIdto change the text of an element managed by Reagent. Doing so bypasses the Virtual DOM, and Reagent will overwrite your manual change on the next render. - Render performance: Avoid heavy computation (like sorting a large list) directly inside the component function, since it runs on every state change. Move expensive logic into event handlers or memoize results.
- Global atoms at scale: A single global atom works for small apps, but in larger codebases updates become hard to trace. Consider splitting state into focused atoms or adopting a framework such as re-frame.
Recovery: Fixing a Render Loop
If a state update causes a crash or an infinite render loop (usually from calling swap! directly in the render body):
- Refresh the browser to clear the in-memory state.
- Locate the
swap!orreset!call inside the component body. - Move the mutation into an event handler (such as
:on-click) or a lifecycle/effect hook so it runs in response to an action, not during rendering.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.