Introduction
Reagent is a popular library that enables ClojureScript developers to write clean and concise React components using simple Clojure data structures and functions. Reagent is commonly used with re-frame, a simlarly popular ClojureScript UI library.
Both libraries have been fundamental to how we build modern, sophisticated, accessible UI/UX at Factor House over the past seven years.
Now, after 121 product releases, we have replaced Reagent and re-frame in our products with two new libraries:
- HSX: a Hiccup-to-React compiler that lets us write components the way we always have, but produces pure React function components under the hood.
- RFX: a re-frame-inspired subscription and event system built entirely on React hooks and context.
About Factor House
Factor House is a leader in real-time data tooling, empowering engineers with innovative solutions forw Apache Kafka® and Apache Flink®.
Our flagship product, Kpow for Apache Kafka, is the market-leading enterprise solution for Kafka management and monitoring.
Explore our live multi-cluster demo environment or grab a free Community license and dive into streaming tech on your laptop with Factor House Local.
The Problem
Our front-end tech stack was starting to accumulate technical debt, not from the usual entropy of growing software, but from the slow diversion of Reagent from the underlying Javascript library that it leverages - React.
In many ways, Reagent was ahead of its time. Simple state primitives (the Ratom), function components, and even batched updates for state changes were innovations Reagent offered well before React itself. It provided a remarkably elegant abstraction that made building UIs in ClojureScript a joy.
But it’s now 2025. React has caught up and in many areas surpassed those early innovations. Hooks offer state management. Concurrent rendering and built-in batched updates are first-class features. While it took React a decade to reach this point, the landscape has undeniably shifted.
Unfortunately, Reagent hasn’t kept pace. Its internals are built around class-based components and are increasingly at odds with React's architecture. Most critically for us, Reagent is fundamentally incompatible with React 19’s new internals.
This incompatibility created serious technical debt for us at Factor House. More and more of our vital front-end dependencies, from libraries for virtualized lists to accessible UI components, are starting to require React 18 or 19. Without a way forward, we risked stagnation.
However, we deeply value what Reagent and re-frame gave us - a simple, expressive syntax based on Hiccup, and a clean event-driven model. We didn’t want to abandon these strengths. Instead, we chose to move forward by building new libraries, ones that preserve the spirit of Reagent and re-frame and modernize their foundations to align with today's React.
In this post, we'll walk you through why we had to move beyond Reagent and re-frame, how we built new libraries to modernize our stack, and the real-world outcomes of embracing React 19's capabilities.
The Migration Challenge
Our goal wasn’t to rewrite our entire front-end stack, but to modernize it. That meant preserving two things that serve us well:
- The ability to write React components using Hiccup-style markup, which we now call HSX.
- Continued use of re-frame’s event and subscription model.
At the same time, we wanted to align ourselves much more closely with React’s internals. We were ready to fully embrace idiomatic React. That meant we were happy to let go of:
- Ratoms — in favor of React’s native
useState
,useEffect
, anduseReducer
primitives. - Class-based components — which are no longer relevant in a hooks-first React world.
Choosing to move away from Reagent’s internals, especially Ratoms, was not a loss. To us Ratoms were always an implementation detail. Since we already manage app state through re-frame subscriptions, local component state was minimal.
So the real migration challenge became this:
Could we capture the spirit of Reagent and re-frame — using nothing but React itself?
And if we could, would the resulting behavior and performance match (or exceed) what we had before?
With these in hand, we were ready to test them where it matters most: against our real-world products. Both Kpow for Apache Kafka and Flex for Apache Flink are complex, enterprise-grade applications. Could HSX and RFX support them without regressions? Could we maintain backward compatibility, migrate incrementally, and still unlock the benefits of React 19?
These were the questions we set out to answer, and as we’ll see, they led to some surprising and exciting results.
Migrating Kpow and Flex
We began by sketching out minimal viable implementations of HSX and RFX — enough to prove the migration path could work.
HSX: Building a Modern Hiccup-to-React Layer
For HSX, the first goal was essentially to reimplement the behavior of reagent.core/as-element
. We required:
- The same props and tag SerDes logic as Reagent.
- Special operators like Fragments (
:<>
) and direct React component invocation (:>
). - Memoization semantics — essentially replicating Reagent’s implicit
shouldComponentUpdate
optimization.
This would allow us to preserve the developer experience of writing Hiccup-style components while outputting React function components under the hood.
RFX: Reimagining re-frame on Pure React Foundations
Migrating re-frame was more challenging because of its much larger API surface area. We needed to implement:
- Subscriptions, events, coeffects, effects — the full re-frame public API.
- A global state store compatible with React Hooks.
- A queuing system for efficiently processing dispatched events
Implementing functions like reg-event-db
and subscribe
was straightforward. The bigger challenge was syncing global state changes into the React UI without relying on Ratoms and 'reactions'.
To solve this, we initially deferred a custom solution and instead leaned on a battle-tested JavaScript library: Zustand.
For event queuing, we adapted re-frame’s own FIFO router, which was pleasantly decoupled from Reagent internals and easily portable.
First Steps in Production: Tweaking Kpow and Flex
With early versions of HSX and RFX in hand, we moved quickly to integrate them into our products. The migration required surprisingly few code changes at the application level:
- Replacing
reagent.core/atom
withreact/useState
where needed (thankfully very few places). - Replacing
reagent.core/as-element
calls withio.factorhouse.hsx.core/create-element
. - Replacing
react/createRef
calls withreact/useRef
. - Updating the entry points to use the
react-dom/client
API (createRoot
) instead of the legacyrender
method. - Introducing a
re-frame.core
shim namespace over RFX, mapping 1:1 to the re-frame public API and requiring no migration for event handlers or subscriptions!
With these adjustments in place, and some rapid iteration on HSX and RFX, we were able to compile and run Kpow (our larger application at ~60,000 lines of ClojureScript) entirely on top of React 19!
The first results were rough: performance was poor and some pages failed to render correctly.
But critically, the foundation worked and these early failures became the catalyst for aggressively refining and productionizing our libraries.
Optimizing HSX: Learnings Along the Way
As we moved toward a pure React model, we found ourselves learning a lot more about React’s internals. Sometimes the hard way.
The biggest issue we faced stemmed from React’s reliance on referential equality. In React, referential equality (whether two variables point to the same object in memory) underpins how React identifies components across renders and how it optimizes updates, handles memoization, etc.
This presented a fundamental problem for HSX:
Just like Reagent, HSX creates React elements dynamically at runtime (when io.factorhouse.hsx.core/create-element
is called).
Unlike other ClojureScript React templating libraries, we do not rely on Clojure macros to precompile Hiccup into React elements.
We quickly encountered several major symptoms:
- Component memoization failed: React could not track components properly across renders.
- Hook rule violations: Clicking around the app often triggered Hook violations, a sign that React's internal assumptions were being broken.
- Internal React errors: Most concerning was the obscure: Internal React error: Expected static flag was missing.
To understand why, consider a simple example:
(defn my-div-component [props text] [:div props text])
HSX compiles this by creating a proxy function component that:
- Maps React’s
props
object to a Reagent-style function signature. - Compiles the returned Hiccup (
[:div props text]
) into a React element viareact/createElement
.
The problem is that a new intermediate proxy function is created between renders, even if the logic is identical.
React, relying on referential equality, treated each instance as a brand-new component, thus resulting in the above bugs.
The Solution: WeakMap-Based Caching
Our solution was a simple but powerful idea: cache the translated component functions using a JavaScript WeakMap.
- Keys: the user-defined HSX components (e.g.,
my-div-component
), which have stable memory references. - Values: the compiled React function components.
Using a WeakMap was essential, without it the cache could grow unbounded if components created new anonymous functions every render.
WeakMaps automatically clean up entries when keys (functions) are garbage collected.
However, this approach revealed a secondary problem: Higher-Order Components (HOCs).
The Hidden Trap: Anonymous Functions and HOCs
When users define anonymous functions inside render methods, React treats them as Higher-Order Components.
Example:
(defn my-complex-component [props text] (let [inner-component (fn [] [:div props text])] [inner-component]))
In this case, inner-component
is redefined every render, breaking referential equality, exactly the problem we had just solved. This exact issue is even highlighted in the legacy React docs.
To address this, we added explicit logging and warnings whenever HSX detected HOC-like patterns during compilation.
This forced us to clean up the codebase by refactoring anonymous components into top-level named components.
Unexpectedly, this not only improved correctness but significantly improved performance.
Even when using Reagent previously, anonymous functions inside components had led to unnecessary re-renders, an invisible cost that we were now able to eliminate.
Optimizing RFX: Learnings Along the Way
The challenge with RFX was twofold:
- Without Ratoms, how would we sync application state to the UI efficiently and correctly?
- How could we faithfully reimplement re-frame’s subscription graph, ensuring minimal recomputation when parts of the database change?
Signal Graph: Re-frame’s Core Innovation
In re-frame, subscriptions form a DAG called the signal graph.
Subscriptions can depend on other subscriptions (materialised views), and on each state change, re-frame walks this graph and only recomputes nodes where upstream values have changed.
For example:
;; :foo will update on every app-db change (reg-sub :foo [db _] (:foo db)) ;; :consumes-foo will update only when the value of :foo changes (reg-sub :consumes-foo :<- [:foo] (fn [foo _] (str "Consumed foo")))
In this setup:
:foo
listens directly to the app-db and updates on every change.:consumes-foo
listens to:foo
and only recomputes if:foo
’s output changes, not just because the db changed.
This graph-based optimization is a key reason re-frame scales so well even in complex applications.
useSyncExternalStore: The Missing Piece
Fortunately, React 18+ provides a new primitive that fits our needs perfectly: useSyncExternalStore.
This hook allows external data sources to integrate cleanly with React. We used this to wrap a regular ClojureScript atom, turning it into a fully React-compatible external store.
On top of this, we layered the store's signal graph logic: fine-grained subscription invalidation and recomputation based on upstream changes.
Accounting for Differences
With HSX and RFX at a production-grade checkpoint, it was time to audit Kpow’s functionality and identify any performance regressions between the old (Reagent-based) and new (React 19-based) implementations.
As we touched on earlier, the key architectural difference was relying on React’s batched updates instead of Reagent’s custom batching system.
Up to this point we had Kpow running on HSX and RFX without any structural changes to our view or data layers. We had effectively the same application, just running on a new foundation.
Our Only Regression: Data Inspect
We noticed only one major area where performance regressed after the migration: our Data Inspect feature.
Data Inspect is one of Kpow’s most sophisticated pieces of functionality. It allows users to query Kafka topics and stream results into the browser in real-time. Given that Kafka topics can contain tens of millions of records, this feature has always demanded a high level of performance.
We observed that when result sets grew beyond 10,000 records, front-end performance degraded when a user clicked the "Continue Consuming" button to load more data.
Root Cause: Subscriptions vs Component Responsibility
Upon investigation, the root cause was clear: we were performing sorting operations inside a re-frame subscription.
Because React’s batched update model differs subtly from Reagent’s, this subscription was being recomputed more frequently as individual records streamed in from the backend.
Each recomputation triggered an expensive sort over an increasingly large dataset. Under the old model (Reagent), our batched updates masked some of this cost. Under React’s model, these inefficiencies became more visible.
Solution: Move Presentation Logic to Components
The fix was simple and logical:
- Sorting and other presentation-specific logic were moved out of the re-frame database layer and into the UI components themselves using local state.
- Components could now locally manage view-specific transforms on the shared data stream, without polluting the central app-db or affecting unrelated views.
- This also better modeled reality: multiple queries might view the same underlying result set with different sort preferences or filters.
This change not only solved the performance regression, but improved architectural clarity, separating global application state from local view presentation.
Features like data inspect could be better served by React APIs like Suspense.
Figuring out how newer React API features like suspense and transitions fit into HSX+RFX is part of ongoing research at Factor House!
The Outcome: Better Performance, Better Developer Experience
Performance Improvements
We saw performance gains across several dimensions:
- Embracing concurrent rendering in React 19 allowed React to interrupt, schedule, and batch rendering more intelligently — especially critical in data-heavy UIs.
- Eliminating class-based components, which Reagent relied on under the hood, removed 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 gave us more predictable form behavior with fewer workarounds.
- Removing all use of Higher-Order Components (HOCs), which had previously introduced subtle performance traps and referential equality issues.
Profiling Kpow
We benchmarked two versions of Kpow using React's Profiler:
- Kpow 94.1: Reagent + re-frame + React 17
- Kpow 94.2: HSX + RFX + React 19
The result: HSX+React19 led to overall fewer commits.
With both versions of the product observing the same Kafka cluster (thus identical data for each version), we ran a simple headed script in Chrome navigating through Kpow's user interface.
We found that HSX resulted in a total of 63 commits vs Reagent's 228 commits:
Reagent profiling at 228 commits
HSX profiling at 63 commits!
Some notes:
- The larger yellow spikes of render duration in both bar charts were roughly identical (around ~160ms)
- These spikes relate to the performance of our product, not Reagent or HSX. This is something we need to improve on!
- This isn't the most scientific test, but seems to confirm that migrating to React19 has resulted in overall less commits without blowing out the render duration.
- See this gist on how you can create a production React profiling build with shadow-cljs.
Developer Experience
We also took this opportunity to address long-standing developer pain points in Reagent and re-frame, especially around testability and component isolation.
Goodbye Global Singletons
Re-frame's global singleton model, while convenient, made it hard to:
- Isolate component state in tests
- Run multiple independent environments (e.g., previewing components in StorybookJS)
- Compose components dynamically with context-specific state
With RFX, we took a idiomatic React approach by using Context Providers to inject isolated app environments where needed.
(defmethod storybook/story "Kpow/Sampler/KJQFilter" [_] (let [{:keys [dispatch] :as ctx} (rfx/init {})] {:component [:> rfx/ReactContextProvider #js {"value" ctx} [kjq/editor "kjq-filter-label"]] :stories {:Filter {} :ValidInput {:play (fn [x] (dispatch [:input/context :kjq "foo"]) (dispatch [:input/context :kjq "bar"]))}}}))
Here, rfx/init
spins up a completely fresh RFX instance, including its own app-db and event queue, scoped just to this story.
Accessing Subscriptions Outside a React Context
One of the limitations we frequently ran into with re-frame was the inability to easily access a subscription outside of a React component. Doing so often required hacks or leaking internal implementation details.
But in real-world applications, this use case comes up more than you might expect.
For example, we integrate with CodeMirror, a JavaScript-based code editor that lives outside of React’s render cycle. Within CodeMirror, we implement rich intellisense for several domain-specific languages we support including kSQL, kJQ, and Clojure.
These autocomplete features often rely on data stored in app-db
. But much of that data is already computed via subscriptions in other parts of the application (materialized views). Re-computing those values manually would introduce duplication and potential inconsistency.
Another example: when writing complex event handlers in reg-event-fx
, it's often useful to pull in a computed subscription value (using inject-cofx
) to use as part of a side-effect or payload.
With RFX, this problem is solved cleanly via the snapshot-sub
function:
(defn codemirror-autocomplete-suggestions [rfx] (let [database-completions (rfx/snapshot-sub rfx [:ksql/database-completions])] ;; Logic to wire up codemirror6 completions based on re-frame data goes here ...))
This gives us access to the latest materialized value of a subscription without needing to be inside a React component. No hacks, no coupling, just a clean, synchronous read from the store.
It's a small feature, but one that has made a big impact on the architecture of our side-effecting code.
Developer Tooling
As a developer tooling company, it should come as no surprise that we're also building powerful tools around these new libraries!
Following from our earlier point about isolated RFX contexts, this architectural shift unlocked an entirely new class of debugging and introspection capabilities, all of which we're packaging into a developer-focused suite we're calling rfx-dev
.
Here’s what it can do, all plug and play:
Subscription Inspector
See which components in the React DOM tree are using which subscriptions, including:- How often they render
- When they last rendered
- Which signals triggered them
Registry Explorer
A dynamic view of:- Subscriptions
- Registered events
- Their current inputs and handlers
Profiling Metrics
Measure performance across the entire data loop:- Event dispatch durations
- Subscription realization and recomputation timings
Event Log A chronological record of dispatched events - useful for debugging complex flows or reproducing state issues.
Interactive REPL
Dispatch events, subscribe to signals, and inspect current app-db state in real-time - all from the browser.Time Travel Debugging
Snapshot, restore, and export app-db state - perfect for debugging regressions or sharing minimal reproduction cases.Live Signal Graph
A visual, interactive graph of your subscriptions dependency tree.
See how subscriptions depend on one another and trace data flows across your app in real-time.
rfx-dev
is still a work-in-progress but we’re excited about where it’s heading. We hope to open-source it soon. Stay tuned! 🚀
Summary
What began as a necessary migration became an opportunity to radically improve our front-end stack.
We didn’t just swap out dependencies, we:
- Preserved what we loved from Reagent and re-frame: Hiccup and a data-oriented event and subscription model.
- Dropped what was holding us back: class-based internals, ratoms, global singletons.
- Aligned ourselves with idiomatic React: hooks, context, and newer API features.
HSX and RFX are more than just drop-in replacements, they’re the result of over a decades experience working in ClojureScript UIs - rethought for React’s present and future.
After adopting these libraries we find our UI snappier and our code easier to test and reason about. Our team is better equipped to work with the broader React ecosystem, no compromises or awkward interop. Our intent is to continue to hold close to React as the underlying library evolves further in the future.
For years, the Reagent + re-frame stack was the gold standard for building reactive UIs in ClojureScript and many companies (like ours) adopted it with great success. We know we're not alone in experiencing the issue of migrating to React 19 and beyond, if you find yourself in the same boat let us know if these libraries help you.
HSX and RFX are open-source and Apache 2.0 licensed, we're hopeful they contribute some value back to the Clojure community.