Solving data-intensive React apps

Keeping the same data in sync across many views is where data-intensive React apps get hard. Here's the problem, and the single-source-of-truth solution I built.

July 25, 2026

I have been building React apps since 0.14 version and many apps I've seen are coping with the same old problem: keep data up-to-date is difficult.

Here I will focus on problem and solution to this problem. But it's worth to mention that not every app can have this problem. It is the kind of app that renders a lot of data and this data has to be updated frequently.

Let's call them as "data-intensive" apps and solving data-intensive apps will be the goal of this article.

The problem.

Render one piece of data in two components and try to keep them in agreement. Render it in six places, with two people editing it in live, and the job grows into a large share of what the app does. Much of the code you write around fetching is there to keep those places in sync.

Modern day approaches like react-query handle this case very poorly: you must invalidate queries by hand and look carefully that each invalidation happens at the right time.

It helps to count what that actually costs. Call K the number of cache keys holding the same entity — one per view: a list, the same list filtered, a detail page, a sidebar count, a search result. Call W the number of writes that touch that entity: create, update, delete and any write on a related entity that changes its shape. Every write has to be reconciled against every key, so what you wire up and keep in your head is W × K.

Normalization is the solution: one slot per entity, keyed by its id, referenced by every view instead of copied into it. With a single copy there is nothing to reconcile, and W × K collapses to nothing. And this is not specific to react-query — every modern solution that skips normalization carries the same graph, whatever its API looks like. Some libraries give normalization to you by default, some only if you build and maintain the layer yourself, and some not at all — I keep the details in a comparison of the usual tools.

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
↓ writes on the entitycache keys holding it →
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.

This is what I mean by non-linear. It is not that any single invalidation is hard — it is that you have to hold all of them at once, and the pile grows faster than the app does.

Problem deconstruction.

First, we should identify the root cause of these problems

There are too many instances of same piece of data.

Single source of truth is the core principle that we need to follow in order to make our life easier.

Your database is already a single source of truth: each row is one piece of data, kept in one place. The question is how to carry that same properly up to the client, in a framework that:

  • keeps maintenance cost flat as the number of views grows
  • has only 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 propagate 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 is cacheable and visible by agents and search engines

These are the requirements that I've been carrying for many years during my career and I did many attempts to build such.

And I think I've build one. I always wanted to name it rxfy.

Solution overview.

One write routes through the server and lands in every client's store

Client-side.

Define entity model and store each entity in separate slot of model store. Make it as single source of truth accessed by its unique id.

Define and compose state with predefined entity models. Make this state shared between server and client-side so we can have server-side rendering at no cost.

Each page has it's own state.

Derive page's state as observables and use RxJS operators to fit it to any kind of view or use-case.

Live updates flow into that same store. A patch updates the entity's slot, and every subscriber re-renders with no refetch. A create or delete raises an "updates available" count you apply when ready.

Server-side.

Bind each entity model to its database table with a resource. The server reads and writes the same model the client renders, so the entity is defined once for both sides.

Query the database and fill the page's state on the server, then serialize it into the HTML. The client restores that snapshot during hydration and skips the fetch, which makes server-side rendering free.

Route writes through the resource. An update publishes a patch on that entity's topic; a create or delete marks the affected state channels stale instead.

Sign a grant per served state and hand it to the client with the data. The client subscribes only to the entities in that state, nothing more, so a patch reaches the clients rendering that entity and no one else.

Core principles

It is important to know that rxfy rests on a few rules. Everything above follows from them.

Everything is a data stream. Every value is an observable stream, built on RxJS. Data pushes to whoever listens and composes with the operators. You define a derived value once as a transform of its source, and it recomputes itself whenever the source changes, so nothing is updated explicitly. This is the one principle that costs you something to adopt, and the next section is about why I think it pays.

Data normalization. Each entity lives once, in a single slot keyed by its id, and every view references that slot instead of 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. Re-render scope follows unwrap scope: unwrap at the leaf, and one entity changing touches one component.

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 into the page; the client restores that snapshot and skips the fetch. There is no second definition to drift out of sync: 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, not a production incident. Access data from stores with type-safe branded entity references. No margin for making a mistake.

Why RxJS?

The fair objection to everything above is that none of it obviously needs RxJS. But streams are not one principle among the five — they are what the others are built out of. Normalized entities are cells you can subscribe to, and late unwrapping needs a value that stays wrapped on the way down. Three more things come with that, and a plain store gives none of them.

Derived data is declared, not recomputed. A view rarely wants the entity as stored — it wants it filtered, sorted, joined with an author, counted. A useMemo already does this job, and a stream does the same one: cache a derived value, recompute it when the inputs change. Two things differ. There is no dependency array — the inputs are whatever you piped from, named once in the code that uses them, so there is no second list to maintain. And a memo can only run on data that has already unwrapped, while a stream derives over the wrapped observable, so the shape is declared before the data exists.

Render scope collapses to the leaf. Without streams you select the data from the store and unwrap it in the component's hook section — so any change re-renders that component and everything beneath it. A stream keeps the value wrapped on the way down and unwraps at the leaf that renders it. That is what late unwrapping means in practice, and it is only possible because the value is a stream.

Every stream keeps its own emit schedule. With hooks React's single render loop is your scheduler: async coordination has to be written as effects that run after a render, and every intermediate value you did not want still costs one. Streams escape that loop, and each one is scheduled on its own. Filter a list quickly and the requests for a, ab, abc overlap — abc returns first, ab lands after it, and the view ends up showing a query the user has already moved past. switchMap drops the stale response before React hears about it at all. Debounce one param, retry one load, coalesce a firehose of live patches into a single render per frame — each of those is local to its own stream and changes nothing about how the rest of the app renders.

