<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[KanuniLabs]]></title><description><![CDATA[Measured notes on React and JavaScript data grids and pivot tables: a million rows, Web Workers, exports, charts. Originals at kanunilabs.com.]]></description><link>https://kanunilabs.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ac5f823e44eb9d0d88c6688/45ba2de4-b213-4c9e-a486-a241900d5d0e.png</url><title>KanuniLabs</title><link>https://kanunilabs.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 22 Oct 2026 22:41:02 GMT</lastBuildDate><atom:link href="https://kanunilabs.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Cell, batch, row, form or popup: five ways to edit a table in React, all in the free grid]]></title><description><![CDATA[Five ways to let users edit a table in React: cell, batch, row, form and popup, what each one hands your app, and the write-back four of them need.]]></description><link>https://kanunilabs.hashnode.dev/cell-batch-row-form-or-popup-five-ways-to-edit-a-table-in-react-all-in-the-free-grid</link><guid isPermaLink="true">https://kanunilabs.hashnode.dev/cell-batch-row-form-or-popup-five-ways-to-edit-a-table-in-react-all-in-the-free-grid</guid><dc:creator><![CDATA[Serdar Bayatlıca]]></dc:creator><pubDate>Mon, 19 Oct 2026 08:22:00 GMT</pubDate><content:encoded><![CDATA[<p>Most admin screens end up with an editable table, and the first question is not how to put an input in a cell. It is when a change counts. Should a price be saved the moment someone presses Enter, or only after they have checked the whole order? Should a customer record open in place, under the row, or in a dialog?</p>
<p>Our DataGrid answers that with one option, <code>editing.mode</code>, and since the 16 October release all five modes are in the free packages: <code>@kanunilabs/datagrid-react</code> 1.8.1 and <code>@kanunilabs/datagrid</code> 1.9.1 for plain JavaScript. No licence key, no watermark. This post goes through the five on the same 240 real order lines and shows exactly what each one hands to your code.</p>
<p>The <a href="https://kanunilabs.com/demos/edit-modes">demo</a> loads only the free package. The order lines are the last 240 product lines sold to customers in France in December 2011, from the <a href="https://archive.ics.uci.edu/dataset/502">Online Retail II</a> dataset. The screenshots use the Emerald colour scheme with the Elevated layout; you can switch both in the demo, or make your own scheme in the <a href="https://kanunilabs.com/theme-builder">Theme Builder</a>.</p>
<h2>The one option</h2>
<pre><code class="language-tsx">import { DataGrid } from '@kanunilabs/datagrid-react';
import '@kanunilabs/datagrid-react/styles.css';

&lt;DataGrid
  gridId="orders"
  dataSource={rows}
  rowKey="id"
  columns={columns}
  editing={{
    mode: 'batch', // 'cell' | 'batch' | 'row' | 'form' | 'popup'
    onSave: async (changes) =&gt; {
      await api.save(changes);
      setRows((current) =&gt;
        current.map((row) =&gt; {
          const change = changes.find((c) =&gt; c.rowKey === row.id);
          return change ? { ...row, ...change.values } : row;
        }),
      );
    },
  }}
/&gt;
</code></pre>
<p>Every change that reaches <code>onSave</code> carries the row key, <code>values</code> with the changed columns only, and <code>oldValues</code> with what they were before. Columns with <code>editable: false</code> stay closed. In the demo that is Invoice, Date, Stock code and Customer; only Product, Qty and Unit price can be changed.</p>
<h2>Cell: every value is saved on its own</h2>
<p><code>cell</code> is the free grid's default. Double-click a cell, press F2 or just start typing; Enter commits and moves down, Tab moves to the next editable cell, Esc cancels. The grid writes the new value into the row and calls <code>onSave</code> with that one change.</p>
<img src="https://kanunilabs.com/blog/react-data-grid-editing-modes/06-cell-log.png" alt="Cell mode: two edits, two separate calls to onSave, one change each" style="display:block;margin:0 auto" />

<p>Two edits in the screenshot, two calls: <code>{"quantity":24}</code> for row 1, then <code>{"price":2.1}</code> for row 3. This suits a table where each value stands on its own, such as a stock count or a list of settings, and where an auto-save is what users expect. It does not suit a change that only makes sense together with another one: the first half is saved before the second is typed.</p>
<h2>Batch: change many cells, save them together</h2>
<p>In <code>batch</code> mode nothing is saved while the user types. Changed cells get a small marker in their corner, and a Save / Cancel strip appears above the grid with the number of rows waiting.</p>
<img src="https://kanunilabs.com/blog/react-data-grid-editing-modes/01-batch-pending.png" alt="Batch mode: two changed cells marked, and a Save 2 row(s) button above the grid" style="display:block;margin:0 auto" />

<p>Save sends everything in one call: both rows, each with only the column that changed.</p>
<img src="https://kanunilabs.com/blog/react-data-grid-editing-modes/02-batch-saved.png" alt="After Save: one call to onSave with two rows, quantity for row 1 and price for row 3" style="display:block;margin:0 auto" />

<p>Cancel puts every cell back. This is the mode for a screen where someone reviews a set of numbers before committing them: a price list, a budget, an order that is checked line by line. It also gives your server one request to accept or refuse as a whole.</p>
<h2>Row: open one record in place</h2>
<p><code>row</code> opens every editable cell of one row at once, by double-click or by Edit row… in the row's right-click menu. The strip above the grid says which row is open, with Save row and Cancel.</p>
<img src="https://kanunilabs.com/blog/react-data-grid-editing-modes/03-row.png" alt="Row mode: row 2 open, its Product, Qty and Unit price cells turned into editors" style="display:block;margin:0 auto" />

<p>Only that row is saved. If the user has half-finished something elsewhere, it stays where it was. Use it when a record is one line of a few short fields and people edit one at a time.</p>
<h2>Form: the same record under the row</h2>
<p><code>form</code> lays the same row out as a labelled form, opened under the row in the table's flow. Ctrl+Enter saves and Esc cancels, and the buttons are on the form itself.</p>
<img src="https://kanunilabs.com/blog/react-data-grid-editing-modes/04-form.png" alt="Form mode: row 2 opens as a form under it, with labelled fields, Cancel and Save" style="display:block;margin:0 auto" />

<p>A form is easier to read than a row of narrow cells when a record has more fields than fit on the screen, or long values such as an address. Because it opens in place, the rows above and below stay visible for comparison.</p>
<h2>Popup: the same form in a dialog</h2>
<p><code>popup</code> puts that form in a dialog over the grid.</p>
<img src="https://kanunilabs.com/blog/react-data-grid-editing-modes/05-popup.png" alt="Popup mode: the same form in a dialog over the grid" style="display:block;margin:0 auto" />

