essay · change & derivation

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.

01

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.

the changes what the runtime emits
the table orders
drag the playhead, or edit the table →
0 / 0

Everything 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:

filter length avg orders active perRegion avg
const active    = orders.filter(o => o.active)
const perRegion = active.length(o => o.region)
const avg       = orders.avg('value')
the three views, live off the table
activeorders.filter(o => o.active)
perRegionactive.length(o => o.region)
avgorders.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.

02

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.
03

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.

data · O(Δ)
recompute · O(N)
one change touches one row; recompute re-scans all N. raise N — data stays one tick

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.)

04

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.

one row per change · a filled dot marks a view its output moved
hover or scrub a change

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.

05

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:

a change to orders— toggle a row —
filter reads it, and hands on…
a change to active

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.
06

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.

by hand — you wire the streams
orders$.pipe(
  scan(applyChange, []),
  map(os => os.filter(o => o.active)),
  switchMap(active => active$.pipe(
    scan(toBuckets, {}),   // streams of streams…
  )),
  distinctUntilChanged(deepEq),
).subscribe(render)
with data — you reason about the view
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.

07

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.

the change
— press a button —
↳ the one DOM instruction it becomes
render(el, orders, …) — a sink like any other

A 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
08

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.