Hsx
HSX is a ClojureScript library for writing React components using Hiccup syntax.
Install / Use
/learn @factorhouse/HsxREADME
HSX is a ClojureScript library for writing React components using Hiccup syntax. We believe Hiccup is the most idiomatic (and joyful) way to express HTML in Clojure.
Think of HSX as a lightweight syntactic layer over React, much like JSX in the JavaScript world.
Why HSX?
HSX is designed to offer a seamless transition from Reagent to plain, idiomatic React Function components. It’s compatible with Reagent-style Hiccup, making it trivial to migrate your existing codebase to HSX.
Unlike Reagent, HSX does not:
- Render components as classes (under the hood). HSX compiles to plain React Function components.
- Include its own state abstractions like Ratoms and reactions. Use React’s built-in state management hooks like useState.
If you want to read more about the engineering challenge of moving a 120k LOC Reagent codebase to React 19 read this blog post.
Features
- Supports React 19: hooks, effects, concurrent rendering, suspense, transitions, etc
- Hiccup Syntax: Write React components with concise, readable Hiccup expressions.
- Minimal Overhead: HSX is just a thin layer on top of React. No unnecessary abstractions or runtime complexities. No external dependencies.
- Migration-Friendly: Drop-in compatibility with Reagent-style Hiccup makes it simple to upgrade existing codebases.
Motivation
Reagent was ahead of its time, giving ClojureScript developers advanced tools like reactive atoms and declarative UI rendering. However, React has since evolved, and modern React features such as hooks and concurrent rendering are fundamentally incompatible with Reagent’s internals.
Challenges with Reagent
- React 19 Compatibility: Reagent's rendering model does not play nice with current React versions.
- Technical Debt: Continuing to depend on Reagent introduces maintenance challenges. Reagent depends on a version of React that is over three years old. Most of the React ecosystem is starting require React 18 at a minimum.
Usage
Using HSX is straightforward. The entire library is only about 300 lines of ClojureScript (with comments). HSX is designed to be as close to plain React as possible while retaining the expressive power of Hiccup and Clojure data structures.
HSX exposes two primary functions:
io.factorhouse.hsx.core/create-element- likereact/createElementbut for HSX componentsio.factorhouse.hsx.core/reactify-component- likereagent.core/reactify-component
Example
(ns com.corp.my-hsx-ui
(:require [io.factorhouse.hsx.core :as hsx]
["react-dom/client" :refer [createRoot]]))
;; This is a HSX component
(defn test-ui [props text]
[:div props
"Hello " text "!"])
(defonce root
(createRoot (.getElementById js/document "app")))
(defn init []
(.render root
(hsx/create-element
[test-ui {:on-click #(js/alert "Clicked!")}
"prospective HSX user"])))
See the examples directory for more examples.
Migrating from Reagent
If you have an existing Reagent codebase, the following reagent.core functions map to:
| Reagent | HSX |
|----------|-------------|
| reagent.core/as-element | io.factorhouse.hsx.core/create-element |
| reagent.core/reactify-component | io.factorhouse.hsx.core/reactify-component |
| reagent.core/create-element | react/createElement |
FAQs
What about performance?
When migrating from Reagent you will objectively find performance wins for your application by:
- Embracing concurrent rendering - allowing React to interrupt, schedule, and batch updates more intelligently.
- Eliminating class-based components, which Reagent relied on under the hood, removing unnecessary rendering layers (via
:f>) and improved interop with React libraries. - Fixing long-standing Reagent interop quirks — such as the well-documented controlled input hacks.
When profiling our real-world, enterprise grade product (Kpow) we saw 4x fewer commits without the overall render duration blowing out after switching to HSX. More details here.
What about Ratoms (local state)?
HSX components are just React function components under the hood with a bit of syntactic sugar.
There are no state abstractions found in this library. We suggest you migrate any Reagent components with local state to use react/useState. The useState hook is the most idiomatic way to deal with local state in React.
;; (:require ["react" :as react])
(defn reagent-component-with-local-state []
(let [state (reagent.core/atom 1)]
(fn []
[:div {:on-click #(swap! state inc)}
"The value of state is " @state])))
(defn hsx-component-with-local-state []
(let [[state set-state] (react/useState 1)]
[:div {:on-click #(set-state inc)}
"The value of state is " state]))
What about re-frame?
We have a companion library named RFX which is a drop-in replacement for re-frame without the dependency on Reagent.
See the RFX repo for more details.
What about global application state?
If RFX is overkill for your application (or you have bespoke requirements), you can use standard React solutions for global application state management like:
- useSyncExternalStore hook to subscribe to an external store (such as a plain Clojure atom or even a Datascript database).
- Solutions found in the JS ecosystem like zustand.
- Using React Reducer and Context APIs.
What about hot-reloading?
Using shadow-cljs add a reload function like:
(defn ^:dev/after-load reload []
(hsx/memo-clear!))
This will clear the component cache after a code change.
How are props handled?
Exactly the same as Reagent:
[:div {:on-click #(js/alert "Clicked!")}]
Would translate to:
[:div #js {"onClick" #(js/alert "Clicked!")}]
We use the same props serialization logic as Reagent to make migrating to HSX as pain-free as possible.
What about component metadata (keys, etc)
The same as Reagent - use Clojure metadata. Say you want to pass a React key to a component:
(defn component-with-seq []
[:ol {:className "bg-slate-500"}
(for [item items]
^{:key (str "item-" (:id item))}
[item-component item])])
What about id and className short-hands?
The same as Reagent + Hiccup:
[:div#foo ...] ;; => [:div {:id "foo"}]
[:div.foo.bar ...] ;; => [:div {:className "foo bar"}]
What about Reagent class-based components?
Class based components (the ones with lifecycle methods) have been out of style for almost a decade with React.
If you wish to adopt HSX you will need to migrate Reagent class components to function components. Generally this means rewriting the component to use hooks.
However, error boundaries are the one place in the React ecosystem where class components may be required. We suggest using a wrapping library like react-error-boundary instead.
If you still require class-based components, you can always extend js/React.Component.prototype yourself. See this gist for an example.
Are components memoized (like Reagent's implicit componentDidUpdate logic)?
By default, yes, HSX components are wrapped in a react/memo call with an appropriate arePropsEqual? predicate for ClojureScript data structures.
Note: unlike Reagent, memoization is a performance optimization. Please refer to the official React documentation for more information.
If you'd like to disable memoization by default globally, you can:
{...
:builds
{:app
{:target :browser
:modules {:app {:entries [your.app]}}
:closure-defines {io.factorhouse.hsx.core/USE_MEMO false}
}}}
If you'd like to disable/enable memoization per-component, you can supply a :memo? key as metadata to the component vector:
[:div
^{:memo? false}
[my-hsx-comp arg1 arg2]]
If you want to use a custom are-props-equal? predicate for memoization, you can also use component metadata:
;; This custom predicate treats the previous and next state as equal if the value of `:foo` has not changed.
(defn custom-are-props-equal-pred
[[prev-arg1 _prev-arg2] [next-arg1 _next-arg2]]
(= (:foo prev-arg1) (:foo next-arg1)))
[:div
^{:memo? true :memo/predicate custom-are-props-equal-pred}
[my-hsx-comp a
