Beyond Reagent: Migrating to React 19 with HSX and RFX

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:

About Factor House

Factor House is a leader in real-time data tooling, empowering engineers with innovative solutions for Apache Kafka® and Apache Flink®.

Our flagship product, Kpow for Apache Kafka, is the market-leading enterprise solution for Kafka management and monitoring.

Start your free 30-day trial or explore our live multi-cluster demo environment to see Kpow in action.

image

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:

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:

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:

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:

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:

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:

To understand why, consider a simple example:

HSX compiles this by creating a proxy function component that:

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.

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:

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:

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:

In this setup:

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:

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:

Profiling Kpow

We benchmarked two versions of Kpow using React’s Profiler:

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

Reagent profiling at 228 commits

HSX profiling

HSX profiling at 63 commits!

Some notes:

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:

With RFX, we took a idiomatic React approach by using Context Providers to inject isolated app environments where needed.

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:

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:

rfx-dev preview

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:

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.