rxfy
Typed, normalized, reactive state — built on RxJS
rxfy (/ɑɹ ɪks faɪ/) is a reactive data-flow layer for your React app: declare typed models, states, and normalized stores, and scale from a client-only store to a fully synced app with server-side rendering and real-time sync via websockets. It's built for consistency and granular RxJS-based reactivity at no extra cost.
The problem
Render one piece of data in two components and keep them in agreement. Now render it in six — a list row, that same list filtered, a detail panel, a sidebar counter, a search result — with two people editing it at once, and the job grows into a large share of what your app does. Much of the code you write around fetching exists only to keep those copies in sync.
Tools like TanStack Query handle this by invalidation: after a mutation you invalidate the affected query keys and refetch, and you have to get every key right, every time. Count the cost: K cache keys hold the same entity, W writes touch it, and every write has to be reconciled against every key — so the wiring you maintain is W × K.
An edge is one (write, cache key) pair: a place where a mutation has to remember to invalidate a cache that holds the same entity. Miss one and a view goes stale.
| W ↓K → | 1 | 2 | 3 | 4 | 6 | 8 | 10 | 12 |
|---|---|---|---|---|---|---|---|---|
| 1 | 1 | 2 | 3 | 4 | 6 | 8 | 10 | 12 |
| 2 | 2 | 4 | 6 | 8 | 12 | 16 | 20 | 24 |
| 3 | 3 | 6 | 9 | 12 | 18 | 24 | 30 | 36 |
| 4 | 4 | 8 | 12 | 16 | 24 | 32 | 40 | 48 |
| 6 | 6 | 12 | 18 | 24 | 36 | 48 | 60 | 72 |
| 8 | 8 | 16 | 24 | 32 | 48 | 64 | 80 | 96 |
| 10 | 10 | 20 | 30 | 40 | 60 | 80 | 100 | 120 |
| 12 | 12 | 24 | 36 | 48 | 72 | 96 | 120 | 144 |
Problem deconstruction
The root cause is one thing: your app holds many copies of the same entity. The list has one, the detail view fetched another, the cache may hold a third, and every write has to find and update all of them. Add a second client and the copies span the network too.
Your database does not have this problem. Each row is one piece of data, kept in one place — a single source of truth. The question is how to carry that property up to the client, in a framework that:
- keeps maintenance cost flat as the number of views grows
- has one shared definition per model, not one for the server and another for the client
- keeps every view of an entity up to date after a write, and propagates to all connected clients with no manual invalidation
- re-renders only the components whose data actually changed
- has first-class SSR support, so the app stays cacheable and visible to search engines and agents
Solution overview
rxfy stores each entity once, in a normalized store keyed by its id, and every view references that slot instead of holding a copy. One write reaches every subscriber, and the same declaration carries the client, the server, and the wire.
Client-side
Declare an entity model and store each entity in its own slot of the model store, addressed by its id. Compose a state from those models — each page has its own — and derive it as an observable, using RxJS operators to shape it for any view. Live updates flow into the same store: a patch updates the entity's slot and every subscriber re-renders with no refetch, while a create or delete marks every state that lists it stale, ready to sync when you choose.
Server-side
Bind each model to its database table with a resource, so the server reads and writes the same model the client renders. On a request it fills the page's state and serializes it into the HTML; the client restores that snapshot on hydration and skips the fetch, which makes SSR free. Writes go through the resource — an update publishes a patch on the entity's topic, a create or delete marks the affected state channels stale — and the server signs a grant per served state, so each client subscribes to exactly the entities it was handed and no others.
Core principles
The insight above unfolds into the principles rxfy is built on:
- Everything is a data stream. rxfy builds on RxJS rather than inventing a
reactivity system of its own: an
Atomis anObservablewith synchronousget()andset(), and entity cells, statedata$, and lenses are all streams. The whole operator library composes over your app state, a derived value defined once recomputes itself whenever its source changes, and a websocket push is just another stream flowing into the same cells. - Data normalization. Each entity lives once, in a single normalized slot keyed by its id, and every view references that slot rather than holding its own copy. There is no second copy to drift out of sync, and a change has exactly one place to happen.
- Late unwrapping. Async data travels wrapped in its loading status, and only the leaf that
renders a value unwraps it — each
Pendingboundary re-renders only when its entity changes. The narrower the unwrap, the narrower the re-render — see Late Unwrapping. - No model copies. Each model shape and each state is declared once and used on both sides. The server fills a state and serializes it; the client restores that snapshot and skips the fetch, so SSR reuses the same declaration — no DTO layer, no client-only type, no separate SSR path.
- Static validation. Every model, state, param, and mutation is typed, so a wrong shape is a
compile error rather than a production incident. The store is keyed by a branded
StoreKeyrather than a bare id, and because each model is a Zod schema, the same declaration validates the data at runtime.
Why RxJS?
Streams are not one principle among five — they are what the others are built out of. Normalized entities are cells you can subscribe to, late unwrapping needs a value that stays wrapped on the way down. Three things come with that, and a plain store gives none of them. Derived values declared once, with no dependency array to maintain and no waiting for the data to unwrap; a re-render scope that narrows to the leaf; and individual per-stream emit schedule that escapes React's single render loop, where a debounce or a retry stays local to its own stream.
The cost is that RxJS is a mental model, not a dependency you hide behind an API. Someone who only
renders data can stay inside useAtom and
never meet an operator; whoever writes the derivations has to think in streams. Weigh that against
what a data-intensive codebase already contains by hand — the stale-response ref, the abort
controller, the debounce hook, the subscription cleanup and other edge cases.
Alternative approaches
None of these principles are new on their own — normalized stores, observable state, SSR, and real-time sync all exist elsewhere. rxfy holds all of them behind one declaration. TanStack Query keys its cache by response and leaves invalidation to you; Redux Toolkit normalizes but notifies through selectors rather than streams; Jotai and MobX are client-state primitives with no normalization and no shared server declaration; sync engines stream normalized changes too, but ask you to move your schema and hosting onto their platform, where rxfy layers onto the database and API routes you already run. The comparison covers where each one fits.
Continue with
- Getting Started — install rxfy and add levels as you need them: a client-only store, then SSR, then a fully synced app.
- Using rxfy with the agent skill — give your AI coding assistant accurate rxfy context.
- Comparison with other libraries — how rxfy relates to TanStack Query and other libraries.