Jerry Orta
← Concepts

Table Library Architecture

  • table
  • tanstack
  • angular
  • architecture
  • virtualization

Sample code — the library this note examines, in the public ngx-experiments repo:

A data table is where a component library's extensibility claim is settled. It is the component that accumulates requirements — pinning, resizing, virtualization, selection, ranges, editing, expansion, export — and the one that most often ends as a file nobody wants to open, because each of those arrived as a branch in the middle of the last one. So this library is built as two halves with a deliberate line between them. A headless engine owns rows, columns and state; everything visible is owned here, as a small substrate of locked rendering decisions plus exactly four seams a new feature arrives through.

What makes the second half more than an intention is how the seams were established. Each one was proven by building a feature through it and counting the files that had to change elsewhere — not by reviewing the design and agreeing that it looked additive. That distinction produced most of what follows, including the two occasions when a seam turned out to be broken in the direction that looks like success.

A table for reading, not a spreadsheet

The bar is Angular Material's table and AG Grid: this is a component for displaying complex data, and deliberately not a spreadsheet. Stating that early resolves scope questions rather than merely describing them.

Selection, cell ranges and a fill handle are present because they make reading and extracting faster. Where a spreadsheet gesture has a display half and an editing half, the library takes the display half and declines the other. The fill handle proposes values and never writes them. Dragging a range inward shrinks the selection rather than clearing the cells it leaves, because clearing is a change to data whose vocabulary belongs to the host's schema.

That boundary is enforced by the type of the thing the table emits. A released fill drag and a committed cell edit are announced as intents, and the host performs the write:

onEvent(event: NgeTableEvent<Row>): void {
  if (event.kind !== 'fill-intent' && event.kind !== 'edit-intent') return;

  this.rows.update(rows => applyPatches(rows, event.cells)); // the host's own write
}

Two consequences follow, and both are the point. A host that has not opted into editing ignores those two kinds and still has a correct table. And the library owns no data at any point, which is what keeps a frozen row from a state store — or a row that arrives from a server between two frames — from being something the table has to reconcile.

A headless engine, and the half worth owning

TanStack Table is an engine with no renderer: row models, column models, sorting, filtering, pagination, sizing, the state machine. That omission is the reason to adopt it. The algorithms are commodity and thoroughly tested by everyone else using them, while the rendering — pinning, virtualization, theming, the slots a consumer plugs into — is where a table is actually won or lost, and is exactly the part a third-party component asks its host to accept as given.

Consumers of this library never import the engine. NgeTableColumn is a thin alias over its column definition, and one function is the only place engine option names appear at all. A single translation point is what makes the facade real rather than nominal: a breaking rename upstream lands in that one file, and a migration stays internal.

The line between the halves also had to be drawn inside the Angular layer, and the useful version of that rule came out of auditing the adapter's own store when it grew large:

State belonging to a table feature goes on the engine. State describing how Angular paints the table belongs in the adapter.

Naming the boundary is what stops a large adapter from being mistaken for a badly-organised one. What this one holds is a registry of consumer-supplied templates, lane widths in pixels, a scroll margin, the virtualization window, the ARIA row and column counts, and one scratch field naming the cell being edited. The engine has no concept of a template, a pixel, or a scroll that has settled — so none of it could move, and the audit's real product was a placement rule rather than a migration.

Four seams, and no central switch

The charts library needs one extension axis, because a chart type is a layer. A table needs four, and all four had to exist before the first feature shipped: retrofitting a seam later is the rewrite the design exists to avoid.

  • Behaviour and state — a feature object registered through dependency injection.
  • Render slots — a registry of named templates a consumer projects in.
  • The data pipeline — readers over the processed row model, after sorting and filtering.
  • Events — one discriminated union, on one output.

They share the property that makes the charts layer registry work: the thing carries its own logic, the core iterates, and there is no central switch to edit. A consumer registers a feature the same way the library registers its own, through the same array, with nothing privileged about the built-ins:

@Component({ providers: [provideNgeTableFeatures(ngeCellHighlighting)],})

Registration goes through injection rather than through the config object, and that is an engine constraint rather than a preference. The engine reads its feature array once, while constructing the instance, and the Angular adapter constructs it from a microtask scheduled as soon as the store exists — before the component has run the effect that pushes its config in. A config.features field would therefore register nothing at all, silently, and would read as the addon being broken rather than as the wiring being impossible.