<p>The table does not move, so this suits a long table where opening a form in the middle would push the rows the user was looking at off the screen. Row, form and popup share one editing session underneath, so Save and Cancel behave the same way in all three. They differ only in where the fields are drawn.</p>
<h2>What this costs</h2>
<p>In <code>cell</code> mode the grid writes the new value into your row object. In the other four it writes nothing into your rows: the change waits until Save, goes to <code>onSave</code>, and is then cleared from the grid. If your handler does not write it into your data, as the <code>setRows</code> call above does, the cells show the old values again after Save. That is the line every mode but <code>cell</code> needs, and the one most easily forgotten.</p>
<p>Throw from <code>onSave</code> and the changes stay pending, so a refused save loses nothing the user typed.</p>
<p>Two things we noticed while taking these screenshots, both on our to-do list: in the form and the dialog, the fields of <code>editable: false</code> columns are locked but look the same as the open ones; and in row mode the number editors align their values to the left while the cells align them to the right.</p>
<h2>Free and paid</h2>
<p>The five modes, the Save / Cancel strip, the edit form and the dialog, <code>valueParser</code> and <code>valueSetter</code>, and the same modes in plain JavaScript through <code>createGrid</code> are free. Validation rules, adding and deleting rows, per-row permissions, lookup and custom editors, undo and redo, and range selection with fill and paste are in the Enterprise edition. The <a href="https://kanunilabs.com/demos/order-editing">order editing demo</a> shows validation and undo on the same rows, and the <a href="https://kanunilabs.com/pricing">pricing page</a> has the rest.</p>
<h2>Try it</h2>
<p>Open the <a href="https://kanunilabs.com/demos/edit-modes">demo</a>, switch the mode, change a few quantities and prices and watch what <code>onSave</code> receives. The <a href="https://kanunilabs.com/docs/datagrid/editing">editing docs</a> cover each mode and its keys, and an <a href="https://kanunilabs.com/blog/editable-table-knowing-what-changed">earlier post</a> goes deeper into what a batch save should send to a server and what happens when the server says no.</p>
<hr />
<p><em>Disclosure: this article was drafted with AI assistance. Every screenshot was taken from the live demo, and every payload shown was logged by it, in October 2026.</em></p>
]]></content:encoded></item><item><title><![CDATA[Two example apps on our own React data grid and pivot table and the nine bugs they found]]></title><description><![CDATA[React data grid and pivot table, a CRM screen and a sales report, and the nine bugs in our own product that building them found.]]></description><link>https://kanunilabs.hashnode.dev/two-example-apps-on-our-own-react-data-grid-and-pivot-table-and-the-nine-bugs-they-found</link><guid isPermaLink="true">https://kanunilabs.hashnode.dev/two-example-apps-on-our-own-react-data-grid-and-pivot-table-and-the-nine-bugs-they-found</guid><category><![CDATA[React]]></category><category><![CDATA[Programming Blogs]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Serdar Bayatlıca]]></dc:creator><pubDate>Sun, 18 Oct 2026 13:40:52 GMT</pubDate><content:encoded><![CDATA[<p>For a long time every demo on kanunilabs.com was a small page with one feature on it: sorting here, grouping there, an Excel export on a third page. That shows a feature works. It does not show what a real screen built with the components feels like, or what happens when five features meet on the same table.</p>
<p>So we built two apps the way a customer would: a customer manager on the <a href="https://kanunilabs.com/docs/datagrid">DataGrid</a> and a sales report on the <a href="https://kanunilabs.com/docs/pivotgrid">PivotGrid</a>, both in React. They run in the browser on made-up data, with no sign-up, and nothing you type leaves the page. There is a one-minute video of both:</p>
<p><a class="embed-card" href="https://youtu.be/uOVkTqPYh60">https://youtu.be/uOVkTqPYh60</a></p>

<p>The rest of this post goes through each app screen by screen, then through the nine bugs in our own product that building them turned up.</p>
<h2>Customer Management</h2>
<p><a href="https://kanunilabs.com/demos/customer-management">Customer Management</a> is the customer module of a CRM: 2,500 accounts, each with a status, a segment, a region, an account owner, orders and revenue. The screenshots use the Lagoon colour scheme with the Framed layout, picked from the bar at the top of the app. Both come from the <a href="https://kanunilabs.com/theme-builder">Theme Builder</a>, where you can also make your own scheme.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ac5f823e44eb9d0d88c6688/6e832bb8-a312-41e9-aeef-0ab973b86dd4.png" alt="" style="display:block;margin:0 auto" />

<p>The five figures at the top are computed from the rows in view, so they follow every filter, search and saved edit. Under them are the saved views (All customers, At-risk accounts, Enterprise by revenue, By owner) and the quick filters. A status chip writes the same condition as the Status column's own header filter, and the sentence under the grid reads it back, so there is one filter, not three that can disagree.</p>
<p>Tick a few customers and a bar appears to reassign them to another owner, change their status or export only those rows. Cells are edited in place and wait in the grid until you press Save. Type a broken email address into a customer's row and the grid refuses it before it reaches the app's data.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ac5f823e44eb9d0d88c6688/94b1e331-5265-4364-806c-e1f874e0905f.png" alt="" style="display:block;margin:0 auto" />

<p>The arrow at the start of a row opens its detail panel: contact details, the five most recent orders and the account notes. The orders come from the same seeded history as the row's Orders and Revenue columns, so they add up to the cent.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ac5f823e44eb9d0d88c6688/73742007-cccc-4866-b037-596a234d880b.png" alt="" style="display:block;margin:0 auto" />

<p>The Chart button in the grid's toolbar opens the grid's own chart panel above the table: revenue by segment, over the rows in view. A click on the Mid-market bar filtered the table to Mid-market, as above; a second click clears it. The panel's toolbar changes the chart type and the column, downloads PNG or SVG and goes full screen.</p>
<h2>Sales Analytics</h2>
<p><a href="https://kanunilabs.com/demos/sales-analytics">Sales Analytics</a> is the report a sales team opens on Monday: 20,000 orders and 43,472 order lines of a made-up hardware and software supplier, in four regions, from January 2024 to September 2026. These screenshots use the Indigo scheme.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ac5f823e44eb9d0d88c6688/44612f08-dd73-4492-aa55-90fd8d2a6c25.png" alt="" style="display:block;margin:0 auto" />

<p>The filters at the top (period, region, category, channel) feed both the six figures and the pivot. The figures and the pivot read the same rows, and the app checks it: the line under the figures says total revenue equals the pivot's grand total, and gives the number. Five tabs load ready-made analyses (regional overview, category by month, year over year, rep performance, channel mix). Any of them can be rearranged by dragging fields between the filter, row, column and value areas.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ac5f823e44eb9d0d88c6688/0e294fb5-a37f-4015-83bb-7fce1fa7c9a3.png" alt="" style="display:block;margin:0 auto" />

<p>Click any number to see the order lines behind it. Above, the cell for Australia in 2026 opened a window with its 795 order lines, which can be searched and exported on their own.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ac5f823e44eb9d0d88c6688/e1abe05d-f621-483c-8721-e94692f830cb.png" alt="" style="display:block;margin:0 auto" />

