$ data incremental computation for typescript

the original sin of reactive UI

Only do the work that changed.

Almost every reactive system re-runs the whole computation when a single value changes. data re-runs only the path that changed — work scales with the edit, not the dataset. Wrap a value in $(), chain operators, bind to the DOM. No virtual DOM, no diffing.

Like crossfilter's incremental aggregation meets Solid-style fine-grained DOM — for UIs over large, fast-changing data: dashboards, analytics, grids, live feeds.

quickstart ↓ github →

≈29 kB gzip · 0 dependencies · no virtual DOM · MIT

one engine · two workloads · flip through nine
1 / 9

↓ same engine · interactive brushing · 231,083 flight rows — drag any chart

The same engine you pick above, doing two jobs. Top: one realistic order-book tick stream through the same depth-chart-and-ladder viz — data moves a couple of bucket counters per tick (O(1)); every peer re-walks all 150,000 orders once per frame (O(N)), and the green baseline is data against the 16ms budget. Bottom: the same library filtering 231,083 flight rows live as you brush, with its real per-interaction latency. Every implementation is the real library doing the idiomatic thing.

01

The argument

A change is small. You set one field on one row. Yet most reactive stacks respond by re-running everything downstream — re-filtering the whole list, re-summing every number, diffing an entire tree — and then trusting a memoiser to paper over the waste.

data takes the change literally. A mutation carries its exact path, and each operator in the chain does work scoped to that path alone:

orders[42].bid = 99.85
  • filter re-tests the predicate for one row
  • length(bucket) moves one bucket counter
  • avg nudges a running mean in O(1)
  • render rewrites only the single bound cell

No diff pass. No list re-render. No selector re-run over 200,000 rows. The cost of an update is the length of the path it travels — which is why the race above holds at any rate, and why it scales to datasets that make recompute-everything systems stall.

02

The operator surface

One source, many derived views. Wrap a collection in $() and chain operators; each returns a reactive view that updates incrementally as the source changes. Every row below runs live off one shared $(trades) feed of rate trades — pick any tenor, watch the top-K reshuffle, see the groups recount as bid/ask/pnl tick.

filterrows matching a predicatetrades.filter('tenor', '5Y')1671×
betweencolumn in a range (sort-indexed)trades.between('pnl', [-500, 500])547×
gt / lt / gte / ltesingle-threshold comparisontrades.gt('pnl', 0)351×
za / az / top / limitsort and / or limit (top-K)trades.za('pnl', 5)420×
lengthrow count, or counts per keytrades.length(d => d.tenor)447×
sum / avg / max / minscalar aggregatestrades.avg('pnl')723×
some / everyany / all rows matchingtrades.some(r => r.pnl > 1000)723×
intersectrows in all source viewsfiveY.intersect(gainers)441×
union / exceptset algebra over viewsfiveY.union(gainers)250×
groupnested under a computed keytrades.group(d => d.tenor)2310×
distinctfirst-seen unique by projectiontrades.distinct(r => r.tenor)1241×
mapper-row transformtrades.map(r => ({ id, spread }))1902×
to / reducewhole-value transform / foldtrades.reduce(add, remove, 0)29556×
tapdeclarative side effectstrades.tap(() => n++)1875×
keys / values / reverseprojections over the shapetrades.keys()7621×

×-figures are the peak speedup — the widest gap to a peer — across eight measured peer libraries; click any to see the full fastest→slowest range in its reproducible run (npm run bench:ops regenerates them). some/every reuse the scalar-aggregate path, so they carry its figure. Summary table →

03

From zero

datalean core — $, render, HTML, SVG
data/fullcore + every operator + JSX helpers
data/renderjust the DOM render layer
data/devtoolsopt-in reactive-graph inspector

a collection

import { $ } from 'data/full'

const trades = $(rows)
const top = trades
  .map(t => ({ ...t, spread: t.ask - t.bid }))
  .filter(t => t.spread > 0.10)
  .za('spread', 10)

trades[42].bid = 99.5   // O(1) + O(1) + O(log N)

bound to the DOM

import { $, render, HTML } from 'data'
const { ul, li } = HTML

const todos = $([{ task: 'foo' }, { task: 'bar' }])

render(document.body,
  ul(todos, (node, item) => node.text(item.task))
)

todos.insert({ task: 'baz' })   // a new <li> appears

JSX works too (jsxImportSource: "data"). render() does per-key surgical updates — element identity and focus survive across changes. See it running in the gallery ↓.

04

See your graph

Import data/devtools and a graph-first overlay auto-mounts: a right-edge dock with a Tree/DAG view of the reactive graph and a slide-in inspector (Inspect / Events / Profile). Hold Alt for inline badges, click to pick any DOM element back to the view that bound it. Lazy-loaded — the console API alone ($.inspect, $.trace, $.profile) doesn't pay the panel's bytes.

Or try it on a worked example: todo-jsx ?devtools → · crossfilter ?devtools →