The slot seam is addressed by column for cells and by name for everything else, so adopting a custom cell is a per-column decision and a table nobody projected into renders exactly what it did before the seam existed:

<nge-table [config]="config" [(state)]="tableState" (ngeTableEvent)="onEvent($event)">
  <ng-template ngeCell="amount" [ngeCellOf]="rows" let-cell>
    <strong>{{ cell.row.amount | currency }}</strong> · {{ cell.row.owner.name }}
  </ng-template>

  <ng-template ngeTableSlot="empty" let-table>
    Nothing matched across {{ table.columnCount }} columns.
  </ng-template>
</nge-table>

A slot is a place, not a state. Its template renders whenever one is registered, and whether anything appears is the consumer's decision, taken from the context the table hands over. That is why a loading slot needs no loading flag on the config and a row-detail band needs no expansion feature: the template gates itself on what it is given, and the table gains no coupling to how a consumer fetches or expands anything.

The cost of extending each seam is one line of the same kind. Adding a slot costs a name, its context type, and one anchor in the template — nothing in the directives, the registry or the resolvers mentions a name. Adding an event costs a member on the union and an entry in its exhaustiveness list; the emission pipeline names no kind. The growth is the evidence: the slot seam opened with nine names and carries thirteen, and the event union opened with ten kinds and carries fourteen. Every addition landed inside the feature story that needed it, and the two files that define those lists have been edited three times and five times respectively across the whole library.

One output rather than ten deserves its own line, because it is the decision that keeps the component's signature from growing for the lifetime of the library. With one output per event type, every new event is a public API change and a binding each consumer has to learn. With a kind-discriminated union, a new event is a member: hosts already bound receive it without changing a line, and hosts that do not care keep ignoring the kinds they do not switch on.

The gate was a build, not a review

The core was not declared finished when it rendered. It was declared finished when two addons — cell highlighting and CSV export — could be added while touching zero core files. Highlighting was chosen because it spans three axes at once, and export because it forces two independent addons to compose without importing each other. If either needed a core edit, the seams were wrong, and the discovery would cost a day rather than five consumers.

Highlighting found one seam broken, and broken in the worst direction — silently, while looking like success. The state axis had never been wired to the engine's own state-update route, and the Angular adapter keeps an internal state signal that absorbed every write. The addon rendered, toggled, and survived a virtualized scroll while the published state never moved and the host was never told anything had happened. The fix was one option line and one store method, neither of which names a slice. Then CSV export needed nothing at all.

The pair is the result, and the sentence it produced is the most reusable thing in this library:

A rendering addon working is not evidence that a state seam works.

A second seam failed in the same silent direction two waves later, and the shape of its fix is what makes the pair worth reporting rather than merely worth fixing. The event axis turned out to be closed to addons altogether: the function that emits is a closure on the component's own store, while an addon's services live in the consumer's injector holding nothing but the raw engine instance, so a feature had no route to announce anything at all. The answer was again one general, kind-agnostic edit — publishing the sink onto the engine instance, exactly as the data-pipeline seam already publishes its reader — rather than a special case for the feature that happened to find the gap. A seam that needs a special case has been widened; a seam that needs a general edit has been finished, and the next addon's event needs no second seam.

The rendering-versus-state sentence then returned twice more, in different keys. A select editor's dropdown carries a single stopPropagation() so that Escape cannot reach two addons listening on the document; delete it and the panel still opens, still closes, still commits, and still passes every keyboard assertion, while exactly one spec goes red — the one that installs a document listener and asserts it never fires. And a mark-painting overlay re-derived its membership correctly on every call, forever, while never being asked to re-derive it at all. The visible half of a behaviour working is not evidence about the invisible half, and the invisible half is usually the one holding an invariant.

The check that nothing ran

Angular does not type-check a template with TypeScript. It type-checks it with the Angular compiler, which runs as part of a build — and a component library has no reason to have a build. This one has three targets: test, lint, and a typecheck that invokes tsc --noEmit against its own configuration. TypeScript reads the class; the template beside it is, to tsc, a path in a decorator. The strictTemplates flag sits in that configuration set to true, and nothing in the library ever reads it.