<p>The chart under the pivot is drawn from the pivot's result, so it changes when the pivot does. A click on a bar or a slice filters the pivot to that country.</p>
<h2>Free and paid</h2>
<p>Most of what you see is in the free packages. In Customer Management: sorting by several columns, filtering, search, the column chooser, pinning, row selection, one level of grouping, footer totals, CSV export and colour schemes. Editing in place and saving in a batch is also free since the 16 October release. In Sales Analytics: the pivot itself, dragging fields, the filters, the display modes and CSV export.</p>
<p>The validation rule that refuses the broken email, the detail panel, the charts, the drill-down window, group subtotals, Excel export and the status bar are in the Enterprise edition. Both apps put an Enterprise tag next to each of those, so you never have to guess. Prices are on the <a href="https://kanunilabs.com/pricing">pricing page</a>.</p>
<h2>The nine bugs</h2>
<p>We expected the apps to show the product. We did not expect them to test it. Building them turned up nine bugs in our own packages, each of which had passed every existing test:</p>
<ol>
<li><p>A "count distinct" on a text field showed 1 in every cell of the pivot.</p>
</li>
<li><p>Exporting the pivot's rows to CSV or Excel XML stopped with an error when a field had a formatting function.</p>
</li>
<li><p>The field settings dialog had no "count distinct" option for text at all.</p>
</li>
<li><p>The pivot's chart ignored the "% of total" and running-total display modes and drew the raw sums.</p>
</li>
<li><p>The React pivot chart cut long category labels at a fixed 60 px.</p>
</li>
<li><p>The same chart wrote "14000k" on its value axis.</p>
</li>
<li><p>In the React DataGrid, clicking the arrow that opens a row's detail panel also selected the row.</p>
</li>
<li><p>The detail panel was as wide as all the columns together, so on a narrow screen it ran off the side.</p>
</li>
<li><p>Batch editing showed an empty toolbar strip when there was nothing to save.</p>
</li>
</ol>
<p>All nine are fixed and on npm since the 15 October release, and each fix came with a test that fails on the old code.</p>
<p>What we take from it: a one-feature demo tests a feature, and a real screen tests the places where features meet. Almost every bug above sat between two of them: a chart and a display mode, row selection and the detail panel, an export and a formatting function. Each piece had its own tests. Nothing tested them together until a screen needed both.</p>
<h2>Try it</h2>
<p>The two apps are at <a href="https://kanunilabs.com/demos/customer-management">/demos/customer-management</a> and <a href="https://kanunilabs.com/demos/sales-analytics">/demos/sales-analytics</a>. Change the colour scheme, tick a few customers, drag a field into the columns, click a number. If anything feels slow or wrong on your machine, we would like to hear about it on the <a href="https://kanunilabs.com/contact">contact page</a>.</p>
<p>For the parts underneath: the <a href="https://kanunilabs.com/docs/datagrid">DataGrid docs</a> cover filtering, saved views, editing and grouping, the <a href="https://kanunilabs.com/docs/pivotgrid">PivotGrid docs</a> cover fields, display modes and drill-down, and an <a href="https://kanunilabs.com/blog/pivot-report">earlier post</a> builds a sales report step by step.</p>
<hr />
<p><em>Disclosure: this article was drafted with AI assistance. Every number in it was read from the live apps and from our engineering log in October 2026. The customers, orders and figures in the apps are made up.</em></p>
]]></content:encoded></item><item><title><![CDATA[Server-side sorting and filtering for a React data grid, and five places your SQL quietly disagrees]]></title><description><![CDATA[Five ways a hand-written SQL endpoint disagrees with a React data grid, measured on 1,067,371 rows in PostgreSQL, and the indexes that fix the cost.]]></description><link>https://kanunilabs.hashnode.dev/server-side-sorting-and-filtering-for-a-react-data-grid-and-five-places-your-sql-quietly-disagrees</link><guid isPermaLink="true">https://kanunilabs.hashnode.dev/server-side-sorting-and-filtering-for-a-react-data-grid-and-five-places-your-sql-quietly-disagrees</guid><category><![CDATA[React]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[SQL]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Serdar Bayatlıca]]></dc:creator><pubDate>Fri, 16 Oct 2026 08:16:21 GMT</pubDate><content:encoded><![CDATA[<p>When the data is too big for the browser, or some users may only see some of it, the grid stops sorting and filtering in the browser. It sends your server the sort, the filters and the search, asks for 100 rows at a time and shows what comes back. The browser then has no way to know whether those 100 rows are right.</p>
<p>The endpoint behind it is usually written in an afternoon: turn the request into <code>WHERE</code>, <code>ORDER BY</code>, <code>LIMIT</code> and <code>OFFSET</code>, run it, return the rows and a count. This post runs that version against a real table and compares it with what the grid would have shown with the data in the browser. The data is the 1,067,371 invoice lines of <a href="https://archive.ics.uci.edu/dataset/502">Online Retail II</a>, a UK online gift shop's sales from December 2009 to December 2011, loaded into PostgreSQL 16 on a laptop. 243,007 of those lines have no customer number, and that turns out to matter.</p>
<img src="https://kanunilabs.com/blog/server-side-sorting-and-filtering-sql/01-grid.png?v=20261017" alt="A React data grid showing invoice lines from PostgreSQL: 1,067,371 rows, loaded 100 at a time" style="display:block;margin:0 auto" />

<p>The grid is our free DataGrid for React with its REST data source. The screenshots use the Teal colour scheme with the Ledger structure, both from the <a href="https://kanunilabs.com/theme-builder">Theme Builder</a>. The server half is <a href="https://kanunilabs.com/docs/datagrid/remote#the-server-side-ready-made-kanunilabsdatagrid-server"><code>@kanunilabs/datagrid-server</code></a>, which is also free. The sections below go through five ways the usual endpoint goes wrong, then what the correct version costs and the indexes that pay it back. <a href="https://kanunilabs.com/blog/where-client-side-sorting-and-filtering-stop-working">An earlier post</a> covers the browser half: what the grid sends and why.</p>
<h2>The page and the usual endpoint</h2>
<p>On the page, the grid gets a URL instead of an array:</p>
<pre><code class="language-tsx">import { DataGrid, createRestDataSource } from '@kanunilabs/datagrid-react';

const source = createRestDataSource({ url: '/api/invoices', distinctValues: true });

&lt;DataGrid dataSource={source} rowKey="id" columns={columns} toolbar filterRow headerFilter style={{ height: 640 }} /&gt;;
</code></pre>
<p>Each request carries <code>skip</code>, <code>take</code>, and the sort and filter as JSON. This is the endpoint most of us would write first. Every value is a bound parameter, so it looks safe:</p>
<pre><code class="language-js">const where = [], params = [];
const p = (v) =&gt; (params.push(v), `$${params.length}`);
for (const f of filters) {
  if (f.operator === 'contains') where.push(`${COL[f.columnId]} LIKE '%' || ${p(f.value)} || '%'`);
  if (f.operator === '&lt;&gt;') where.push(`${COL[f.columnId]} &lt;&gt; ${p(f.value)}`);
  if (f.operator === '=') where.push(`${COL[f.columnId]} = ${p(f.value)}`);
}
const order = sort.map((s) =&gt; `${COL[s.columnId]} ${s.direction}`).join(', ');
const sql = `SELECT … FROM invoices ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
             ${order ? 'ORDER BY ' + order : ''} LIMIT ${p(take)} OFFSET ${p(skip)}`;
