A Leaner Core, the Same Everything: Announcing @toolbox-web/grid v3

I'll admit it: there's a specific kind of satisfaction in shipping a release whose changelog is mostly a list of things I deleted.

That's what v3 of @toolbox-web/grid is. I didn't cram in a pile of new headline features and slap a major-version sticker on it - the feature set has been complete for a while now. This cycle was about something less glamorous and, honestly, more satisfying: prying the core apart from everything optional, cutting the Angular adapter's bundle roughly in half, and finally deleting the handful of v2 API warts I'd been papering over with @deprecated tags for months.

One thing worth saying plainly up front, because it colours everything below: this is a one-person project. No team, no funding round, no "Enterprise" tier waiting behind a sales call. That's not a humblebrag - it's the reason the obsession with bundle size is genuine rather than a slide in a deck. When I'm the only person who has to keep this thing fast and small, "ship all of it and let tree-shaking sort it out later" isn't a plan I can afford.

So: if you're already on v2, v3 shrinks your bundle without taking anything away. And if you're still shopping for a grid, this is a good moment to look, because v3 is the clearest expression yet of the one bet I've made since day one - you shouldn't have to choose between a grid that does everything and a grid that stays out of your bundle.

TL;DR - The shell is now opt-in and tree-shakes away when unused. The Angular adapter's baseline bundle roughly halved. Every @deprecated symbol from the v2.x line is gone. Core is ~42 KB gzipped; the batteries-included all endpoint (core + every feature) is ~140 KB gzipped. No feature was dropped. Migration is mostly mechanical, and if you kept your IDE's deprecation warnings clean through v2.x, you may have almost nothing to do.


The changelog undersells this one

