ClojureScript for React Developers
If you write React, you already understand most of ClojureScript's front-end story — you just don't know it yet. Reagent, the dominant ClojureScript UI library, is React underneath. Components are functions that return markup, state triggers re-renders, the virtual DOM diffs and patches. What changes is the language around it: immutable data by default, s-expressions instead of JSX, and a build tool you drive from a REPL. This is the map I'd give my past React self.
Components Are Still Just Functions Returning Markup
A React function component returns JSX. A Reagent component returns hiccup — plain ClojureScript vectors describing the DOM. Keyword for the tag, a map for props, then children:
(defn greeting [name]
[:div.greeting ; <div class="greeting">
[:h1 "Hello, " name]
[:button {:on-click #(js/alert "hi")} "Wave"]])
That's <div className="greeting"><h1>Hello, {name}</h1><button onClick={...}>Wave</button></div>. No JSX transform, no template DSL — the markup is data structures your language already has, which means you manipulate views with the same map/filter/assoc you use on any other data:
(defn order-list [orders]
[:ul
(for [o orders]
^{:key (:id o)} [:li (:name o) " — $" (:total o)])]) ; map over data → children
The ^{:key ...} is React's reconciliation key, same concept as key={} in JSX.
State: ratoms Instead of useState
React's useState gives you a value and a setter that triggers a re-render. Reagent's equivalent is a reactive atom (r/atom): a mutable container of an immutable value. Deref it (@) to read; any component that derefs it re-renders when it changes.
(defn counter []
(let [n (r/atom 0)] ; created once (form-2 component)
(fn [] ; this render fn re-runs on change
[:div
[:span "Count: " @n] ; deref → this component tracks n
[:button {:on-click #(swap! n inc)} "+"]])))
The one gotcha for React devs: this is a "form-2" component — the outer let runs once (like the body of a useState setup), and the returned inner function is what re-renders. Mix that up and your atom resets every render. Past that, the model is exactly React's: local state in a ratom, shared app state in something like re-frame, which I compared to Redux here.
The Quiet Win: Immutable Data Makes Re-renders Cheap
Here's the payoff that isn't obvious until you've felt it. In React, avoiding needless re-renders means memo, useMemo, useCallback, and careful dependency arrays — because with mutable JS objects, "did this prop actually change?" can't be answered by a cheap reference check (two different objects can be structurally equal, and one mutated object is reference-equal to its old self).
ClojureScript data is immutable and persistent, so "did this change?" is a reference-equality check, and it's always correct: if the reference is the same, the value is unchanged, guaranteed. Reagent leans on this to skip re-rendering subtrees whose inputs are identical, with none of the manual memoization bookkeeping. The thing you fight in React — unnecessary renders from reference churn — mostly evaporates because equality is cheap and honest. (This is the same property that makes the big-dataset rendering techniques in the re-frame performance post work.)
Interop Is a First-Class Citizen
You do not lose the React ecosystem. You can drop any React component into hiccup with the :> adapter, passing props as a map:
(ns app.core
(:require ["react-select" :default Select] ; import an npm package
[reagent.core :as r]))
(defn country-picker [options]
[:> Select {:options options ; render a JS React component
:on-change #(js/console.log %)}])
js/console.log, (.-property obj) for field access, (.method obj args) for calls — interop with JavaScript is a normal, everyday part of the language, not an escape hatch. You reach for npm libraries when they're the best tool and write the rest in ClojureScript.
The Build: shadow-cljs
The tool that makes this pleasant for React developers is shadow-cljs, because it treats npm as a first-class dependency source — you npm install a package and require it directly. It gives you hot reloading (edit a component, see it update with state preserved) and a REPL connected to the running browser app, so you evaluate expressions live against your actual UI. Coming from a Webpack/Vite setup, the config is smaller and the npm story is smoother than you'd expect from a "different language" toolchain.
Trade-offs
Concern React (JS/TS) ClojureScript (Reagent)
----------------- ------------------------ ----------------------------
Markup JSX (transform) hiccup (plain data)
Local state useState r/atom (ratom)
Avoiding renders memo/useMemo/useCallback ~free via reference equality
npm ecosystem native native via shadow-cljs + interop
Types TypeScript dynamic (spec/malli optional)
Hiring/onboarding huge pool small pool, ramp-up needed
The Takeaway
For a React developer, ClojureScript is less of a leap than the parentheses suggest: it's the React mental model you already own, expressed in a language where immutable data is the default rather than a discipline. You trade TypeScript's static safety and a huge hiring pool for genuinely cheaper re-render correctness and a REPL-driven workflow. That's a real trade — a JS-native team shouldn't switch lightly — but if you've ever fought a wall of useMemo to tame re-renders, the ClojureScript side of that trade will feel like relief.