</code></pre>
<h2>1. Rows that appear twice while scrolling</h2>
<p>Sort the grid by stock code and scroll. The same invoice line shows up again a few rows further down, and another line never shows up at all:</p>
<img src="https://kanunilabs.com/blog/server-side-sorting-and-filtering-sql/02-repeated-rows.png?v=20261017" alt="Hand-written SQL shows the same invoice line three times on one screen; the package shows each line once" style="display:block;margin:0 auto" />

<p>Thousands of lines share each stock code, and SQL does not promise any order among equal values. Every block of 100 is a separate query, and PostgreSQL is free to break the ties differently each time. We scrolled through the first 10,000 rows, which is 100 blocks. Sorted by stock code, about 2,250 lines appeared more than once, so at least 2,700 of the lines that belong there never appeared. Sorted by quantity it was about 2,100 and 2,650. The exact counts change from run to run, which is the problem. Sorted by country or by date there were none, because those columns had an index, and reading an index happens to return ties in the same order every time. That is why this passes testing on one column and fails on the next. With no sort at all, the endpoint has no <code>ORDER BY</code>, and the first page of the not-equal screenshot below begins at line 267,617.</p>
<p>The fix is one more sort term: end every <code>ORDER BY</code> with the row's unique key, here <code>id</code>. The package adds it to every query once you give it the <code>key</code>.</p>
<h2>2. "Not equal" drops the empty cells</h2>
<p>Filter the Customer column to <code>&lt;&gt;17841</code>. The grid should hide that one customer's lines. The hand-written endpoint hides 243,007 more:</p>
<img src="https://kanunilabs.com/blog/server-side-sorting-and-filtering-sql/03-not-equal.png?v=20261017" alt="Customer not equal to 17841: 811,267 rows from hand-written SQL, 1,054,274 from the package" style="display:block;margin:0 auto" />

<p>In SQL, <code>NULL &lt;&gt; 17841</code> is neither true nor false, so <code>WHERE</code> drops every line without a customer number. To the person using the grid, an empty cell is not equal to 17841, and that line should stay. The package writes <code>(customer_id IS NULL OR customer_id &lt;&gt; $1)</code>, and <code>= 0</code> does not match empty cells either.</p>
<h2>3. Empty values first</h2>
<p>Sort by customer, highest first. PostgreSQL puts <code>NULL</code> values first in descending order and last in ascending order, so the first 243,007 rows of the "highest customer" view have no customer at all. That is 2,430 full blocks of blank cells before the first number. MySQL and SQLite do the opposite, so the same endpoint behaves differently on another database.</p>
<img src="https://kanunilabs.com/blog/server-side-sorting-and-filtering-sql/04-empty-first.png?v=20261017" alt="Sorted by customer, highest first: hand-written SQL starts with empty cells, the package starts with customer 18287" style="display:block;margin:0 auto" />

<p>The grid's own sort puts empty values last in both directions. The package does the same on all four databases it supports.</p>
<h2>4. Search: capital letters and wildcards</h2>
<p>Type "heart" into the search box. The shop's catalogue is written in capital letters ("HEART OF WICKER SMALL"), and <code>LIKE</code> is case-sensitive in PostgreSQL, so the hand-written endpoint finds nothing. The package finds 99,653 lines:</p>
<img src="https://kanunilabs.com/blog/server-side-sorting-and-filtering-sql/05-search-case.png?v=20261017" alt="Searching &quot;heart&quot;: no rows from hand-written SQL, 99,653 from the package" style="display:block;margin:0 auto" />

<p>Search for "50%" in the description column and the opposite happens: <code>%</code> is a wildcard in <code>LIKE</code>, so the endpoint returns 7,565 lines containing "50", such as "SET OF THREE 50'S GIFT WRAPS", and none of them contains "50%". The package escapes <code>%</code> and <code>_</code>, and compares text without case. For <code>contains</code>, it also treats the Turkish dotted and dotless i as the same letter, the same way the grid does in the browser.</p>
<img src="https://kanunilabs.com/blog/server-side-sorting-and-filtering-sql/06-percent.png?v=20261017" alt="Description contains &quot;50%&quot;: 7,565 wrong rows from hand-written SQL, none from the package" style="display:block;margin:0 auto" />

<h2>5. The sort direction is SQL too</h2>
<p>The endpoint above at least looks column ids up in a map (<code>COL[...]</code>), but it pastes <code>s.direction</code> into the query as it arrives. A request whose direction is <code>asc, (SELECT 1 FROM pg_sleep(2))</code> made the database wait two seconds before answering (2,190 ms measured). Any expression can go where that <code>pg_sleep</code> is. Bound parameters protect values, not the parts of the query that are built as text.</p>
<p>The package treats the column map as its whitelist. A request naming any other column, such as <code>price; DROP TABLE invoices</code>, is refused with a 400 and the message <code>Unknown column "price; DROP TABLE invoices" in sort.</code>. The direction gets the same treatment: anything other than <code>asc</code> or <code>desc</code> is a 400 (<code>Unknown sort direction "asc, (SELECT 1 FROM pg_sleep(2))" — use "asc" or "desc".</code>), and neither is ever copied into the query.</p>
<h2>The same endpoint, with the package</h2>
<pre><code class="language-ts">import { createSqlLoader, SqlRequestError } from '@kanunilabs/datagrid-server';

const invoices = createSqlLoader({
  dialect: 'postgres', // 'mysql' | 'sqlite' | 'mssql'
  from: 'invoices',
  key: 'id',
  columns: {
    // nullable: false = NOT NULL in the table, so the SQL needs no NULL handling for it
    id: { type: 'number', nullable: false },
    invoice: { type: 'string', nullable: false },
    stockCode: { sql: 'stock_code', type: 'string', nullable: false },
    description: 'string',
    quantity: { type: 'number', nullable: false },
    invoiceDate: { sql: 'invoice_date', type: 'date', nullable: false },
    price: { type: 'number', nullable: false },
    customerId: { sql: 'customer_id', type: 'number', searchable: false },
    country: { type: 'string', nullable: false },
  },
  query: (sql, params) =&gt; pool.query(sql, params).then((r) =&gt; r.rows),
});

