Skip to content
rxfy

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.

Get started →

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.

How many invalidation edges you maintain

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.

Invalidation edges for each combination of writes on an entity and cache keys holding it
W ↓K →1234681012
11234681012
2246812162024
33691218243036
448121624324048
6612182436486072
8816243248648096
10102030406080100120
12122436487296120144
Every cell is W × K and nothing else — no fitted curve, no assumption about how one grows with the other. Find your own app on the grid: eight views of an entity with six writes on it is 48 relationships to wire by hand and keep in your head.

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.

Loading diagram...

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 Atom is an Observable with synchronous get() and set(), and entity cells, state data$, 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 Pending boundary 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 StoreKey rather 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