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.