app.get('/api/invoices', async (req, res) =&gt; {
  try {
    res.json(await invoices.handle(req.query));
  } catch (e) {
    res.status(e instanceof SqlRequestError ? 400 : 500).json({ error: String(e) });
  }
});
</code></pre>
<p>It has no dependencies and brings no database driver: you pass the function that runs a query. It answers the rows, the total that sizes the scrollbar, footer totals, and the distinct values for the header filter's checklist (the <code>distinctValues: true</code> in the page code above). The package's tests run a list of filters and searches, the edge cases above among them, through the grid's in-browser engine and through a real SQLite database, and compare the rows.</p>
<h2>What it costs, and the indexes that pay it back</h2>
<p>Getting those cases right makes some of the SQL longer, and longer SQL can stop using your indexes. To compare text without case, both sides of an <code>=</code> go through <code>LOWER(REPLACE(...))</code>, and a plain index on <code>country</code> cannot answer that expression, so PostgreSQL reads the whole table. Sorting stays cheap: on PostgreSQL the package puts empty values last with <code>NULLS LAST</code>, and for a column marked <code>nullable: false</code> it writes a plain <code>ORDER BY invoice_date DESC, id</code>, which the existing index on <code>invoice_date</code> serves. Search needs a different kind of index: <code>LIKE '%heart%'</code> cannot use an ordinary one at all, whoever writes the query.</p>
<p>These are the times for one block of 100 rows plus the count, as the grid asks for it. Each is the median of seven runs on 1,067,371 rows, with the config above. Compare the package's two columns with each other more than with the first: the hand-written endpoint runs its page and count one after the other and searches two columns, while the package runs them side by side and searches four.</p>
<table>
<thead>
<tr>
<th>Request</th>
<th>Hand-written</th>
<th>Package, plain indexes</th>
<th>Package, matching indexes</th>
</tr>
</thead>
<tbody><tr>
<td>First block, no sort</td>
<td>48 ms</td>
<td>45 ms</td>
<td>57 ms</td>
</tr>
<tr>
<td>First block, newest first</td>
<td>52 ms</td>
<td>45 ms</td>
<td>56 ms</td>
</tr>
<tr>
<td>Block at row 500,000, newest first</td>
<td>91 ms</td>
<td>116 ms</td>
<td>91 ms</td>
</tr>
<tr>
<td>Block at row 1,000,000, newest first</td>
<td>163 ms</td>
<td>228 ms</td>
<td>207 ms</td>
</tr>
<tr>
<td>Country = France</td>
<td>1 ms</td>
<td>105 ms</td>
<td>5 ms</td>
</tr>
<tr>
<td>Search "heart"</td>
<td>198 ms (0 rows, wrong)</td>
<td>472 ms</td>
<td>100 ms</td>
</tr>
</tbody></table>
<p>Differences of 10 or 20 ms between runs are noise on a laptop; the rows that matter are the last two. The matching indexes come from the package itself. <code>indexStatements</code> takes the same object you pass to <code>createSqlLoader</code> and writes the <code>CREATE INDEX</code> statements for the expressions it queries with, for your migration:</p>
<pre><code class="language-ts">import { indexStatements } from '@kanunilabs/datagrid-server';

for (const sql of indexStatements(config, { columns: ['country', 'invoiceDate'] })) await pool.query(sql);
</code></pre>
<p>For this config it wrote five: one on the folded <code>country</code> for <code>=</code>, and one per sort direction for each column, ending in <code>id</code>. Search needs a trigram index per searchable column, which you write yourself:</p>
<pre><code class="language-sql">CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX ON invoices USING gin
  ((LOWER(REPLACE(REPLACE(REPLACE(COALESCE(description, ''), 'İ', 'i'), 'ı', 'i'), 'I', 'i'))) gin_trgm_ops);
</code></pre>
<p>An expression index is used only when the query contains the same expression: the same functions with the same literals, in the same order. <code>buildSqlPlan(request, config)</code> returns the statements a request would run without running them; the expression to copy is in its <code>page.sql</code>.</p>
<p>These indexes are not small, so pass only the columns users really filter or sort by. Here the five from <code>indexStatements</code> came to 153 MB and the four trigram indexes to 101 MB, next to a table of 123 MB, and building all nine took 19 seconds. The country <code>=</code> index alone is 7 MB and does nearly all of the work in its row. The four sort indexes are most of the 153 MB and bought little on this table, because the plain index on <code>invoice_date</code> already served the sort; they matter on columns that have no index yet.</p>
<h2>Limits</h2>
<ul>
<li><p><strong>Deep pages still cost more.</strong> <code>OFFSET 1000000</code> makes the database step over a million rows: even with the matching index, the block at row 1,000,000 took 207 ms against 56 ms for the first. A grid lets the user drag the scrollbar to any row, so it cannot use keyset paging, which only works from one page to the next.</p>
</li>
<li><p><strong>Every block is counted.</strong> The total sizes the scrollbar, so the package runs <code>COUNT(*)</code> with the same filters next to every block it serves. Run on their own, the count of this table took about 45 ms and the first page about 1 ms, so the count is nearly all of a block's time when nothing is filtered.</p>
</li>
<li><p><strong>Some things are the database's to decide:</strong> the sort order of text (your collation), and on SQLite, upper and lower case beyond ASCII.</p>
</li>
<li><p><strong>Free and paid.</strong> The server package, loading from the server, sorting, filtering, search, distinct values and footer totals are all free. Grouping, a tree and exporting the whole result from the server need the paid grid on the page; the server side is the same package.</p>
</li>
</ul>
<h2>Try it</h2>
<p>The <a href="https://kanunilabs.com/docs/datagrid/remote">server-side data guide</a> has the full contract and the package's options for PostgreSQL, MySQL, SQLite and SQL Server. The <a href="https://kanunilabs.com/demos/online-retail">sales dashboard demo</a> loads the same 1,067,371 lines into the browser instead, if you want to compare.</p>
<p>Data: Online Retail II, UCI Machine Learning Repository (Chen, 2019), CC BY 4.0.</p>
<hr />
<p><em>Disclosure: this article was drafted with AI assistance. Every number in it was measured against our packages (</em><code>@kanunilabs/datagrid-react</code> <em>1.8.0,</em> <code>@kanunilabs/datagrid-server</code> <em>1.1.0), PostgreSQL 16 and the published dataset in October 2026.</em></p>
]]></content:encoded></item><item><title><![CDATA[How to build a sales report your users can rearrange, with a React pivot table]]></title><description><![CDATA[A React pivot table report on 1,067,371 real invoice lines: best sellers per country, net of cancellations, average price, saved views, drill-down.]]></description><link>https://kanunilabs.hashnode.dev/how-to-build-a-sales-report-your-users-can-rearrange-with-a-react-pivot-table</link><guid isPermaLink="true">https://kanunilabs.hashnode.dev/how-to-build-a-sales-report-your-users-can-rearrange-with-a-react-pivot-table</guid><category><![CDATA[React]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Programming Blogs]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Serdar Bayatlıca]]></dc:creator><pubDate>Wed, 14 Oct 2026 09:53:16 GMT</pubDate><content:encoded><![CDATA[<p>A sales report starts as one table and turns into a queue of requests: the same numbers for another year, only the best sellers, the share instead of the amount, the invoices behind that one figure. Each request is a small change, and each one waits for a developer.</p>
<p>This post builds that report once, so that the person reading it can make those changes themselves. The data is real: the 1,067,371 invoice lines of the <a href="https://archive.ics.uci.edu/dataset/502">Online Retail II</a> dataset, a UK online gift shop's sales from December 2009 to December 2011, with invoice number, product, quantity, unit price, customer and country on every line. The table is our free PivotGrid for React, and everything below is in its Community edition.</p>
<p>Here is the finished report: the eight countries with the most revenue in 2011, inside each one its five best-selling products, with units sold, the average price paid and each line's share of the year.</p>
<img src="https://kanunilabs.com/blog/pivot-report/01-report.png?v=20261014" alt="The report: top countries of 2011 and their five best sellers, with revenue, units, average price and share of the year" style="display:block;margin:0 auto" />

