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