The cost surfaced the first time an application imported the table. Five keyboard handlers declared a KeyboardEvent parameter while being bound from pseudo-events — (keydown.shift.arrowleft), (keydown.enter), (keydown.escape) — which Angular routes through its key-events plugin and types as the base Event. Each of the five had been wrong since the line was written, across every wave of this library's construction. Restoring them and re-running the library's own gates is the measurement worth recording: typecheck passes, lint passes, and all 1,034 specs pass. Three targets report green over a template that cannot compile.

A library with no build has not type-checked its templates. It has type-checked the classes beside them.

The repair was to widen the parameter, because none of the five had needed the narrower type: between them they use target, preventDefault and stopPropagation, and not one reaches for a key. That is the smaller half of the finding. The larger half is that a library written to be consumed had never been compiled the way a consumer compiles it, and no quantity of internal testing could have reported so, because the failing check is not one that any internal target performs. A Storybook build would perform it, being a build; a Storybook build belongs to the Storybook application rather than to this library, which is precisely the point. The gate that matters here is not a stricter setting. It is the existence of a consumer.

A claim that can decay is asserted, not stated

A design note is true on the day it is written. Three of this library's claims are the kind that decay in silence instead of failing, so each is a test rather than a sentence, and each reads something no other gate reads.

The library has three entry points, and the production one must never reach the other two — otherwise an optional editor's dependency quietly becomes every consumer's. A convenience re-export added months later compiles, lints, passes every other test, and folds the optional half into the main bundle. So a spec walks the transitive relative-import closure of the public barrel and fails if it reaches either secondary directory. It has to be transitive, because a core module importing an editor is exactly as bad as the barrel doing it and considerably harder to notice.

⚠️ And then the check itself has to be checked. A walker whose resolver silently returns nothing passes everything, forever, while looking like coverage. Appending export * from './editors' to the barrel and watching the spec go red takes ten seconds and is the difference between a guard and a decoration.

The second guard watches a ceiling the framework does not document. The adapter's store is an @ngrx/signals signalStore, whose widest typed overload accepts fifteen features — and the sixteenth is not rejected. Inference simply stops matching, every member degrades to an index signature, the store's own type becomes Function, and dozens of errors appear in the consuming component, which is where every signal points and where the cause is not. The tripwire therefore fires at ten of fifteen rather than at the cliff, so whoever trips it still has five slots to land the feature and regroup afterwards. A guard whose only setting is "too late" gets deleted; one that leaves room gets obeyed. It also parses the composition root with the TypeScript AST rather than a regular expression, because the file's own prose names the composers it counts.

The third reads bytes rather than a program. A map key was once joined with a NUL byte: collision-proof, functionally correct, green across lint, type-checking and several hundred specs, and verified in a browser. Git classifies a file containing one as binary, so the change rendered as Bin 0 -> N bytes instead of a diff and merged with nobody having seen it, and blame cannot attribute a line in such a file afterwards. Every gate that reads the source as code is structurally blind to that defect. The one that walks the tree and reads bytes is not.

Two substrate decisions that look like preferences

Underneath the seams sits a small set of rendering decisions that are not open for re-litigation, and the two most interesting ones each have a rejected obvious answer attached.

Flexbox lanes, not CSS Grid. Grid is the modern answer for a table-shaped layout, and it was rejected. Its one decisive advantage is intrinsic column sizing, which this product explicitly refuses: a column's width is whatever the user last dragged it to. It is also structurally incompatible with the pinning model, because a sticky lane wrapper cannot be a grid item and display: contents cannot be sticky at all, having generated no box to stick. Each row is therefore three flexbox lanes — pinned-left, center, pinned-right — with position: sticky on the lane wrapper and never on a cell. The predecessor component pinned per cell, so every frozen column claimed left: 0 and they stacked on top of one another; here the pinned cells are ordinary flex children of one sticky box, so three frozen columns and thirty cost the same. Header and body share a single scroll viewport, which makes "the header lanes stay aligned with the body lanes" structural rather than synchronised by a listener, and the nested stickiness — vertical on the header band, horizontal on the lanes inside it — is what produces the frozen corner without arithmetic.

Virtualized rows are positioned with top, never with transform: translateY. A transform creates a stacking context, and a stacking context breaks position: sticky for every pinned cell inside the row. The cruel property is that a transformed row looks perfectly correct until a column is pinned: it is a correctness bug in one feature that manifests only in another, which is not a place anyone looks. AG Grid hit the same wall and made the same switch, and independently arriving where a mature product already stands is a useful signal.