<p>It is live at <a href="https://kanunilabs.com/demos/pivot-report">/demos/pivot-report</a>. The sections below take it apart in the order it was built: the fields, the best sellers, two numbers the raw data gets wrong, the share column, saved views, and the invoice lines behind a cell.</p>
<h2>The fields</h2>
<p>A pivot table is configured as a list of fields. Each one names a column of your data, an area (rows, columns, values or the filter area) and how to show it:</p>
<pre><code class="language-tsx">import { BasePivotGrid, type PivotField } from '@kanunilabs/pivotgrid-react';
import '@kanunilabs/pivotgrid-react/styles.css';

const fields: PivotField&lt;Line&gt;[] = [
  { id: 'country', dataField: 'country', caption: 'Country', dataType: 'string',
    area: 'row', areaIndex: 0, sortOrder: 'desc', sortBySummaryField: 'revenue', topN: 8 },
  { id: 'product', dataField: 'description', caption: 'Product', dataType: 'string',
    area: 'row', areaIndex: 1, sortOrder: 'desc', sortBySummaryField: 'revenue', topN: 5 },
  { id: 'revenue', dataField: 'revenue', caption: 'Revenue', dataType: 'number',
    area: 'data', areaIndex: 0, summaryType: 'sum' },
  { id: 'units', dataField: 'quantity', caption: 'Units', dataType: 'number',
    area: 'data', areaIndex: 1, summaryType: 'sum' },
  { id: 'year', dataField: 'year', caption: 'Year', dataType: 'string',
    area: 'filter', filterValues: new Set(['2011']), filterType: 'include' },
];

&lt;BasePivotGrid gridId="sales-report" data={lines} initialFields={fields}
  layout={{ rowHeaderLayout: 'tree' }} /&gt;;
</code></pre>
<p>Everything set here is also on screen. The year filter is a chip in the filter area with a filter icon on it; the sort is an arrow on the Country and Product chips. A user who wants 2010 opens the Year filter and ticks it, without anyone editing the field list.</p>
<h2>Best sellers per country</h2>
<p><code>sortBySummaryField: 'revenue'</code> orders countries by their revenue instead of alphabetically, and <code>topN: 8</code> keeps the first eight. The same two options on Product apply inside each country, so every country gets its own top five, not the shop's overall top five.</p>
<p>The cut changes what is listed, not what is counted. The grand total at the bottom is still the whole year, every country included: £9,032,994. The United Kingdom alone is 84.2% of it.</p>
<h2>Postage is the UK's best seller</h2>
<p>The first version of this report put <strong>DOTCOM POSTAGE</strong> at the top of the United Kingdom, ahead of every product. Germany had <strong>POSTAGE</strong> and <strong>Manual</strong> in its top five, Ireland <strong>Manual</strong> and <strong>CARRIAGE</strong>.</p>
<p>The dataset mixes products with everything else that goes through an invoice: postage, carriage, marketplace fees, bad-debt write-offs, bank charges, manual adjustments. Ten descriptions in all. A best-seller report should not count them at all, so they are removed with a prefilter, a condition applied to the source rows before anything is grouped or summed:</p>
<pre><code class="language-tsx">const NOT_PRODUCTS = ['DOTCOM POSTAGE', 'POSTAGE', 'CARRIAGE', 'AMAZON FEE', 'Adjust bad debt',
                      'Manual', 'Bank Charges', 'Discount', 'CRUK Commission', 'SAMPLES'];

&lt;BasePivotGrid /* … */ layout={{
  rowHeaderLayout: 'tree',
  prefilter: { logicalOperator: 'and',
               conditions: [{ fieldId: 'product', operator: 'notin', value: NOT_PRODUCTS }] },
}} /&gt;
</code></pre>
<p>Because it runs before aggregation, it changes the totals too: the year's grand total is product revenue, not product revenue plus postage minus fees.</p>
<h2>A cancelled order in the top five</h2>
<p>With postage gone, the next surprise was <strong>PAPER CRAFT, LITTLE BIRDIE</strong>: £168,470 and 80,995 units, the UK's best seller of 2011. One customer ordered 80,995 paper birds at 06:15 on 9 December 2011 and cancelled the order twelve minutes later.</p>
<p>Whether it shows up depends on one filter, the one on the Kind chip. The demo gives every line a Kind, read from its invoice number: a sale, a cancellation (numbers starting with C) or an adjustment (starting with A). Filter Kind to Sale and you are adding up orders, including the ones that were taken back; the paper birds win. Leave Kind unfiltered and cancellations subtract from the products they cancel; the paper birds net to £0 and drop out, and Regency Cakestand leads with £109,044.</p>
<img src="https://kanunilabs.com/blog/pivot-report/02-kind-filter.png?v=20261014" alt="The Kind filter with only Sale ticked" style="display:block;margin:0 auto" />

<img src="https://kanunilabs.com/blog/pivot-report/03-sales-only.png?v=20261014" alt="Sales only: the cancelled paper-bird order at the top of the UK" style="display:block;margin:0 auto" />

<p>The report uses net revenue. The Kind chip stays in the filter area, so anyone who needs gross sales can switch.</p>
<h2>Average price is computed after the sum</h2>
<p>The average price column is the one that is easiest to get wrong. Put the unit price in the values with <code>summaryType: 'avg'</code> and you get the average of the line prices: for Regency Cakestand in the UK, £14.07. But a line for one cake stand and a line for 192 count the same in that average. What customers actually paid per unit is revenue divided by units, £12.83, because the large orders were the cheaper ones.</p>
<p>That ratio has to be computed per cell, after revenue and units are summed. A summary-level calculated field does exactly that:</p>
<pre><code class="language-tsx">import { compileSafeFormula } from '@kanunilabs/pivotgrid-core';

const AVG = '[Revenue] / [Units]';
const avgPrice: PivotField&lt;Line&gt; = {
  id: 'avgPrice', caption: 'Avg price', dataType: 'number', area: 'data', areaIndex: 2,
  isCalculated: true, formulaType: 'summary', formulaString: AVG,
  calculateSummaryExpression: compileSafeFormula(AVG, new Map([['Revenue', 'revenue'], ['Units', 'units']]))!,
};
</code></pre>
<p>The formula language is deliberately small, field references, numbers and arithmetic, and it is compiled without <code>eval</code>, so a formula can come from a saved report without running code. Computed row by row instead, the same formula would divide each line and then add the quotients, which is a number with no meaning.</p>
<h2>Share of the year</h2>
<p>The last column is revenue again, shown as a percentage of the column total:</p>
<pre><code class="language-tsx">{ id: 'share', dataField: 'revenue', caption: 'Share of year', dataType: 'number',
  area: 'data', areaIndex: 3, summaryType: 'sum', summaryDisplayMode: 'percentOfColumnTotal' }
</code></pre>
<p>Users can make the same change without code: right-click a value chip, open Field Settings, and pick % of total, of row or of column, a running total, or the difference or % difference from the previous value.</p>
<img src="https://kanunilabs.com/blog/pivot-report/04-field-settings.png?v=20261014" alt="Field Settings, opened from a right-click on the Revenue chip" style="display:block;margin:0 auto" />

<h2>Let users keep their version</h2>
<p>Once users can rearrange a report, they will want to keep what they built. The Views menu in the footer saves the current fields and layout under a name, loads it back, updates it, and can set it as the default that opens next time. The grid also remembers its last state per <code>gridId</code> across reloads.</p>
<img src="https://kanunilabs.com/blog/pivot-report/05-views-menu.png?v=20261014" alt="The Views menu with the saved report" style="display:block;margin:0 auto" />

