Write the view,
flow the change.
A table and the stream of changes that built it are the same thing in two forms. A view is just a table derived from another — a filter, a count, a grouping. Put those two ideas together and you can write the view you actually mean, then let only the change flow through it: the readability of recompute, the cost of hand-tuned deltas, and none of the bookkeeping.
A table and its changes are the same thing
Watch any table over time and what you are really watching is a stream of changes: a row inserted, a field updated, a row removed. Apply that stream in order and you get the table back. The two are duals — a table is its changes accumulated; the changes are the table differentiated. Neither is more real than the other; they are one thing in two shapes.
And a change is a small, literal thing. It names what moved and where — nothing more:
{ type: 'insert', at: '7', value: { region: 'west', active: true, value: 74 } }
{ type: 'update', key: ['5', 'active'], value: false } // row #5 went inactive
{ type: 'remove', key: ['3'] } // row #3 is gone
Below is the same data in both forms. On the left, the real change records the
runtime emits — not a hand-drawn imitation, the actual deltas. On the right,
the orders table they add up to. Drag the playhead to apply more
or fewer of them; press a button to edit the table and watch the change it
produces appear on the left. Apply the changes and you get the table;
touch the table and you get a change.
ordersEverything else on this page is a view derived from that table — a filter, a count, an average — and each stays correct as the changes flow. Here are three. The diagram is how they derive from one another and from the table; the code is all you write; the panels below are their live values:
const active = orders.filter(o => o.active)
const perRegion = active.length(o => o.region)
const avg = orders.avg('value')
orders.filter(o => o.active)active.length(o => o.region)orders.avg('value')You wrote three one-line derivations. You did not write a single line that handles a change. That gap — between the view you declare and the change-handling you'd otherwise maintain by hand — is the subject of this essay.
A view is a table derived from another
orders.filter(o => o.active) reads like a one-off: run the
filter, get a list. It isn't. The list it hands back is a live view —
a new table, defined as a derivation of the first, that the runtime keeps in
step with orders. Add an active order and it appears in
active; toggle one off and it leaves. You never re-run the filter; the
relationship you wrote once simply stays true.
This is where the duality starts to pay. A derivation between tables —
active from orders — casts a shadow: a derivation between
their changes. filter doesn't only map a table to
a table; it maps a change in orders to a change in
active, or to nothing at all. So the runtime has two ways to keep
active current — and, as the next section shows, they cost wildly
differently.
const orders = $({…}) // keyed by id, like the table above
const active = orders.filter(o => o.active) // a live view, not a one-off
// read `active` any time — it is always the current subset. You wrote the
// relationship; the runtime keeps it true as `orders` changes.
Flow the change, not the table
When one order changes, how do active, perRegion, and avg stay current? The obvious way is to recompute them — re-derive each view over every row. Below, both strips are the same table of N rows: one change touches the green tick; recompute re-scans the red. Raise N and watch what each approach has to touch.
Recompute is O(N): its cost climbs in lockstep with the table —
every row, every change. The runtime refuses that bargain. You've seen that a
change is a tiny, self-contained thing, and that every derivation has a
change-shadow, so the runtime
pushes the one change through the shadow — flip one membership,
adjust one bucket, nudge one running total. The work is proportional to the
change, O(Δ), flat in N. (The lit tick above is
the one the change at the playhead touched — scrub the rail and it stays a single
tick while the recompute strip fills regardless of size.)
A change reaches only the views that read it
A change is cheap for a second reason: it doesn't disturb every view — only the
ones whose output it changes. Toggle a row's active flag
and its membership shifts, so active and the per-region count both move;
the average never stirs, because no value changed. Bump a row's
value and the average moves — and so does active,
because the row it still contains now reads differently — yet the per-region
count sits perfectly still, because a tally never looks at a value.
Don't take that on faith — to show it, the figure diffs each view's output the instant before and after each change — a measurement aid, not how the runtime works (it never needs to look: a change either has a shadow in a view or it doesn't). The matrix below is one row per change, with a filled dot under every derived view it actually moved. Hover or scrub a row, and notice how each kind of change moves a different, small handful.
That's what keeps the model composable. Add a tenth derived view and the
nine others don't slow down, because a change they don't read never reaches
them. The cost of a change is its size times the number of views that care —
both small, neither N.
Derivations compose
There is really one primitive here — a table derived from another — and it
composes without limit. A derived view is itself a perfectly good base for the
next derivation. perRegion isn't built on the raw table; it's built
on active: active.length(o => o.region). A derivation
of a derivation, kept live exactly the same way.
And the change-shadow chains too. When a change moves a row in or out of
active, that movement is itself the change perRegion
reads — one bucket up, another down. The change that orders hands to
active becomes the change that active hands to
perRegion. Toggle a row — or scrub to any toggle in the history —
and watch the source change become the change active hands on:
— toggle a row —filter reads it, and hands on…—Compose these and you get leaderboards, histograms, pivots — non-trivial structures, each maintained the same way, paying for the change rather than the table. You declare the relationships; you never write the propagation.
const active = orders.filter(o => o.active)
const perRegion = active.length(o => o.region) // a derivation of a derivation
// {north: 7, south: 9, east: 6, west: 8} — one bucket moves per relevant change.
You never touch a delta
Everything so far — the small change, threaded only through the views
that read it — is happening under the hood. You can see it on the left, as
the change history. But notice what your code looks like: it is the
derivations, and nothing else. You never wrote an onInsert, never
diffed two arrays, never reasoned about a stream of deltas over time.
That last part is the trap the other fast option springs. To make a recompute incremental by hand, you start handling change events — and then your derived-of-derived views are change-streams of change-streams, and the logic you actually care about is buried in plumbing. The deltas should be the runtime's problem, not your program's.
orders$.pipe(
scan(applyChange, []),
map(os => os.filter(o => o.active)),
switchMap(active => active$.pipe(
scan(toBuckets, {}), // streams of streams…
)),
distinctUntilChanged(deepEq),
).subscribe(render)
const active = orders.filter(o => o.active)
const perRegion = active.length(o => o.region)
render(el, perRegion, …)
// the change-handling is the runtime's job.
And even that isn't the cheap version — the map(os => os.filter(…))
re-scans the whole array on every event, the same O(N) recompute we
measured earlier. Make it genuinely incremental and you're threading deltas through every stage
by hand, the logic you care about lost in the plumbing. data gives you
that readable right-hand code at the best cost the by-hand version could ever reach: you write the derivations, the
runtime pays only the change. The readability of recompute, the cost of hand-tuned
deltas, and none of the bookkeeping.
The DOM is the last derivation
The screen is just the last view in the chain. render(el, orders, …)
is not special — it's one more sink reading the same changes. In fact the list
below is a second render() pointed at the very same
orders source as the table at the top of the page; press a button
and both update from the one change. And because each change names exactly what
moved and by which key, it maps to one minimal DOM instruction: an
insert is a single appendChild; a field update is one text write to
one node — the row itself never re-renders. No virtual DOM, no diff to compute:
the change already said what to do.
— press a button —
render(el, orders, …) — a sink like any otherA count in memory, a bucket in a histogram, a row on the page — each a derived view, maintained the same way, ending in a different sink. The framework doesn't know what a div is. It knows what a change is.
// Three sinks, one set of changes.
orders.tap(change => console.log(change)) // into a console
orders.connect(myArray) // into an array
render(el, HTML.ul(orders, (li, row) => …)) // into the DOM
Best of both worlds
The pieces are old — incremental view maintenance, dataflow. What's new is the price: a library you drop into one page, a few kilobytes, that lets you write views the way you'd write business logic and pays only for the change. Once a derived view costs the change rather than the table, a lot stops being hard. Local-first apps keep derived state live off a change feed instead of round-tripping a server per query. A live dashboard updates in place instead of choosing between freshness and frame rate. An edge function hands a client a view and a stream of changes and lets it stay current without polling.
And there's a second dividend, straight from the duality. Because a table and its changes are duals, writing your views this way hands you the change stream itself — for nothing. That stream is exactly what you need to undo an action, sync two replicas, audit what happened, or hydrate a fresh client and keep it live. The very thing that makes the views cheap is the thing that makes those features fall out.
Build your own chain off the table above and watch it keep up. Then read how the operators compose in the reference docs, or see them under load in the examples. The idea is the one table and its changes: you say what each view is, and the change does the rest.