One more piece of the substrate is worth naming because it is what keeps the first two affordable. Lane geometry does not travel as inline styles. The component writes the lane widths onto its own host as --nge-table-internal-* custom properties, once per state change, and every lane and row rule reads them — so a pin, a resize or a reorder costs four property writes no matter how many rows are on screen, where inline widths would mean touching three elements per row and would stop scaling exactly when virtualization makes it matter. Those properties are deliberately absent from the themeable contract: they carry live measurements, so overriding one from a theme breaks layout rather than restyling it.

The state belongs to the host

Sorting, filters, pagination, sizing, order, visibility, pinning, selection and expansion all live outside the engine, are handed in as one object, and every change is routed back out. Client-side, this is indistinguishable from letting the engine keep its own copy, which is precisely the trap: only this arrangement makes a server-side mode a later flag flip rather than a rewrite of every feature that had reached into the internal copy.

config = createNgeTableConfig<Row>({ columns, data, getRowId: row => row.id });
tableState = signal(createNgeTableState());

The state type is declared, not aliased to the engine's, and deliberately narrower — filter payloads are a JSON value type rather than unknown — so "this view can be persisted" is a compile-time property rather than a convention nobody checks until a date comes back as a string. A spec asserts the round trip. Binding is optional in both directions: bind the two-way form and the host owns the view, ready to save and restore; bind neither half and the store's own copy keeps the table usable out of the box. The loop cannot oscillate, because the component tracks the last object that crossed the boundary by reference and skips whichever direction already carries it.

The accompanying rule is one line of prose: never read state back off the table instance as a source of truth. It was written in the first wave and sat unexercised for six, which is usually the fate of a rule that gets deleted as ceremony. Row expansion is where it paid. The obvious implementation of an expand-all control asks the engine whether all rows are expanded — and the engine answers from the options object the adapter last applied, not from the store, so two writes inside one change-detection pass have the second deciding against a state one pass old. Pressing expand-all twice expanded twice. Angular's own signals were correct throughout; the engine's copy of the state was behind. A lock can sit unused for six waves and still be the thing that saves the seventh, which is an argument for keeping the ones whose cost is a sentence.

Marks are descriptors, not enumerations

Anything that marks a row, a cell or a column is id-keyed state — never a DOM flag, because virtualization recycles nodes, and never a field on the datum, because the data belongs to the host and the rows arriving from a state store are frozen.

But identified by ids is not the same as enumerated per cell, and the difference is the scalability lock of the whole design. Highlighting one column of the ten-thousand-row fixture as a per-cell map is roughly 270 KB of JSON, built in about 25 ms, re-emitted on every state change — and three or four such columns exceed a document store's 1 MiB limit. That does not merely cost frames; it destroys the persistable-view property the controlled-state contract exists for. A descriptor — an anchor, a focus, and a list of column ids — is one object regardless of row count, and membership is answered by a predicate rather than by a lookup.

Two details follow, and both are choices rather than consequences. The endpoints are row ids rather than coordinates, so they follow their records across a scroll, a filter and a re-fetch, while which rows lie between them resolves against the current view — meaning a re-sort re-shapes the block. AG Grid's coordinate-based range makes the opposite trade: its endpoints are positions, so a sort leaves the selected rectangle exactly where it was on screen while the records inside it quietly become different rows. Neither reading is free, and choosing one is choosing what "the block the user dragged out" means. The second detail is that the state holds two collections rather than one: cells picked individually are enumerated as short keys, and blocks are descriptors. The lock is not "never enumerate", it is enumerate only what a user picked one at a time. The engine had already reached the same conclusion elsewhere, which is reassuring company — its expansion slice accepts the literal true as shorthand for "everything is open", precisely so that expanding ten thousand rows need not materialise ten thousand keys.

And then the trap that took two independent sightings to become a rule. An overlay painting a marked block must depend on something a sort actually changes. A sort leaves the addon's own state slice untouched, reorders the same row objects, and — because the row and cell loops track by id — moves DOM rather than rebuilding it. An overlay whose computed reads its own slice therefore has no dependency to invalidate: it goes on painting the block as it stood when the marks were made, which is visually indistinguishable from the enumeration the descriptor exists to avoid, while every unit assertion about the membership predicate still passes. Two overlays, written months apart by different stories, arrived at the same defect independently, which is what makes it a rule rather than an anecdote. It is now stated positively — an overlay binds the whole state, never its own slice — and the regression test has an axis: a column reorder invalidates the engine's leaf-column memo and makes the computed re-run incidentally, so only a re-sort discriminates.