<p>By default this lives in the browser's localStorage, so a view saved on one laptop is not on the next one. To keep views per user on your server, pass <code>stateStoring={{ enabled: true, type: 'custom', onSave, onLoad }}</code> and store the state object yourself.</p>
<h2>Click a number, see the lines</h2>
<p>The question after "what are the numbers" is "where does this one come from". Pass <code>onCellClick</code> and the grid hands you the source rows behind the clicked cell:</p>
<pre><code class="language-tsx">&lt;BasePivotGrid
  /* … */
  onCellClick={(e) =&gt; {
    if (e.cellType === 'rowHeader' || e.cellType === 'colHeader') return;
    showLines(e.records ?? []);
  }}
/&gt;
</code></pre>
<p>What you render is up to you. The demo lists the 50 largest lines and adds them up next to the cell's value. For Regency Cakestand in the UK that is 1,686 invoice lines summing to £109,044, the same figure the cell shows.</p>
<img src="https://kanunilabs.com/blog/pivot-report/06-drill-down.gif?v=20261014" alt="Clicking a cell: the invoice lines behind it, with their sum beside the cell's value" style="display:block;margin:0 auto" />

<p>That lookup has a cost worth knowing. The grid does not keep the rows behind each cell; when the handler reads <code>e.records</code>, it finds them again, on the main thread, matching the cell's row and column path first and applying the filters only to those rows. Finding these 1,686 lines among a million took 43–93 ms in our browser. The cost follows the number of rows behind the cell: the United Kingdom's total, 453,888 lines, took 363–529 ms (that includes the demo sorting them). Fine for a click, but it is time the page cannot respond. A handler that never reads <code>records</code> pays nothing.</p>
<h2>How fast is the rest</h2>
<p>Every change to this report regroups all 1,067,371 rows. The aggregation runs in a Web Worker, off the thread that handles clicks and typing; on the live demo a recompute of this layout took 83–222 ms over five runs, most of them under 120. Our <a href="https://kanunilabs.com/blog/pivot-table-million-rows">previous post</a> measures that in detail, including the longest pause it causes on the page.</p>
<h2>Limits</h2>
<ul>
<li><p><strong>The data has to be in the browser.</strong> The demo downloads a 5.5 MB file once and decodes it into a million row objects. For data that should stay on the server, the paid edition can ask your server for the aggregates instead.</p>
</li>
<li><p><strong>Reading a cell's records runs on the main thread</strong>: tens of milliseconds for a product, around half a second for a whole country, as measured above.</p>
</li>
<li><p><strong>No "Others" row in this report.</strong> The top-N options can fold the cut countries and products into an "Other countries" row (<code>topNShowOthers</code>); this report leaves it off. Writing this post we found two bugs in it (under a second top-N level the outer Others row stayed empty, and a calculated ratio was added up instead of recomputed); both are fixed in PivotGrid 1.9.0.</p>
</li>
<li><p><strong>Paid parts.</strong> The formula designer that lets end users write calculated fields, a ready-made drill-down window, rendered charts and styled Excel/PDF export are in the paid edition. Everything in this report is free.</p>
</li>
</ul>
<h2>Try it</h2>
<p>The report is at <a href="https://kanunilabs.com/demos/pivot-report">/demos/pivot-report</a>: drag Month into the columns, untick a country in its filter, click any value. The <a href="https://kanunilabs.com/docs/pivotgrid">PivotGrid docs</a> cover fields, top-N, calculated fields, display modes and saved views for React and plain JavaScript.</p>
<p>Data: Online Retail II, UCI Machine Learning Repository (Chen, 2019), CC BY 4.0.</p>
<hr />
<p><em>Disclosure: this article was drafted with AI assistance. The demo and every number in it were measured against our packages and the published dataset in October 2026.</em></p>
]]></content:encoded></item><item><title><![CDATA[A pivot table your users can drag, on a million rows, and how to tell whether it freezes the page — KanuniLabs]]></title><description><![CDATA[A React pivot table on 1,067,371 rows in the browser: recompute time, the longest main-thread pause, and the string sort that froze the page.]]></description><link>https://kanunilabs.hashnode.dev/pivot-table-million-rows</link><guid isPermaLink="true">https://kanunilabs.hashnode.dev/pivot-table-million-rows</guid><category><![CDATA[React]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[performance]]></category><dc:creator><![CDATA[Serdar Bayatlıca]]></dc:creator><pubDate>Sat, 10 Oct 2026 07:52:59 GMT</pubDate><content:encoded><![CDATA[<p>Every reporting screen gets the same request after its first week: "Can I see this by month instead?" Then by product, then by country and month together. A pivot table is the answer that stops the requests, because the user drags the fields where they want them and gets the report they were about to ask for.</p>
<p>The catch is the data. Every drag regroups and re-sums every row. With a few thousand rows nobody notices. With a million, the obvious implementation freezes the page while it works, and a page that freezes on every drag is a page people stop dragging in.</p>
<p>This post measures that on real data: the 1,067,371 invoice lines of the <a href="https://archive.ics.uci.edu/dataset/502">Online Retail II</a> dataset, pivoted in the browser with our free PivotGrid. The <a href="https://kanunilabs.com/demos/pivot">demo</a> times every recompute in your own browser, so you can check each number here against your machine.</p>
<h2>The report a user builds</h2>
<p>Start from revenue by country and year, then drag Month into the columns or Product into the rows. Each drop recomputes the whole table:</p>
<img src="https://kanunilabs.com/blog/pivot-table-million-rows/01-country-year.png?v=20261010" alt="Revenue and units by country and year, built from 1,067,371 invoice lines" style="display:block;margin:0 auto" />

<p>In React that is one component and a list of fields; <code>area</code> says where each field starts, and the user moves them from there:</p>
<pre><code class="language-tsx">import { BasePivotGrid } from '@kanunilabs/pivotgrid-react';
import '@kanunilabs/pivotgrid-react/styles.css';

const fields = [
  { id: 'country', dataField: 'country', caption: 'Country', dataType: 'string', area: 'row' },
  { id: 'year', dataField: 'year', caption: 'Year', dataType: 'string', area: 'column' },
  { id: 'revenue', dataField: 'revenue', caption: 'Revenue', dataType: 'number', area: 'data', summaryType: 'sum' },
  { id: 'month', dataField: 'month', caption: 'Month', dataType: 'string', area: 'filter' },  // waiting to be dragged
  { id: 'product', dataField: 'description', caption: 'Product', dataType: 'string', area: 'filter' },
];