And the cost is: RxJS is a mental model, not a dependency you can hide behind an API. There are new things to learn — the operators, hot/cold observables distinction and other stuff. Someone who only renders data can stay inside useAtom and Pending and never meet an operator, but whoever writes the derivations has to think in streams.

What makes me think it is worth paying is the honest comparison. It is not streams against nothing. Every data-intensive React codebase I have worked in already contains a hand-rolled, partial, slightly buggy subset of RxJS: the stale-response ref, the abort controller, the debounce hook, the unmount guard, the subscription cleanup everyone forgets once. More to learn up front, less to hold in your head afterwards — that is the trade this whole library makes.

Alternative approaches.

None of these principles are new on their own. Streams, normalized stores, one declaration for both sides, real-time sync: each lives somewhere already. What no other tool does is hold all of them at once. Here is the principle each of the usual tools drops once the app gets data-intensive.

TanStack Query is the closest, and for most apps still the right call. It shares the server-state focus and ships SSR, but it keeps copies: it keys the cache by response, so the same entity in two queries lives in two entries a mutation has to invalidate and refetch to reconcile. That is normalization it lacks. Its data also arrives as snapshots rather than streams, so nothing derived recomputes itself and real-time is yours to wire, one setQueryData per key.

Redux Toolkit can normalize, but it reaches subscribers by re-running selectors over one store, not by pushing from a stream. And the shared server-client declaration, the runtime validation, and the SSR wiring stay separate pieces you assemble yourself.

Jotai and MobX are primitives for local and client state. They give reactivity of a kind, but no normalization, no single declaration across server and client, no late unwrapping. Reach for them for the state rxfy leaves alone.

Sync engines (Convex, Electric SQL, Zero, Replicache) come closest, and are the interesting case: they do stream normalized changes into the client, most of these principles at once. The difference is what you adopt. A sync engine is a database decision: your schema, your queries, and often your hosting move onto its platform. rxfy layers onto what you already run. defineResource binds a table you own, writes stay in your own API routes, and you can put one resource behind sync and leave the rest fetch-only.

What's the catch?

I need to mention that rxfy is not free, and it is not for every app. The learning curve is covered above; two more costs are worth stating plainly:

It is not for landing pages or small apps. You declare models, ids, and states before you render anything. For landing page or simple small app, that's you'll never be paid back for.

It's young and have just 1 maintainer. TanStack Query has years of hardening, a huge community, and answers to your exact problem already on Stack Overflow. rxfy has none of that yet. You'd be adopting a small library.

What it looks like

It is not a tutorial but just enough code to see the principles in action; the rxfy docs cover the full API. Follow one entity, a post, through the whole thing.

You declare it once: the model, and a page's state over it.

import { array, createModel, defineState } from "rxfy";
import { z } from "zod";

const PostSchema = z.object({
  id: z.string(),
  title: z.string(),
  body: z.string(),
});

const postModel = createModel({
  schema: PostSchema,
  getKey: (p) => p.id,
  name: "post",
});
const postsState = defineState({
  key: "posts",
  params: z.object({ category: z.string() }),
  model: { posts: array(postModel) },
});

The state hands you ids, never entities. The entity comes from the one shared store, resolved by its id at the leaf that renders it:

import { Pending, useAtom, useModelStore, useStateData } from "rxfy-react";
import { postModel, postsState } from "./posts";

function PostsList({ category }: { category: string }) {
  const { data$ } = useStateData({
    state: postsState,
    params: { category },
    fetchFn: ({ category }) => fetch(`/api/posts?category=${category}`).then((r) => r.json()),
  });

  return <Pending value$={data$}>{({ posts }) => posts.map((id) => <PostItem key={id} id={id} />)}</Pending>;
}

function PostItem({ id }) {
  const [post] = useAtom(useModelStore(postModel).get(id));
  return <h3>{post.title}</h3>;
}

That indirection — ids in the state, the entity in the store — is the single source of truth made concrete. Six components can render id; there is still one post.

On the server the same model binds to its table, and a write goes through it:

import express from "express";
import { z } from "zod";
import { createInMemoryHub, createSync } from "rxfy-server";
import { defineResource, drizzleStorage } from "rxfy-server-drizzle";
import { postModel } from "./posts";
import { db, posts } from "./db";

const sync = createSync({
  storage: drizzleStorage(db),
  hub: createInMemoryHub(),
  secret: process.env.RXFY_SECRET!,
});

const postResource = defineResource({
  table: posts,
  model: postModel,
});

const app = express();
app.use(express.json());

const patchBody = z.object({ title: z.string().min(1) });

app.patch("/posts/:id", async (req, res) => {
  const parsed = patchBody.safeParse(req.body);
  if (!parsed.success)
    return res.status(400).json({
      error: parsed.error.flatten(),
    });

  // one line updates the row and pushes the patch to every subscriber
  const post = await sync.update(postResource, req.params.id, parsed.data);
  if (!post)
    return res.status(404).json({
      error: "post not found",
    });

  res.json(post);
});

That one line is the whole payoff. The patch lands in the store slot, and every subscriber rendering that id — on this client and every other one — re-renders. No invalidateQueries, no setQueryData per key, no refetch. The thing I spent years writing by hand is the thing I no longer write.

In conclusion

I think we are still far from solving the frontend once and for all. Different kinds of apps still require different solutions. My passion has always been data-intensive apps, and I believe I've built a solution that solves them — at least for me. I'll keep growing this library, and with a community of builders, I hope we'll make it even better.