A cell is an arbitrary render target

This is the claim that decides whether a table library is a table or a grid of formatted strings, so it was tested with the most expensive content available: a chart in every cell of a column, over ten thousand rows.

Two rules make such a cell work, and they are the whole of the contract. A percentage-sized child needs an ancestor with a definite height, and a cell already is one — because a virtualized row's height is arithmetic rather than a measurement, the definite height was already there. And because virtualization recycles DOM, cell content must re-derive everything it shows from the context it is handed and hold nothing locally.

The interesting part was the affordability signal. Building a chart during a fast flick is waste, so a cell wants to know whether the scroll has settled, and the obvious implementation is a scroll listener and a debounce timer. The library has no scroll listener at all — scrolling is delegated entirely to the virtualizer, which already tracked whether a scroll was in progress and already cleared it after a configurable quiet period, which is the required semantics with exactly one knob. The feature is one computed over a flag the engine was maintaining anyway, and reading the dependency's source first is what turned a subsystem into a derivation. The knob was deliberately left off the public config, because one tuning constant a consumer can set badly is worse than a default that is occasionally imperfect.

Then the cache problem, which generalises well past tables. Cell contexts are memoised against the engine's cell object in a WeakMap, because allocating a context per cell per render is exactly the churn virtualization exists to remove, and the justification is sound: a cell's value cannot change under it. Then a field arrives that does change — has the scroll settled? Both obvious moves are bad. Dropping the memo restores the churn it was added to remove. Keeping the memo and adding a boolean serves a value read once at first build, and that failure is silent and looks like success, because the placeholder appears exactly as designed and simply never resolves.

The resolution is to make the field signal-valued. Object identity stays stable, which is all the cache requires; the value stays live, which is all the template requires. The two requirements stop being in tension because they were never about the same thing — one is about identity and the other about content. One spec pins both halves in a single assertion pair, and it is the spec that fails the day someone simplifies the field back to a boolean.

The library's own editors ride the same seam rather than a branch, and this is the gate's most useful kind of dividend: a design question it settled before a line was written. Shipping the editors appeared to require a switch in the core — if this column is editable, render our input — which is precisely the central switch the gate exists to catch, and which would have forced the core to import the optional editors it names. The render seam already accepted a component as well as a template, so a column names one:

{ accessorKey: 'name', header: 'Name', id: 'name',
  meta: { ngeEdit: { editor: NgeCellInputComponent, enabled: true } } }

The existing lookup gained a second line, the core learned nothing about what an input is, and a consumer's own template for the same column still wins — so a library editor is a default to be shadowed rather than a fixture to work around. When a feature appears to need a branch in the core, the question worth asking first is whether the seam's contract is already wider than the way it has been used.

Theming rides the same contract as everything else

The token contract is the one described in the design library note, pointed at a table: a --nge-table-* namespace with literal light-mode defaults, so a table renders correctly with no theme loaded at all, and a domain theme re-declares the same properties inside its own class. The themes layer is where one library's contract is expressed in another's values, and the table is bridged into every persona exactly as the charts library is.

Three details are specific to a table.

A pinned lane is sticky and scrolls over the center lane, so its surface token defaults to an opaque colour on purpose — a transparent one shows the center cells travelling underneath it. A pinned lane inside the header takes a different token again, because it sits on the header band rather than on a row, and the row-flavoured surface would punch two pale rectangles through it.

A handful of the metric tokens are mirrored in TypeScript as numbers, and a spec asserts parity between the two sources. Layout code needs them arithmetically: virtualization computes a row's offset as an index multiplied by a height, and it cannot measure the row, because the rows being positioned are precisely the ones not yet rendered. The row-height token is therefore pinned while virtualization is on — a theme moving it out from under that arithmetic would not restyle the table, it would overlap its rows.

And a correction worth passing on, because the habit of saying otherwise is nearly universal. A theme does not win on specificity. :root is a pseudo-class and scores exactly what a single class scores, so a theme class landing on the same element as the defaults merely ties with them, and source order breaks the tie: the token partial has to load first. A theme class on a descendant of the document root does win, but for an unrelated reason — custom-property proximity, where the nearer declaration beats the inherited one. The false version of the claim survives contact with most setups, which is exactly why it is worth writing the real rule down next to the tokens it governs.

The shape an agent can extend