Skim the CHANGELOG.md and v3 looks tiny: one breaking commit (#400) that removed the v2.x deprecations, plus some beta-line polish around clipboard, the light-DOM column shorthand parser, and declarative tbw-grid-type defaults. All accurate - and all of it sells the release short, in two directions:

  1. The real v3 work is a theme, not a commit. "Core isolation from plugins/features" is an architectural direction that landed across many v2.x minors (feature registry scoping, col.__element, the shell extraction groundwork) and then got finalized by the v3 removal. The one-line breaking-change entry tells you the deprecations are gone; it doesn't tell you why the bundle got smaller or what the new mental model is.
  2. The migration surface is per-adapter and detailed. The changelog is a flat list of commits. The actual "what do I change in my app" answer lives in the migration guide, where the Angular per-feature import split, the pinned-rows slots[] unification, and the ssr-prop removal are spelled out with before/after code.

So treat the changelog as an index and the migration guide as the source of truth - this article is the tour between them.


Everything since the v2 launch

The v2 announcement covered v2.0.0 in full - the unified DataSource, the ~106-API cleanup, the accessibility pass, styled Excel export - so I won't relitigate it here. Here is everything that came after:

Seventeen minor releases between v2.0.0 (2026-04-16) and the v3 beta (2026-07-06), each quietly sharpening the grid. If you're upgrading from an early-v2 pin you inherit the lot at once, so here's the tour, grouped by what it buys you:

  • Data at scale - config-driven dataSource, loadThreshold prefetch, AbortSignal cancellation for server-side getRows (plus an Angular fromObservable bridge), local sortMode / filterMode, and fractional scroll mapping that lets one grid exceed the browser's max element-height cap - yes, million-row datasets (mostly useful for infinite-scroll paging, I mean who queries out millions of rows in one request?).

  • New capabilities - RowDragDropPlugin (drag rows within and across grids, coordinated cross-window over BroadcastChannel), a sticky-rows plugin, column-selection mode, the unified pinned-rows slots[] API, an emptyRenderer for no-rows / error overlays, and a rebuilt pivot plugin (events, custom aggregators, sorting, formatting).

  • Declarative & zero-config - the declarative features API, light-DOM column shorthand, a declarative column-order attribute, columnInference: 'merge', data-src server fetch straight from HTML, tbw-grid-type typeDefaults, and col.__element so plugins can read their own light-DOM attributes.

  • Shell & tool panels - push and dropdown layout modes, toolPanel.initialState / locked, shell.header.visible with Esc / click-outside dismissal, a toolPanelToggle opt-out, and one-click openToolPanel(panelId) routing.

  • Adapters - a Vue overlay editor and typed #header / #headerLabel slots, ten missing grid events wired into Vue and Angular behind a drift guard that keeps the three in lockstep, and a run of editor/renderer memory-leak fixes.

  • Accessibility - gridAriaLabelledBy, gridAriaRoleDescription, context-menu aria-haspopup / aria-expanded, and keyboard navigation for the context menu.

  • Performance - compiled filter predicates, in-place sort (no O(n) copy), a Schwartzian transform for multi-sort with cached collators, O(1) row lookup, deferred rowIdMap rebuilds, and branches hoisted out of the comparator hot path.

  • Operational safety - auto-suffixed tags so multiple grid versions can coexist on one page, a public render event after every scheduler flush, and XSS sanitization of renderer, light-DOM and icon HTML.

    That multi-version bit is really for microfrontend hosts - module-federation, native-federation and the like - where two widgets can mount different grid versions on the same page. v3 isolates the registry and managers per version so their features stop colliding (before, the second version crashed trying to register into the first's registry). Monoliths where every grid is one version never hit it - the guide above has the full story.

The pattern is consistent: almost none of this touched the public API in a breaking way. You could ride the entire v2.x line by bumping a minor and reading a one-paragraph changelog each time. v3 is where the small, accumulated pile of @deprecated tags finally gets swept up - which is exactly why the v3 breaking surface is so shallow.


Only ship what you use

Every decision in v3 rolls up to one idea: the core should hold only what every grid needs, and everything else should be free to vanish from your bundle the moment you stop referencing it.

That sounds obvious once it's written down. It's also the thing most grids quietly get wrong. Features have a way of growing little tendrils back into the core - a shared import here, an event the core "just knows about" there - until you can't ship the core without dragging the whole feature set along for the ride. A big chunk of the v3 cycle was me going wire by wire and snipping those tendrils.

The shell is now opt-in

The most visible change: the shell - the header bar, toolbar content region, and tool panels - is no longer auto-registered. It moved into a built-in ShellPlugin, and a bare <tbw-grid> now renders no shell chrome at all.

Why bother? Because the shell is a genuinely big subsystem: panel layout, dropdown anchoring, focus management, the whole toolbar. And plenty of grids never show any of it - they render rows and columns and call it a day. On v2, those grids paid for the shell anyway. On v3, if you never touch it, it tree-shakes clean out of your bundle and you never think about it again.

Turning it on is a one-liner, in whichever style fits your setup:

// Feature form (recommended)
import '@toolbox-web/grid/features/shell';

grid.gridConfig = {
  features: { shell: true }, // or { shell: { /* ShellConfig */ } }
};
// Explicit plugin form
import { ShellPlugin } from '@toolbox-web/grid/plugins/shell';

grid.gridConfig = { plugins: [new ShellPlugin(/* ShellConfig */)] };

There's a happy side effect, too. Features that need the shell now say so, loudly and early. Turn on the visibility panel without a shell to hang it in and VisibilityPlugin throws TBW020; PivotPlugin warns with TBW021. On v2 the auto-registered shell quietly satisfied those dependencies, which sounds convenient right up until a misconfiguration hides itself until runtime and then surfaces somewhere weird. Now the grid just points at the missing piece and tells you how to add it.

The Angular adapter went on a diet

If you're on Angular, here's the headline: the @toolbox-web/grid-angular baseline bundle roughly halved.

Same core-isolation trick, applied one layer up. On the old adapter, the core Grid directive silently owned every feature input - [filtering], [editing], [selection], all of them - so importing the grid dragged in the machinery for the lot, whether your template used it or not. On the new adapter (it ships as its own major, alongside grid core v3) each feature is its own directive in its own subpath:

import { Grid } from '@toolbox-web/grid-angular';
import { GridFilteringDirective } from '@toolbox-web/grid-angular/features/filtering';
import { GridEditingDirective } from '@toolbox-web/grid-angular/features/editing';

@Component({
  imports: [Grid, GridFilteringDirective, GridEditingDirective],
  template: `<tbw-grid [filtering]="true" [editing]="editConfig" />`,
})
export class MyComponent {}

Now the main entry pulls in only the core Grid directive and the shared types. Every feature - its directive, its editor and filter base classes, its inject helpers - stays out of your bundle until you import its subpath. Bind [filtering], import GridFilteringDirective. Don't bind it, pay nothing. Yes, it's a couple of extra import lines per component. In exchange, the bundler can finally see what your app actually uses instead of guessing, and the size graph rewards you for it.

React and Vue got off easy here: those adapters already register features through plain side-effect subpath imports, so there's no per-feature split to do. For them the v3-era migration is just the handful of renames and removals further down.

The numbers

The whole point, in two rows:

Endpoint Gzipped
Core (@toolbox-web/grid) ~42 KB
all (core + every feature) ~140 KB

Give that second number a second. A hundred and forty kilobytes gzipped for the entire feature set - virtualization, sorting, filtering, editing, grouping, pivot, tree data, master-detail, server-side row model, clipboard, undo/redo, export and print, all of it, in one self-contained file. For scale, AG Grid Community is north of ~200 KB gzipped on its own, and that's before you reach for the Enterprise-only features that are just… included here, at no cost.

That "self-contained" part is a happy accident - the kind you trip over rather than plan. Upgrading the bundler for v3, I discovered that all had never actually been all: it was a thin all.js of just the plugins, importing the core from a sibling index.js - two files wearing a trenchcoat, which is why you'd sometimes see it quoted at ~90 KB (the half that wasn't core). The new bundler folds the core in, so for the first time all really is all - one file, the whole grid - and the number finally counts it. Better still, the no-build, drop-in-a-script-tag recipe now works the way the docs always promised: one <script type="module" src="…/all.js">, no bundler, no import map, no second fetch to chase. Declare your columns in markup and you've got a full-quality grid with zero hand-written JavaScript - which more than a few of you have asked for.

But you almost never ship all, and you shouldn't. The realistic number is the ~42 KB core plus the two or three features a given screen actually needs. v3 is the release that finally made "plus only what you use" true all the way down to the byte.


The ergonomics: one grid, your framework's dialect

Bundle size gets the headlines, but the thing people actually write to me about is the day-to-day feel of using it. <tbw-grid> is a real Web Component, so the same element runs everywhere. The adapters don't make it work - it already works. They exist purely so the grid speaks your framework's dialect: JSX renderers in React, templates in Angular, render functions and slots in Vue.

Here's the same grid - a couple of columns, row selection, one editable cell, a commit handler - in each one. Notice how little there is to it.

Vanilla / TypeScript
import '@toolbox-web/grid';
import '@toolbox-web/grid/features/selection';
import '@toolbox-web/grid/features/editing';
import { createGrid } from '@toolbox-web/grid';

const grid = createGrid<Employee>({
  columns: [
    { field: 'name', header: 'Name' },
    { field: 'salary', header: 'Salary', type: 'number', editable: true },
  ],
  features: { selection: 'row', editing: true },
});
grid.rows = employees;
grid.on('cell-commit', (detail) => save(detail));
document.body.append(grid);
React
import '@toolbox-web/grid-react/features/selection';
import '@toolbox-web/grid-react/features/editing';
import { DataGrid } from '@toolbox-web/grid-react';

<DataGrid<Employee>
  rows={employees}
  columns={[
    { field: 'name', header: 'Name' },
    { field: 'salary', header: 'Salary', type: 'number', editable: true },
  ]}
  selection="row"
  editing
  onCellCommit={(e) => save(e.detail)}
/>;
Vue
<script setup lang="ts">
import '@toolbox-web/grid-vue/features/selection';
import '@toolbox-web/grid-vue/features/editing';
import { TbwGrid } from '@toolbox-web/grid-vue';
</script>

<template>
  <TbwGrid :rows="employees" :columns="columns" selection="row" editing @cell-commit="save" />
</template>
Angular
import { Grid } from '@toolbox-web/grid-angular';
import { GridSelectionDirective } from '@toolbox-web/grid-angular/features/selection';
import { GridEditingDirective } from '@toolbox-web/grid-angular/features/editing';

@Component({
  imports: [Grid, GridSelectionDirective, GridEditingDirective],
  template: `
    <tbw-grid
      [rows]="employees"
      [columns]="columns"
      selection="row"
      [editing]="true"
      (cellCommit)="save($event.detail)"
    />
  `,
})
export class EmployeesComponent {}

Same column shapes, same feature names, same event. The only thing that changes is the syntax your framework already made you learn. Pick it up once and you know it in all four.

Custom cells feel native

Two ways to render a custom cell, and both work in all four frameworks - neither is framework-exclusive.

A renderer on the column config takes whatever your framework renders: a node or string in vanilla, JSX in React, a VNode or component in Vue, a function or a component class in Angular. Same renderer key, same column config, everywhere:

// vanilla - return a string or a node
{ field: 'status', header: 'Status',
  renderer: (ctx) => `<span class="badge badge-${ctx.value}">${ctx.value}</span>` }
// React - return JSX
{ field: 'status', header: 'Status',
  renderer: ({ value }) => <span className={`badge badge-${value}`}>{value}</span> }
// Angular - hand it a component class (config, not template)
{ field: 'status', header: 'Status',
  renderer: BadgeComponent }

Or declare it inline in your markup - the same capability in each stack's native template syntax: a <tbw-grid-column-view> in vanilla, a render-function child in React, a #cell slot in Vue, and the *tbwRenderer directive in Angular:

// React - a renderer straight in the template
<GridColumn field="status">{(ctx) => <span className={`badge badge-${ctx.value}`}>{ctx.value}</span>}</GridColumn>
<!-- Angular - *tbwRenderer keeps the markup in your component -->
<tbw-grid-column field="status">
  <span *tbwRenderer="let value" [class]="'badge badge-' + value">{{ value }}</span>
</tbw-grid-column>

Config or markup, function or component - pick whatever keeps a given column readable. Every framework does both.

And here's the part that saves the most time: you only configure the columns that actually need it. With columnInference: 'merge' the grid derives the whole column set - and each column's type - from your data, then overlays whatever you did specify on top. So the badge column above can carry its renderer while every other column infers its header, type and default rendering. One column of config instead of twenty.

Barely any config, right down to zero JavaScript

The other end of the ergonomics spectrum: register the element, declare your columns as light-DOM HTML, and let the grid infer types from the data. No config object at all:

<script type="module">
  import '@toolbox-web/grid';
</script>

<tbw-grid>
  <tbw-grid-column field="name" header="Name" sortable></tbw-grid-column>
  <tbw-grid-column field="salary" header="Salary" type="number"></tbw-grid-column>
</tbw-grid>

Want the grid to page and virtualize data straight from a server, without writing a single fetch or dataSource function? Point it at an endpoint with data-src and turn on the server-side feature. One thing to get right: importing a feature only registers it - something still has to enable it. You can do that from JavaScript (grid.gridConfig = { features: { serverSide: true } }), or declaratively through the grid-config attribute, which keeps the whole page free of imperative code:

<script type="module">
  import '@toolbox-web/grid'; // defines <tbw-grid>
  import '@toolbox-web/grid/features/server-side'; // registers the feature
</script>

<tbw-grid grid-config='{"features":{"serverSide":true}}' data-src="/api/employees">
  <tbw-grid-column field="name" header="Name"></tbw-grid-column>
  <tbw-grid-column field="salary" header="Salary" type="number"></tbw-grid-column>
</tbw-grid>

The two module imports are setup, not logic - they define the element and register the feature. The grid-config attribute enables it (the grid parses that JSON into gridConfig for you), and the server-side feature reads data-src on attach and pages the endpoint: no fetch call, no dataSource plumbing, no event handlers. (Drop the features entry and data-src is inert, because the plugin never attaches - register-then-enable holds for every feature.) And when you do want full programmatic control, everything - light DOM, individual properties, and the whole gridConfig object - folds into a single effectiveConfig with a defined precedence order. There's never a "which config wins?" guessing game.


What I removed (and what to do about it)

Every symbol removed in v3 carried an @deprecated tag throughout the v2.x line. If you kept those warnings clean, this section is a checklist you can skim. If you didn't, it's your migration plan.

Events: string literals instead of enums

The DGEvents / PluginEvents enums are gone. Event names are now plain string literals, type-checked against DataGridEventMap, so you still get autocomplete and compile-time safety - with less ceremony.

// v2 (removed)
import { DGEvents, PluginEvents } from '@toolbox-web/grid';
grid.on(DGEvents.SORT_CHANGE, (detail) => {
  /* … */
});

// v3 - type-checked against DataGridEventMap
grid.on('sort-change', (detail) => {
  /* … */
});

reorder-rowsrow-drag-drop

The RowReorderPlugin and its plugins/reorder-rows subpath were renamed to RowDragDropPlugin / plugins/row-drag-drop. The old canDragRow and canMove callbacks collapsed into a single canDrag(row, index) veto that runs on drag start. In the framework adapters, the reorderRows prop/binding became rowDragDrop.

- import '@toolbox-web/grid-react/features/reorder-rows';
+ import '@toolbox-web/grid-react/features/row-drag-drop';

- <DataGrid reorderRows />
+ <DataGrid rowDragDrop />

Pinned rows: one unified slots[] API

The pile of ad-hoc pinned-row options (position, showRowCount, showSelectedCount, showFilteredCount, aggregationRows, customPanels) and the imperative addPanel / addAggregationRow methods all collapsed into a single declarative slots[] array. The no-argument new PinnedRowsPlugin() still seeds the familiar bottom info bar, so the common case is unchanged.

// v3 - the unified slots[] API
new PinnedRowsPlugin({
  slots: [{ position: 'bottom', aggregators: { price: 'sum' }, label: 'Total' }],
});

Server-side: clearer method names

cacheBlockSizepageSize, getNodeCount()getTotalNodeCount(), isLoaded(i)isNodeLoaded(i). Straight renames, no behavioural change.

The ssr prop is gone (React & Vue)

The ssr prop and the SSRProps type were no-ops - features register via SSR-safe side-effect imports, and <tbw-grid> is a custom element that hydrates normally on the client regardless. Delete the prop and the import; there's nothing to replace them with.

Shell delegates moved to the plugin instance

The convenience delegates on the grid element (grid.openToolPanel(...), grid.registerHeaderContent(...), grid.getToolPanels(), and friends) were removed. Reach the shell through its plugin instead:

const shell = grid.getPluginByName('shell');
shell?.openToolPanel('filters');

The full removal table - every v2 symbol and its v3 replacement - lives in the migration guide. If you only read one other document before upgrading, read that one.


New here? The short version

If this is your first look, the full pitch lives on the comparison page and in the v2 announcement, so I won't rehash all of it. The essentials: <tbw-grid> is a native Web Component that runs identically in vanilla, React, Angular, Vue or Svelte - the adapters are sugar, not glue. It's MIT with no Enterprise tier, so grouping, pivot, tree data, master-detail, server-side and clipboard are all just there. It's benchmarked head-to-head against AG Grid on that comparison page - run it in your own browser rather than trusting a screenshot. And it's in production apps today, hardened by a long v2.x line of real-world bug fixes.

The one thing worth calling out here, because v3 sharpened it: the errors. Miss a plugin and you get an import hint, not a minified stack trace. Enable a feature whose dependency isn't registered and you get a coded diagnostic (TBW020, TBW021, …) that names the missing piece. The shell going opt-in actually improved this - dependencies that used to be silently auto-satisfied now surface honestly, which is exactly the kind of paper-cut v3 set out to remove.


Should you upgrade?

Yes, and it should be a calm afternoon rather than a saga:

  1. Clear your v2.x deprecation warnings first, on v2. Every removed symbol was deprecated for the whole v2.x line, so fixing warnings before you bump the major turns the upgrade into a version-number change.
  2. Bump the core to ^3.0.0 and the matching adapter major.
  3. Add shell: true wherever you rely on the header bar or a tool panel - this is the one runtime behaviour change most likely to catch you.
  4. Angular only: import a feature directive for each feature you bind in a template, and pull base classes / inject helpers from their features/* subpath. A single missing import shows up as a cascade of member errors - fix the import first and they evaporate.
  5. Run your tests. The API removals are all compile-time, so TypeScript will walk you straight to anything you missed.

The full, adapter-by-adapter walkthrough with before/after code is in the v3 migration guide.


The short of it

If I had to compress v3 into a sentence: it made the promise the project started with actually true, instead of merely intended. A grid that does everything and a ~42 KB core. The features other grids paywall and an MIT license. One component that runs everywhere, with adapters that make each framework feel like home. v3 didn't add a new promise - it cleared away the last few things standing between you and the ones I'd already made.

Upgrade whenever you're ready. Poke at the live benchmark first if you're not. And if any of this ends up in something you build - genuinely, thank you. It's a one-person project, so if you feel like dropping a star on GitHub, it honestly makes my week.