Case study · Company work, anonymised

A headless UI platform that replaced a paid vendor

One framework-free core, thin React 18 and Vue 2 bindings, 119 production screens migrated with zero rollbacks.

  • Project owner and architect
  • 2026
  • TypeScript
  • React 18
  • Vue 2
  • TanStack Table
  • TanStack Virtual
  • WAI-ARIA
  • monorepo
Grid screens migrated
119/1190 rollbacks
Grid bundle
8x smaller(582 KB → 73 KB gzipped)
DOM elements on a 20-column page
3.5x fewer(2,342 → 660)
Page open
2-3x fasterscrolling within 10%
Automated tests
2,664(the legacy grids had none)
Shipped
16 componentsdata grid, 19 chart types, tree view, form controls, flow chart
Licence
100% of its cost removedPaid third-party UI licence dropped

The problem

The flagship data-management app ran on three generations of grids built on a paid third-party UI library. The licence had a hard end date. Renewing meant paying again for a dependency nobody could test: the legacy grids had zero unit tests. Rewriting screen by screen meant risking regressions across more than a hundred production screens that enterprise customers use every day.

The question wasn't "which grid library do we swap in?" It was how do we own our UI layer without a big-bang rewrite?

Scoping before building

Before writing the core, I catalogued what the legacy grids actually did, reading their source across all three generations: 121 capabilities, split into 78 MUST and 43 SHOULD. The matrix fixed the scope. A named DROP list stopped feature creep before it started.

Architecture

A 10-package TypeScript monorepo, one layer per package, with very few third-party dependencies:

Layer diagram of the headless UI platformFour layers left to right: types, core (state), a framework-free DOM renderer, and thin React 18 and Vue 2 bindings. Each layer depends only on the one to its left. TanStack Table and TanStack Virtual sit inside core behind a boundary that a CI lint rule enforces.typesshared contractscorestateDOM rendererframework-freeTanStack TableTanStack VirtualReact 18 bindingthin adapterVue 2 bindingthin adapteronly core may import TanStack · a CI lint rule enforces the boundary← depends on · one package per layer · identical DOM from both bindings
One layer per package. Both bindings are adapters over the same store-driven renderer, so they emit identical DOM.
  1. Rent the engine, own the boundary

    TanStack Table and TanStack Virtual do the hard maths, but only the core may import them. A CI lint rule enforces that boundary, so swapping or upgrading either library is a change to one package, not to 119 screens.

  2. One renderer, two frameworks

    React and Vue don't each render their own grid. Both bindings are thin adapters over one store-driven DOM renderer, so they emit identical DOM. Virtualization, keyboard navigation and accessibility live in one place, and a fix lands once for both apps.

  3. A compatibility layer instead of a rewrite

    Legacy props, events and slots are translated in one place. Screens migrated by swapping an import, not by being rewritten, which is how 119 screens moved with 0 rollbacks.

  4. One data funnel

    A single server-driven contract (page, sort and search in; { count, result } out) runs behind one state-change funnel with a duplicate-request guard. It handles both numbered and cursor pagination.

Interactive · virtualization

The list grows. The DOM doesn’t.

Only the rows inside the viewport (plus a few of overscan) are mounted. Scroll the list; watch the second number.

10,000rows in the dataset
0rows in the DOM
10,000rows in the DOM without virtualization
#1check · 363.6
#2probe · 831.9
#3reading · 58.4
#4batch · 187.1
#5record · 839.7
#6reading · 58.2
#7check · 373.9
#8record · 897.2
#9sample · 576.1
#10batch · 202.4
#11reading · 33.8
#12reading · 57.9
#13record · 973.1

Generic list, synthetic values. The platform’s grid does this for rows and columns; that is where the 3.5x fewer DOM elements come from.

Performance

Measured on an in-house benchmark page that mounts the vendor grid and the new grid side by side on production builds:

  • Bundle: 582 KB → 73 KB gzipped (8x). It comes from the package-per-layer design and a small dependency surface.
  • DOM: 2,342 → 660 elements on a 20-column page (3.5x). This comes from row and column virtualization.
  • Flat at scale: 24 rows drawn whether the dataset has 10,000 or 50,000 rows.
  • Page open: 2-3x faster; scrolling within 10% of the vendor grid.

Accessibility and quality

  • Keyboard navigation and WAI-ARIA grid semantics were built in-house: roving tabindex, aria-sort, live regions.
  • 2,664 automated tests where the legacy grids had none.
  • 6 automated release checks: architecture boundary, browser globals, stale build output, ES2019 syntax, and typecheck/test/build.
  • Branch-driven versioning and npm dist-tags, so consuming apps adopt releases, pre-releases and hotfixes safely.

Leading it

I owned it from R&D to delivery: wrote and presented the architecture proposal and status reviews, laid the base of every layer, then brought in 3 developers and led them on modules.

What I’d do differently

Put a bundle-size budget in CI from day one. An early build of the grid measured 39.5 KB gzipped. As the toolbar, menus and other chrome landed, it grew to 73 KB. That's still 8x smaller than the vendor grid, but the growth happened feature by feature without anyone deciding it. A size budget that fails the build turns every added KB into a deliberate trade-off rather than something you notice at the end. It's the same instinct as the architecture boundary rule: if a rule matters, a machine should check it.

← All work