Everything above has the same second payoff as the charts and design libraries, and it is not about runtime. A feature is a file, a barrel line, and — if it carries state — a slice merged in by declaration; nothing edits a central switch, and nothing needs a shared surface disturbed. That is the kind of bounded, fully-specified change an AI agent makes reliably, and I built this library that way, one feature per story, with the library's own lint and specs as the gate.

The skills I rely on include a generator for a feature's full Storybook set, and its inversion relative to the charts generator is the informative part. A chart is verified by looking at it, so a chart's stories lead with usage. A table is verified by driving it — selection, a range drag, a resize, a virtualized scroll — so the table generator leads with interaction and puts usage and theming behind it. The primary facet follows from how the thing fails, not from a house style.

The written contract is what the agent works from: an architecture guide that names the four axes and the locked substrate decisions, and a set of contributor notes per library. A record of what was tried and rejected is kept deliberately out of the source and in a separate register, because a comment records what is true and not what was attempted — the second one is dead weight from the moment it is written and wrong the moment the next story touches the file.

What only the whole table could find

Every feature had been proven in isolation, and the extensibility gate had already proven that two addons compose without touching the core. What nobody had done, until a showcase story put all of it onto one table, was combine features that never had a reason to know about each other.

Switching on selection or expansion injects a leading control column, which places it at the front of the column order. Pinning is a separate axis, resolved afterwards, with no knowledge that the column it has been told to freeze might be one of the two the other feature just placed at the front. Pin any data column to the left edge — the most ordinary thing a consumer does with pinning — and the row's own checkbox and chevron land in the scrolling centre lane while the data column stays frozen. The result is backwards and it is silent: the table renders exactly as configured, every control is present and works, and the only symptom is that a user has to scroll back left to find their own checkbox.

Nothing about this is a defect in either feature read on its own. The column ordering is correct, the pinning is correct, and no quantity of specs for either in isolation would ever produce it, because each suite configures the feature it is testing and leaves the other switched off — which is exactly how both were tested, for eight waves, without anyone seeing this.

A feature that composes correctly with an addon has said nothing about whether it composes correctly with a sibling feature.

An addon and a core feature interact through a seam built and tested for that purpose. Two core features interact through nothing more than the accident of both writing to the same table. The gate answers "does a new thing plug into the existing whole cleanly", which is a question about one relationship. A showcase answers the harder one — "does the whole still make sense when every relationship is live at once" — and the only way to find out is to build the whole and look at it.

The mitigation was one line, because the two column ids a host needs were already exported: name them in the pinning slice ahead of the data columns. That is also the uncomfortable half. A library asking a consumer to recite two internal-looking column ids to avoid a backwards-reading table is a seam worth revisiting rather than a fix to be pleased with, and it is recorded as such rather than closed.

Why it holds together

Eight decisions, one shape — and a dividend:

  • A headless engine, an owned renderer — the row and column algorithms are commodity and the painting is not, so one translation point insulates a consumer from the engine entirely and a placement rule says which layer a new concern belongs to.
  • Four seams, no central switch — behaviour, slots, the data pipeline and one event union; adding a slot costs a name and adding an event costs a kind, and the seam that opened with nine names now carries thirteen without a resolver changing.
  • The gate was a build — the core was finished when two addons could be added touching zero core files, which is how a state seam that rendered perfectly while publishing nothing was caught in a day.
  • A claim that can decay is asserted — an import-closure spec for the entry-point boundary, a tripwire that fires five slots before a framework ceiling, and one gate that reads bytes rather than code.
  • A locked substrate — flexbox lanes rather than CSS Grid because the user drags the widths, top rather than transform because a stacking context breaks sticky pinning, and lane geometry published as four custom properties instead of an inline style per cell.
  • State belongs to the host — declared narrower than the engine's so persistence is a compile-time property, routed out on every change, and never read back off the engine as a source of truth.
  • Marks are descriptors — an anchor, a focus and a list of column ids rather than a per-cell map, which is the difference between a view that can be saved and 270 KB of JSON per highlighted column.
  • A cell is an arbitrary render target — charts and editors in cells, with a memoised context whose one moving field travels as a signal so identity and content stop competing.

The payoff is the one the charts library gets from its layers: the tenth feature costs what the second did. The difference a table makes is that the tenth feature has nine siblings to be wrong with, so the claim is only worth as much as the table that has all of them switched on at once.