&lt;BasePivotGrid gridId="sales" data={rows} initialFields={fields} /&gt;;
</code></pre>
<h2>What a drag costs</h2>
<p>The demo measures two things on every recompute:</p>
<ul>
<li><p><strong>How long it takes</strong>, from the moment the pivot starts to the moment the new table is painted.</p>
</li>
<li><p><strong>The longest pause on the page's main thread</strong> in that window. The browser's Long Tasks API reports every task of 50 ms or more, and the main thread is the one that also handles your clicks and keys. Rendering the new table counts too.</p>
</li>
</ul>
<p>For country by year, or sales against cancellations by country, a recompute took 29 to 48 ms and no task on the main thread reached 50 ms. Drag Month into the columns and Year out, about 40 countries by 25 months, and it took 45 to 82 ms over three runs, with the longest pause between none and 54 ms:</p>
<img src="https://kanunilabs.com/blog/pivot-table-million-rows/02-measured.png?v=20261010" alt="The measurement panel after dragging Month into the columns: the recompute time and the longest main-thread pause" style="display:block;margin:0 auto" />

<p>The grouping and summing run in a Web Worker, a second thread the page starts for the pivot. The main thread only receives the finished totals and draws them.</p>
<h2>The obvious way, measured</h2>
<p>To see what the worker buys, the demo has a button that computes the same country-by-year revenue the way a quick implementation would: one pass over every row with a <code>Map</code>, on the main thread.</p>
<pre><code class="language-ts">const totals = new Map&lt;string, number&gt;();
for (const r of rows) {
  const key = `${r.country}|${r.year}`;
  totals.set(key, (totals.get(key) ?? 0) + r.revenue);
}
</code></pre>
<p>That loop froze the page for about 110 ms, longer than the pivot's whole recompute. Its totals are the same as the pivot's: £7,511,064 for the United Kingdom in 2011 and £134,790 for France in 2010, which we also checked with an independent computation over the source file.</p>
<p>A tenth of a second does not sound like much until you type through it. The demo has a text box for that: type while you drag fields, and the letters keep appearing; type while the loop runs, and they arrive all at once when it ends.</p>
<img src="https://kanunilabs.com/blog/pivot-table-million-rows/03-drag-and-measure.gif?v=20261010" alt="Two fields dragged with the mouse, each recompute timed in the panel; then the wide layout, and the plain loop on the main thread" style="display:block;margin:0 auto" />

<p>The worker does not make the work free. It moves it off the thread that answers the user.</p>
<h2>Where the page stopped, and the fix it led to</h2>
<p>Put Product in the rows and Month in the columns, about 5,000 products by 25 months, sorted by name, and the measurement changed: on October 7 the recompute took 350 to 650 ms and the main thread paused for 290 to 510 ms.</p>
<p>The aggregation was still in the worker, so we looked at what was left on the main thread. Sorting the same table by revenue instead of by name cut the pause to 105 to 183 ms. The difference was how the 5,000 product names were compared:</p>
<pre><code class="language-ts">// what the pivot's sort did before 1.7.2
names.sort((a, b) =&gt; a.localeCompare(b, locale, { numeric: true }));

// the same order, with the collator built once
const collator = new Intl.Collator(locale, { numeric: true });
names.sort(collator.compare);
</code></pre>
<p>Both give the same order. On 5,000 names, about 60,000 comparisons, the first took 206 to 226 ms and the second 4 to 5 ms, on the same machine. Called with an options object, <code>localeCompare</code> sets up its comparison rules again on every call; a collator sets them up once.</p>
<p>We shipped the second version in PivotGrid 1.7.2 on October 8 (<code>@kanunilabs/pivotgrid-react</code> 1.4.2). Measured again in the live demo, five loads each, the layout sorted by name now takes 151 to 196 ms with a longest pause of 97 to 118 ms, the same as sorted by revenue. You can check it at <a href="https://kanunilabs.com/demos/pivot?layout=product-month&amp;sort=name">/demos/pivot?layout=product-month&amp;sort=name</a>.</p>
<p>If you sort strings anywhere a user waits, a table header, a filter list, a dropdown, this one is worth checking in your own code.</p>
<p>The remaining 100 or so milliseconds were in the React grid, not in the pivot. After the first paint it measured the panel widths and header heights and kept them in React state, so the roughly 1,400 visible cells were drawn a second time in the same task, only to change two paddings and a few CSS variables. It also wrote the row-header width variables on the grid's outer box, where every cell inherits them, so each write invalidated the style of every cell and the next measurement forced a full style recalculation. Now the measurements go through refs and the variables sit only on the panel that reads them. On a local production build of this demo, ten switches to Product × Month, the median longest pause went from 89 to 51 ms and the recompute from 155 to 102 ms. That fix shipped in <code>@kanunilabs/pivotgrid-react</code> 1.5.0 on October 9.</p>
<img src="https://kanunilabs.com/blog/pivot-table-million-rows/04-wide-layout.png?v=20261010" alt="Product by month, best sellers first: the measurement panel after the recompute" style="display:block;margin:0 auto" />

<h2>Measure your own pivot</h2>
<p>You do not need our component to get these two numbers. Any pivot or table that recomputes on user input can be measured the same way:</p>
<pre><code class="language-ts">const longTasks: PerformanceEntry[] = [];
new PerformanceObserver((list) =&gt; longTasks.push(...list.getEntries()))
  .observe({ type: 'longtask', buffered: true });

const t0 = performance.now();
await recompute();                       // whatever your table does on a drag
requestAnimationFrame(() =&gt; requestAnimationFrame(() =&gt; {
  const t1 = performance.now();          // the new table has been painted
  const pause = Math.max(0, ...longTasks
    .filter((t) =&gt; t.startTime + t.duration &gt;= t0 &amp;&amp; t.startTime &lt;= t1)
    .map((t) =&gt; t.duration));
  console.log(`recompute ${t1 - t0} ms, longest main-thread pause ${pause} ms`);
}));
</code></pre>
<p>If the second number is large, your users feel it, whatever the first one says.</p>
<h2>What this costs</h2>
<ul>
<li><p><strong>It is free.</strong> Drag and drop, filters, sorting, % of totals and saved views are in the Community package, with no licence key and no watermark. Calculated fields in code and the rows behind a clicked cell are free too; the formula designer for end users, rendered charts, the ready-made drill-down window and styled Excel/PDF export are in the paid edition.</p>
</li>
<li><p><strong>The data has to be in the browser.</strong> The demo downloads a 5.5 MB file once and decodes it into a million row objects before the first pivot. For data that should stay on the server, the paid edition can ask your server for the aggregates instead.</p>
</li>
<li><p><strong>Wide layouts still pause the page.</strong> For 5,000 products by 25 months, after the two fixes above, the median longest pause was 51 ms on our local production build; slower machines see more. The panel in the demo shows what yours does.</p>
</li>
</ul>
<h2>Try it</h2>
<p>The demo is at <a href="https://kanunilabs.com/demos/pivot">/demos/pivot</a>; <a href="https://kanunilabs.com/demos/pivot?layout=product-month">/demos/pivot?layout=product-month</a> opens the wide layout, best sellers first, and adding <code>&amp;sort=name</code> sorts it by name. The <a href="https://kanunilabs.com/docs/pivotgrid">PivotGrid docs</a> cover fields, filters, summary types and the React and plain JavaScript setups.</p>
<p>Data: Online Retail II, UCI Machine Learning Repository (Chen, 2019), CC BY 4.0.</p>
<hr />
<p><em>Disclosure: this article was drafted with AI assistance. The demo and every number in it were measured against our packages and the published dataset in October 2026.</em></p>
]]></content:encoded></item></channel></rss>