<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: KanuniLabs</title>
    <description>The latest articles on DEV Community by KanuniLabs (@kanunilabs).</description>
    <link>https://dev.to/kanunilabs</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4135181%2Fb648af33-bbc6-4585-9343-f31804c2b8ca.jpg</url>
      <title>DEV Community: KanuniLabs</title>
      <link>https://dev.to/kanunilabs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kanunilabs"/>
    <language>en</language>
    <item>
      <title>A table that turns into cards on a phone: one option in a free React data grid — KanuniLabs</title>
      <dc:creator>KanuniLabs</dc:creator>
      <pubDate>Wed, 28 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kanunilabs/a-table-that-turns-into-cards-on-a-phone-one-option-in-a-free-react-data-grid-kanunilabs-223l</link>
      <guid>https://dev.to/kanunilabs/a-table-that-turns-into-cards-on-a-phone-one-option-in-a-free-react-data-grid-kanunilabs-223l</guid>
      <description>&lt;p&gt;Open a wide table on a phone and the same thing happens every time. The first two columns fill the screen, the rest are somewhere to the right, and as soon as you scroll sideways to read a price you lose the row you were on. Shrinking the text does not fix it, and neither does hiding columns, because the column you hid was the one somebody needed.&lt;/p&gt;

&lt;p&gt;A phone does not want a table. It wants a list where each item carries its own labels. The free DataGrid does that switch for you: below a width you choose, every row is drawn as a card, and above it you get the table back. The option is &lt;code&gt;cardView&lt;/code&gt;, it is in the free packages (&lt;code&gt;@kanunilabs/datagrid-react&lt;/code&gt; and &lt;code&gt;@kanunilabs/datagrid&lt;/code&gt; for plain JavaScript), and it needs no licence key.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://kanunilabs.com/demos/phone-table" rel="noopener noreferrer"&gt;demo&lt;/a&gt; lets you try it without a phone. The grid compares its own width with the breakpoint, not the screen's, so three buttons above it resize the box the grid sits in: phone, tablet and full width. The rows are the 240 order lines of &lt;a href="https://archive.ics.uci.edu/dataset/502" rel="noopener noreferrer"&gt;Online Retail II&lt;/a&gt; the earlier demos use, and one button loads all 1,067,371 lines. The screenshots use the Amber colour scheme with the Framed layout, both from the &lt;a href="https://kanunilabs.com/theme-builder" rel="noopener noreferrer"&gt;Theme Builder&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same grid, two shapes
&lt;/h2&gt;

&lt;p&gt;At full width this is an ordinary table: six columns, a header, a search box.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq7frnte61itf3r8u1lhr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq7frnte61itf3r8u1lhr.png" alt="The grid at full width: a table of order lines with Product, Invoice, Date, Qty, Unit price and Customer columns" width="800" height="593"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is the same grid in a 390 px box, the width of many phones held upright:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwnkjdr3qzgkcpoz3a620.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwnkjdr3qzgkcpoz3a620.png" alt="The same grid at 390 px: each order line is a card, with the product as its title and five labelled lines under it" width="800" height="593"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The product is the title, the other columns become labelled lines, and the column headers are gone. Nothing was written for this view. The code is the table's code with one more option:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DataGrid&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@kanunilabs/datagrid-react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@kanunilabs/datagrid-react/styles.css&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DataGrid&lt;/span&gt;
  &lt;span class="na"&gt;gridId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;
  &lt;span class="na"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;rowKey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;
  &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// the first column is each card's title&lt;/span&gt;
  &lt;span class="na"&gt;toolbar&lt;/span&gt; &lt;span class="c1"&gt;// quick search stays on top&lt;/span&gt;
  &lt;span class="na"&gt;cardView&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;breakpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onCardViewChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setCards&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cardView={true}&lt;/code&gt; uses the default breakpoint, 600 px. &lt;code&gt;columns&lt;/code&gt; inside the option lists the fields to show on a card and their order, if the card should carry fewer lines than the table has columns. &lt;code&gt;onCardViewChange&lt;/code&gt; tells your app when the grid crosses the breakpoint, which is useful if a toolbar button or a side panel should change with it. In our demo it only feeds the sentence above the grid.&lt;/p&gt;

&lt;p&gt;One decision is yours: the first column is the title, so put the column that says what the row is first. In an order list that is the product; in a customer list, the name. If your table starts with an ID, the cards will too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the breakpoint sits
&lt;/h2&gt;

&lt;p&gt;Because the grid measures itself, 600 px is not "a phone". It is a narrow grid. The same page at 720 px, a tablet held upright, stays a table:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmwqqwlmtuh7dd5iqmui0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmwqqwlmtuh7dd5iqmui0.png" alt="The grid in a 720 px box: still a table, because 720 is above the 600 px breakpoint" width="800" height="593"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And a grid in a 500 px side panel on a desktop switches to cards even though the screen is wide. That is usually what you want: a detail panel next to a list is as cramped as a phone. If you want the cards at every width, give the breakpoint &lt;code&gt;Infinity&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What keeps working
&lt;/h2&gt;

&lt;p&gt;Sorting, filtering, searching, selection, grouping, virtual scrolling and remote data work on cards. The headers give way to a Sort by control with a direction button:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxayj0c4sqsk521ume0k0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxayj0c4sqsk521ume0k0.png" alt="Sorted by Unit price from the Sort by control above the cards, with the direction button next to it" width="800" height="593"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The search box stays at the top, and the cards are the rows that match:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1j3brd6e7tiasu9stxdo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1j3brd6e7tiasu9stxdo.png" alt="Searching for MAGNETS: the cards that remain are the magnet packs" width="800" height="593"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We exercised sort and search in the demo; the others are in the docs' list and we did not build a screen for each.&lt;/p&gt;

&lt;h2&gt;
  
  
  A million cards
&lt;/h2&gt;

&lt;p&gt;Cards are taller than table rows, and a phone has less memory than a laptop, so we checked the case that would hurt. With all 1,067,371 lines loaded, the grid draws the cards on screen and nothing else:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr6tenjt911k660tkwgs1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr6tenjt911k660tkwgs1.png" alt="All 1,067,371 invoice lines as cards, parked in the middle of the list" width="799" height="586"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On a laptop with a production build, in Chrome, we measured three runs and give the range:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cards in the page with 240 rows, or with 1,067,371&lt;/td&gt;
&lt;td&gt;9 either way&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Elements inside the grid with 1,067,371 rows&lt;/td&gt;
&lt;td&gt;217&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One card (title and five lines)&lt;/td&gt;
&lt;td&gt;143 px tall&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Table to cards, from the click to the grid reporting cards&lt;/td&gt;
&lt;td&gt;117 to 126 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cards to table&lt;/td&gt;
&lt;td&gt;86 to 89 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jump from the top to the middle or the end of the list&lt;/td&gt;
&lt;td&gt;62 to 83 ms until the first card is drawn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search for LUNCH BAG across all 1,067,371 lines&lt;/td&gt;
&lt;td&gt;1.9 to 5.7 s until the first matching card&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JavaScript heap with the whole dataset loaded&lt;/td&gt;
&lt;td&gt;252 to 365 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Some caveats. Those are laptop numbers: we did not run them on a phone, and a phone will be slower, mostly on the search and the download. The search is the slow part: a quick search over a million lines took between two and six seconds, which is fine for a desk and long for a thumb. And the heap, 250 to 365 MB, is the cost of holding a million row objects in the browser, which is why a phone-sized screen with that much data usually belongs behind a &lt;a href="https://kanunilabs.com/blog/server-side-sorting-and-filtering-sql" rel="noopener noreferrer"&gt;server data source&lt;/a&gt;. The cards themselves cost almost nothing: nine of them are in the page whatever the row count.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes on a card
&lt;/h2&gt;

&lt;p&gt;Anything that needs a cell stands down while the cards are shown: in-cell editing, range selection, the fill handle and column resizing. A card shows each field's text, with the column's formatter applied, so a price reads as £0.85, but a &lt;code&gt;renderCell&lt;/code&gt; you wrote for the table is not used on a card. While cards are shown they also replace &lt;code&gt;renderRow&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is the honest limit. If a screen has to edit rows on a phone, the card is a place to read and pick, and the edit goes through a form of your own, or through the &lt;a href="https://kanunilabs.com/blog/react-data-grid-editing-modes" rel="noopener noreferrer"&gt;edit modes&lt;/a&gt; that open a record as a form or a dialog.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free and paid
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;cardView&lt;/code&gt;, &lt;code&gt;onCardViewChange&lt;/code&gt;, the Sort by control, quick search, sorting, selection, virtual scrolling and remote data are in the free packages, in React and in plain JavaScript through &lt;code&gt;createGrid&lt;/code&gt;. No licence key and no watermark. The things that stand down on a card, range selection and the fill handle, are Enterprise features on a table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Open the &lt;a href="https://kanunilabs.com/demos/phone-table" rel="noopener noreferrer"&gt;demo&lt;/a&gt; on your phone and the grid will already be cards, since the box is as wide as your screen. On a laptop, switch the width, load the full dataset and scroll. The option is described under &lt;a href="https://kanunilabs.com/docs/datagrid/features#cards-on-narrow-screens" rel="noopener noreferrer"&gt;cards on narrow screens&lt;/a&gt; in the docs.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: this article was drafted with AI assistance. Every screenshot and every number in the table came from a production build of the demo, run on a laptop in October 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>javascript</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Server-side sorting and filtering for a React data grid, and five places your SQL quietly disagree</title>
      <dc:creator>KanuniLabs</dc:creator>
      <pubDate>Sat, 17 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/kanunilabs/server-side-sorting-and-filtering-for-a-react-data-grid-and-five-places-your-sql-quietly-disagree-3oc6</link>
      <guid>https://dev.to/kanunilabs/server-side-sorting-and-filtering-for-a-react-data-grid-and-five-places-your-sql-quietly-disagree-3oc6</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The endpoint behind it is usually written in an afternoon: turn the request into &lt;code&gt;WHERE&lt;/code&gt;, &lt;code&gt;ORDER BY&lt;/code&gt;, &lt;code&gt;LIMIT&lt;/code&gt; and &lt;code&gt;OFFSET&lt;/code&gt;, 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 &lt;a href="https://archive.ics.uci.edu/dataset/502" rel="noopener noreferrer"&gt;Online Retail II&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxlzflocgbjvq8g5v3m4u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxlzflocgbjvq8g5v3m4u.png" alt="A React data grid showing invoice lines from PostgreSQL: 1,067,371 rows, loaded 100 at a time" width="800" height="481"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://kanunilabs.com/theme-builder" rel="noopener noreferrer"&gt;Theme Builder&lt;/a&gt;. The server half is &lt;a href="https://kanunilabs.com/docs/datagrid/remote#the-server-side-ready-made-kanunilabsdatagrid-server" rel="noopener noreferrer"&gt;&lt;code&gt;@kanunilabs/datagrid-server&lt;/code&gt;&lt;/a&gt;, 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. &lt;a href="https://kanunilabs.com/blog/where-client-side-sorting-and-filtering-stop-working" rel="noopener noreferrer"&gt;An earlier post&lt;/a&gt; covers the browser half: what the grid sends and why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The page and the usual endpoint
&lt;/h2&gt;

&lt;p&gt;On the page, the grid gets a URL instead of an array:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DataGrid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;createRestDataSource&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@kanunilabs/datagrid-react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createRestDataSource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/invoices&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;distinctValues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DataGrid&lt;/span&gt; &lt;span class="na"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;rowKey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt; &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;toolbar&lt;/span&gt; &lt;span class="na"&gt;filterRow&lt;/span&gt; &lt;span class="na"&gt;headerFilter&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;640&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each request carries &lt;code&gt;skip&lt;/code&gt;, &lt;code&gt;take&lt;/code&gt;, 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;where&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s2"&gt;`$&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;operator&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contains&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;where&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;COL&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;columnId&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt; LIKE '%' || &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;p&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; || '%'`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;operator&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;where&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;COL&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;columnId&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt; &amp;lt;&amp;gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;p&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;operator&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;where&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;COL&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;columnId&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt; = &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;p&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;COL&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;columnId&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`SELECT … FROM invoices &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;where&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WHERE &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;where&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; AND &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
             &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ORDER BY &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; LIMIT &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;p&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;take&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; OFFSET &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;p&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  1. Rows that appear twice while scrolling
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcas1nd38qvpej6zcchue.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcas1nd38qvpej6zcchue.png" alt="Hand-written SQL shows the same invoice line three times on one screen; the package shows each line once" width="800" height="1034"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;ORDER BY&lt;/code&gt;, and the first page of the not-equal screenshot below begins at line 267,617.&lt;/p&gt;

&lt;p&gt;The fix is one more sort term: end every &lt;code&gt;ORDER BY&lt;/code&gt; with the row's unique key, here &lt;code&gt;id&lt;/code&gt;. The package adds it to every query once you give it the &lt;code&gt;key&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. "Not equal" drops the empty cells
&lt;/h2&gt;

&lt;p&gt;Filter the Customer column to &lt;code&gt;&amp;lt;&amp;gt;17841&lt;/code&gt;. The grid should hide that one customer's lines. The hand-written endpoint hides 243,007 more:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ofujjwdv2kbaveir9p3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ofujjwdv2kbaveir9p3.png" alt="Customer not equal to 17841: 811,267 rows from hand-written SQL, 1,054,274 from the package" width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In SQL, &lt;code&gt;NULL &amp;lt;&amp;gt; 17841&lt;/code&gt; is neither true nor false, so &lt;code&gt;WHERE&lt;/code&gt; 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 &lt;code&gt;(customer_id IS NULL OR customer_id &amp;lt;&amp;gt; $1)&lt;/code&gt;, and &lt;code&gt;= 0&lt;/code&gt; does not match empty cells either.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Empty values first
&lt;/h2&gt;

&lt;p&gt;Sort by customer, highest first. PostgreSQL puts &lt;code&gt;NULL&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvw0gqb7du2fbem52l8x0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvw0gqb7du2fbem52l8x0.png" alt="Sorted by customer, highest first: hand-written SQL starts with empty cells, the package starts with customer 18287" width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The grid's own sort puts empty values last in both directions. The package does the same on all four databases it supports.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Search: capital letters and wildcards
&lt;/h2&gt;

&lt;p&gt;Type "heart" into the search box. The shop's catalogue is written in capital letters ("HEART OF WICKER SMALL"), and &lt;code&gt;LIKE&lt;/code&gt; is case-sensitive in PostgreSQL, so the hand-written endpoint finds nothing. The package finds 99,653 lines:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdikczd8qieq5nexu1osw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdikczd8qieq5nexu1osw.png" alt="Searching " width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Search for "50%" in the description column and the opposite happens: &lt;code&gt;%&lt;/code&gt; is a wildcard in &lt;code&gt;LIKE&lt;/code&gt;, 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 &lt;code&gt;%&lt;/code&gt; and &lt;code&gt;_&lt;/code&gt;, and compares text without case. For &lt;code&gt;contains&lt;/code&gt;, it also treats the Turkish dotted and dotless i as the same letter, the same way the grid does in the browser.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa77y6dovqbcfxzwylbzj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa77y6dovqbcfxzwylbzj.png" alt="Description contains " width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The sort direction is SQL too
&lt;/h2&gt;

&lt;p&gt;The endpoint above at least looks column ids up in a map (&lt;code&gt;COL[...]&lt;/code&gt;), but it pastes &lt;code&gt;s.direction&lt;/code&gt; into the query as it arrives. A request whose direction is &lt;code&gt;asc, (SELECT 1 FROM pg_sleep(2))&lt;/code&gt; made the database wait two seconds before answering (2,190 ms measured). Any expression can go where that &lt;code&gt;pg_sleep&lt;/code&gt; is. Bound parameters protect values, not the parts of the query that are built as text.&lt;/p&gt;

&lt;p&gt;The package treats the column map as its whitelist. A request naming any other column, such as &lt;code&gt;price; DROP TABLE invoices&lt;/code&gt;, is refused with a 400 and the message &lt;code&gt;Unknown column "price; DROP TABLE invoices" in sort.&lt;/code&gt;. The direction gets the same treatment: anything other than &lt;code&gt;asc&lt;/code&gt; or &lt;code&gt;desc&lt;/code&gt; is a 400 (&lt;code&gt;Unknown sort direction "asc, (SELECT 1 FROM pg_sleep(2))" — use "asc" or "desc".&lt;/code&gt;), and neither is ever copied into the query.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same endpoint, with the package
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createSqlLoader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SqlRequestError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@kanunilabs/datagrid-server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

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

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/invoices&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;SqlRequestError&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;distinctValues: true&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs, and the indexes that pay it back
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;=&lt;/code&gt; go through &lt;code&gt;LOWER(REPLACE(...))&lt;/code&gt;, and a plain index on &lt;code&gt;country&lt;/code&gt; cannot answer that expression, so PostgreSQL reads the whole table. Sorting stays cheap: on PostgreSQL the package puts empty values last with &lt;code&gt;NULLS LAST&lt;/code&gt;, and for a column marked &lt;code&gt;nullable: false&lt;/code&gt; it writes a plain &lt;code&gt;ORDER BY invoice_date DESC, id&lt;/code&gt;, which the existing index on &lt;code&gt;invoice_date&lt;/code&gt; serves. Search needs a different kind of index: &lt;code&gt;LIKE '%heart%'&lt;/code&gt; cannot use an ordinary one at all, whoever writes the query.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Hand-written&lt;/th&gt;
&lt;th&gt;Package, plain indexes&lt;/th&gt;
&lt;th&gt;Package, matching indexes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First block, no sort&lt;/td&gt;
&lt;td&gt;48 ms&lt;/td&gt;
&lt;td&gt;45 ms&lt;/td&gt;
&lt;td&gt;57 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First block, newest first&lt;/td&gt;
&lt;td&gt;52 ms&lt;/td&gt;
&lt;td&gt;45 ms&lt;/td&gt;
&lt;td&gt;56 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Block at row 500,000, newest first&lt;/td&gt;
&lt;td&gt;91 ms&lt;/td&gt;
&lt;td&gt;116 ms&lt;/td&gt;
&lt;td&gt;91 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Block at row 1,000,000, newest first&lt;/td&gt;
&lt;td&gt;163 ms&lt;/td&gt;
&lt;td&gt;228 ms&lt;/td&gt;
&lt;td&gt;207 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Country = France&lt;/td&gt;
&lt;td&gt;1 ms&lt;/td&gt;
&lt;td&gt;105 ms&lt;/td&gt;
&lt;td&gt;5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search "heart"&lt;/td&gt;
&lt;td&gt;198 ms (0 rows, wrong)&lt;/td&gt;
&lt;td&gt;472 ms&lt;/td&gt;
&lt;td&gt;100 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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. &lt;code&gt;indexStatements&lt;/code&gt; takes the same object you pass to &lt;code&gt;createSqlLoader&lt;/code&gt; and writes the &lt;code&gt;CREATE INDEX&lt;/code&gt; statements for the expressions it queries with, for your migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;indexStatements&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@kanunilabs/datagrid-server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;indexStatements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;country&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invoiceDate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For this config it wrote five: one on the folded &lt;code&gt;country&lt;/code&gt; for &lt;code&gt;=&lt;/code&gt;, and one per sort direction for each column, ending in &lt;code&gt;id&lt;/code&gt;. Search needs a trigram index per searchable column, which you write yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;pg_trgm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;
  &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;REPLACE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;REPLACE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;REPLACE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'İ'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'i'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'ı'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'i'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'I'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'i'&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="n"&gt;gin_trgm_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An expression index is used only when the query contains the same expression: the same functions with the same literals, in the same order. &lt;code&gt;buildSqlPlan(request, config)&lt;/code&gt; returns the statements a request would run without running them; the expression to copy is in its &lt;code&gt;page.sql&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;These indexes are not small, so pass only the columns users really filter or sort by. Here the five from &lt;code&gt;indexStatements&lt;/code&gt; 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 &lt;code&gt;=&lt;/code&gt; 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 &lt;code&gt;invoice_date&lt;/code&gt; already served the sort; they matter on columns that have no index yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deep pages still cost more.&lt;/strong&gt; &lt;code&gt;OFFSET 1000000&lt;/code&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every block is counted.&lt;/strong&gt; The total sizes the scrollbar, so the package runs &lt;code&gt;COUNT(*)&lt;/code&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some things are the database's to decide:&lt;/strong&gt; the sort order of text (your collation), and on SQLite, upper and lower case beyond ASCII.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free and paid.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://kanunilabs.com/docs/datagrid/remote" rel="noopener noreferrer"&gt;server-side data guide&lt;/a&gt; has the full contract and the package's options for PostgreSQL, MySQL, SQLite and SQL Server. The &lt;a href="https://kanunilabs.com/demos/online-retail" rel="noopener noreferrer"&gt;sales dashboard demo&lt;/a&gt; loads the same 1,067,371 lines into the browser instead, if you want to compare.&lt;/p&gt;

&lt;p&gt;Data: Online Retail II, UCI Machine Learning Repository (Chen, 2019), CC BY 4.0.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: this article was drafted with AI assistance. Every number in it was measured against our packages (&lt;code&gt;@kanunilabs/datagrid-react&lt;/code&gt; 1.8.0, &lt;code&gt;@kanunilabs/datagrid-server&lt;/code&gt; 1.1.0), PostgreSQL 16 and the published dataset in October 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>postgres</category>
      <category>sql</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to build a sales report your users can rearrange, with a React pivot table</title>
      <dc:creator>KanuniLabs</dc:creator>
      <pubDate>Wed, 14 Oct 2026 09:47:19 +0000</pubDate>
      <link>https://dev.to/kanunilabs/how-to-build-a-sales-report-your-users-can-rearrange-with-a-react-pivot-table-16bm</link>
      <guid>https://dev.to/kanunilabs/how-to-build-a-sales-report-your-users-can-rearrange-with-a-react-pivot-table-16bm</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://archive.ics.uci.edu/dataset/502" rel="noopener noreferrer"&gt;Online Retail II&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqfj0wc6bxfl4cstd965p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqfj0wc6bxfl4cstd965p.png" alt="The report: top countries of 2011 and their five best sellers, with revenue, units, average price and share of the year" width="800" height="468"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It is live at &lt;a href="https://kanunilabs.com/demos/pivot-report" rel="noopener noreferrer"&gt;/demos/pivot-report&lt;/a&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fields
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BasePivotGrid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PivotField&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@kanunilabs/pivotgrid-react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@kanunilabs/pivotgrid-react/styles.css&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PivotField&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Line&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;country&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;country&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Country&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;row&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;areaIndex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sortOrder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;desc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sortBySummaryField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;topN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;product&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;description&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Product&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;row&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;areaIndex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sortOrder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;desc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sortBySummaryField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;topN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;areaIndex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;summaryType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sum&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;units&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;quantity&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Units&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;areaIndex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;summaryType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sum&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;year&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;year&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Year&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;filter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;filterValues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2011&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="na"&gt;filterType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;include&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;BasePivotGrid&lt;/span&gt; &lt;span class="na"&gt;gridId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"sales-report"&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;initialFields&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;rowHeaderLayout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tree&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best sellers per country
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;sortBySummaryField: 'revenue'&lt;/code&gt; orders countries by their revenue instead of alphabetically, and &lt;code&gt;topN: 8&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Postage is the UK's best seller
&lt;/h2&gt;

&lt;p&gt;The first version of this report put &lt;strong&gt;DOTCOM POSTAGE&lt;/strong&gt; at the top of the United Kingdom, ahead of every product. Germany had &lt;strong&gt;POSTAGE&lt;/strong&gt; and &lt;strong&gt;Manual&lt;/strong&gt; in its top five, Ireland &lt;strong&gt;Manual&lt;/strong&gt; and &lt;strong&gt;CARRIAGE&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NOT_PRODUCTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DOTCOM POSTAGE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POSTAGE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CARRIAGE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AMAZON FEE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Adjust bad debt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Manual&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bank Charges&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Discount&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CRUK Commission&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SAMPLES&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;BasePivotGrid&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt; &lt;span class="na"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;rowHeaderLayout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tree&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;prefilter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;logicalOperator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;and&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;fieldId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;product&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NOT_PRODUCTS&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  A cancelled order in the top five
&lt;/h2&gt;

&lt;p&gt;With postage gone, the next surprise was &lt;strong&gt;PAPER CRAFT, LITTLE BIRDIE&lt;/strong&gt;: £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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwqc5pmqoeosuxrv34vlf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwqc5pmqoeosuxrv34vlf.png" alt="The Kind filter with only Sale ticked" width="800" height="569"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxi4mh0v4qvlefcytny0s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxi4mh0v4qvlefcytny0s.png" alt="Sales only: the cancelled paper-bird order at the top of the UK" width="800" height="468"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The report uses net revenue. The Kind chip stays in the filter area, so anyone who needs gross sales can switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Average price is computed after the sum
&lt;/h2&gt;

&lt;p&gt;The average price column is the one that is easiest to get wrong. Put the unit price in the values with &lt;code&gt;summaryType: 'avg'&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;That ratio has to be computed per cell, after revenue and units are summed. A summary-level calculated field does exactly that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;compileSafeFormula&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@kanunilabs/pivotgrid-core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AVG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[Revenue] / [Units]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;avgPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PivotField&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Line&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;avgPrice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Avg price&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;areaIndex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;isCalculated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;formulaType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;summary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;formulaString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AVG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;calculateSummaryExpression&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;compileSafeFormula&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AVG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;([[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Units&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;units&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]]))&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The formula language is deliberately small, field references, numbers and arithmetic, and it is compiled without &lt;code&gt;eval&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Share of the year
&lt;/h2&gt;

&lt;p&gt;The last column is revenue again, shown as a percentage of the column total:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;share&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Share of year&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;areaIndex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;summaryType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sum&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;summaryDisplayMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;percentOfColumnTotal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj6k2tqylfvudls9d1ytz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj6k2tqylfvudls9d1ytz.png" alt="Field Settings, opened from a right-click on the Revenue chip" width="800" height="569"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Let users keep their version
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;gridId&lt;/code&gt; across reloads.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0re06nob9x8ikloxqbae.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0re06nob9x8ikloxqbae.png" alt="The Views menu with the saved report" width="800" height="569"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;stateStoring={{ enabled: true, type: 'custom', onSave, onLoad }}&lt;/code&gt; and store the state object yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Click a number, see the lines
&lt;/h2&gt;

&lt;p&gt;The question after "what are the numbers" is "where does this one come from". Pass &lt;code&gt;onCellClick&lt;/code&gt; and the grid hands you the source rows behind the clicked cell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;BasePivotGrid&lt;/span&gt;
  &lt;span class="cm"&gt;/* … */&lt;/span&gt;
  &lt;span class="na"&gt;onCellClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cellType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rowHeader&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cellType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;colHeader&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;showLines&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;records&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjej2a82za7uy75isl9bq.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjej2a82za7uy75isl9bq.gif" alt="Clicking a cell: the invoice lines behind it, with their sum beside the cell's value" width="760" height="539"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That lookup has a cost worth knowing. The grid does not keep the rows behind each cell; when the handler reads &lt;code&gt;e.records&lt;/code&gt;, 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 &lt;code&gt;records&lt;/code&gt; pays nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How fast is the rest
&lt;/h2&gt;

&lt;p&gt;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 &lt;a href="https://kanunilabs.com/blog/pivot-table-million-rows" rel="noopener noreferrer"&gt;previous post&lt;/a&gt; measures that in detail, including the longest pause it causes on the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The data has to be in the browser.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reading a cell's records runs on the main thread&lt;/strong&gt;: tens of milliseconds for a product, around half a second for a whole country, as measured above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No "Others" row in this report.&lt;/strong&gt; The top-N options can fold the cut countries and products into an "Other countries" row (&lt;code&gt;topNShowOthers&lt;/code&gt;); 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paid parts.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The report is at &lt;a href="https://kanunilabs.com/demos/pivot-report" rel="noopener noreferrer"&gt;/demos/pivot-report&lt;/a&gt;: drag Month into the columns, untick a country in its filter, click any value. The &lt;a href="https://kanunilabs.com/docs/pivotgrid" rel="noopener noreferrer"&gt;PivotGrid docs&lt;/a&gt; cover fields, top-N, calculated fields, display modes and saved views for React and plain JavaScript.&lt;/p&gt;

&lt;p&gt;Data: Online Retail II, UCI Machine Learning Repository (Chen, 2019), CC BY 4.0.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A pivot table your users can drag, on a million rows, and how to tell whether it freezes the page</title>
      <dc:creator>KanuniLabs</dc:creator>
      <pubDate>Sun, 11 Oct 2026 18:57:27 +0000</pubDate>
      <link>https://dev.to/kanunilabs/a-pivot-table-your-users-can-drag-on-a-million-rows-and-how-to-tell-whether-it-freezes-the-page-1daa</link>
      <guid>https://dev.to/kanunilabs/a-pivot-table-your-users-can-drag-on-a-million-rows-and-how-to-tell-whether-it-freezes-the-page-1daa</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This post measures that on real data: the 1,067,371 invoice lines of the &lt;a href="https://archive.ics.uci.edu/dataset/502" rel="noopener noreferrer"&gt;Online Retail II&lt;/a&gt; dataset, pivoted in the browser with our free PivotGrid. The &lt;a href="https://kanunilabs.com/demos/pivot" rel="noopener noreferrer"&gt;demo&lt;/a&gt; times every recompute in your own browser, so you can check each number here against your machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The report a user builds
&lt;/h2&gt;

&lt;p&gt;Start from revenue by country and year, then drag Month into the columns or Product into the rows. Each drop recomputes the whole table:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F56vqporrfre20svnj9k3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F56vqporrfre20svnj9k3.png" alt="Revenue and units by country and year, built from 1,067,371 invoice lines" width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In React that is one component and a list of fields; &lt;code&gt;area&lt;/code&gt; says where each field starts, and the user moves them from there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BasePivotGrid&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@kanunilabs/pivotgrid-react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@kanunilabs/pivotgrid-react/styles.css&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;country&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;country&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Country&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;row&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;year&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;year&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Year&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;column&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;summaryType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sum&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;month&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;month&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Month&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;filter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;  &lt;span class="c1"&gt;// waiting to be dragged&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;product&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;description&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Product&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;filter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;BasePivotGrid&lt;/span&gt; &lt;span class="na"&gt;gridId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"sales"&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;initialFields&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What a drag costs
&lt;/h2&gt;

&lt;p&gt;The demo measures two things on every recompute:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;How long it takes&lt;/strong&gt;, from the moment the pivot starts to the moment the new table is painted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The longest pause on the page's main thread&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjxi174pumas70g5x8nar.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjxi174pumas70g5x8nar.png" alt="The measurement panel after dragging Month into the columns: the recompute time and the longest main-thread pause" width="800" height="620"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious way, measured
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;Map&lt;/code&gt;, on the main thread.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs4tw8plvvxkq78ool2ww.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs4tw8plvvxkq78ool2ww.gif" 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" width="600" height="464"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The worker does not make the work free. It moves it off the thread that answers the user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the page stopped, and the fix it led to
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// what the pivot's sort did before 1.7.2&lt;/span&gt;
&lt;span class="nx"&gt;names&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;localeCompare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;

&lt;span class="c1"&gt;// the same order, with the collator built once&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;collator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Collator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;names&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;collator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compare&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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, &lt;code&gt;localeCompare&lt;/code&gt; sets up its comparison rules again on every call; a collator sets them up once.&lt;/p&gt;

&lt;p&gt;We shipped the second version in PivotGrid 1.7.2 on October 8 (&lt;code&gt;@kanunilabs/pivotgrid-react&lt;/code&gt; 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 &lt;a href="https://kanunilabs.com/demos/pivot?layout=product-month&amp;amp;sort=name" rel="noopener noreferrer"&gt;/demos/pivot?layout=product-month&amp;amp;sort=name&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;@kanunilabs/pivotgrid-react&lt;/code&gt; 1.5.0 on October 9.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5hgrpdsdmphimvqgi7iw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5hgrpdsdmphimvqgi7iw.png" alt="Product by month, best sellers first: the measurement panel after the recompute" width="800" height="620"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure your own pivot
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;longTasks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PerformanceEntry&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;longTasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntries&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;longtask&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;recompute&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                       &lt;span class="c1"&gt;// whatever your table does on a drag&lt;/span&gt;
&lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// the new table has been painted&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pause&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;longTasks&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startTime&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startTime&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;t1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`recompute &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; ms, longest main-thread pause &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pause&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; ms`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the second number is large, your users feel it, whatever the first one says.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this costs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is free.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The data has to be in the browser.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wide layouts still pause the page.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The demo is at &lt;a href="https://kanunilabs.com/demos/pivot" rel="noopener noreferrer"&gt;/demos/pivot&lt;/a&gt;; &lt;a href="https://kanunilabs.com/demos/pivot?layout=product-month" rel="noopener noreferrer"&gt;/demos/pivot?layout=product-month&lt;/a&gt; opens the wide layout, best sellers first, and adding &lt;code&gt;&amp;amp;sort=name&lt;/code&gt; sorts it by name. The &lt;a href="https://kanunilabs.com/docs/pivotgrid" rel="noopener noreferrer"&gt;PivotGrid docs&lt;/a&gt; cover fields, filters, summary types and the React and plain JavaScript setups.&lt;/p&gt;

&lt;p&gt;Data: Online Retail II, UCI Machine Learning Repository (Chen, 2019), CC BY 4.0.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>performance</category>
    </item>
    <item>
      <title>Select all in a million filtered rows, and send the server one line instead of a list</title>
      <dc:creator>KanuniLabs</dc:creator>
      <pubDate>Mon, 05 Oct 2026 13:48:28 +0000</pubDate>
      <link>https://dev.to/kanunilabs/select-all-in-a-million-filtered-rows-and-send-the-server-one-line-instead-of-a-list-5gp5</link>
      <guid>https://dev.to/kanunilabs/select-all-in-a-million-filtered-rows-and-send-the-server-one-line-instead-of-a-list-5gp5</guid>
      <description>&lt;p&gt;"How do I get the selected rows from a checkbox column?" has been read more than 82,000 times on Stack Overflow, and it is rarely about the checkbox. It comes up when a table has a filter on and a header checkbox, and someone asks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When the user ticks "select all" on a filtered table, which rows are selected? The ones on screen, the ones the filter matches, or every row?&lt;/li&gt;
&lt;li&gt;Which of them does my code get back?&lt;/li&gt;
&lt;li&gt;What do I send to the server when "all" means a million rows?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post answers each one on real data, with the React code. The &lt;a href="https://kanunilabs.com/demos/selection" rel="noopener noreferrer"&gt;demo&lt;/a&gt; loads the 1,067,371 invoice lines of the &lt;a href="https://archive.ics.uci.edu/dataset/502" rel="noopener noreferrer"&gt;Online Retail II&lt;/a&gt; dataset into the free Community DataGrid. You can tick rows, filter by country, and see what the selection would look like as a request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Selection belongs to rows, not positions
&lt;/h2&gt;

&lt;p&gt;The first rule is the one that makes the others possible. A selection is a set of row &lt;strong&gt;keys&lt;/strong&gt;, not row indexes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DataGrid&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@kanunilabs/datagrid-react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DataGrid&lt;/span&gt;
  &lt;span class="na"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;rowKey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;
  &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;multiple&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;checkboxColumn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sort the table and the ticked rows move with their data. Filter them out of view and back, and they are still ticked. An index-based selection points at whichever row happens to be third after the sort, and that is a bug report waiting to happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "select all" selects
&lt;/h2&gt;

&lt;p&gt;With a filter on, the header checkbox selects the rows the filter matches. Not just the rows on screen, and not the rows the filter hides.&lt;/p&gt;

&lt;p&gt;Filter to France and tick the header. That selects 14,330 lines:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkvhr9o0d4xgp0a5ahceb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkvhr9o0d4xgp0a5ahceb.png" alt="France filter, header ticked: 14,330 rows selected" width="800" height="545"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Untick three of them and you have 14,327. So far this is what everyone expects. The interesting part is what that selection is made of.&lt;/p&gt;

&lt;h2&gt;
  
  
  A million keys is a lot to hold and more to send
&lt;/h2&gt;

&lt;p&gt;The obvious way to select all is to put every matching key in a set. For France that is 14,330 keys and nobody notices. Clear the filter and tick the header, and it is 1,067,371 keys, built on the main thread at the moment of the click. Then the user presses "Archive selected" and you send them.&lt;/p&gt;

&lt;p&gt;The demo has a button that does exactly that and times it in your browser. With three rows unticked from the whole table, the list is 1,067,368 ids and &lt;strong&gt;7.1 MB of JSON&lt;/strong&gt;. On our machine it took about a tenth of a second just to build, before the network.&lt;/p&gt;

&lt;p&gt;That is the eager form. There is a second one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deferred: a rule and its exceptions
&lt;/h2&gt;

&lt;p&gt;Switch on &lt;code&gt;deferred&lt;/code&gt; and select-all stops building a list. It records one flag, "everything the current filter matches", and every row the user unticks afterwards goes into a list of exceptions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DataGrid&lt;/span&gt;
  &lt;span class="na"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;rowKey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;
  &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;multiple&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;checkboxColumn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;deferred&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onReady&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;setGrid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it back with &lt;code&gt;getSelectionDescriptor()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSelectionDescriptor&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// { allMatching: true, keys: [73, 75, 77] }   ← keys are the EXCEPTIONS here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;allMatching&lt;/code&gt; is false, &lt;code&gt;keys&lt;/code&gt; is the plain list of ticked rows, as before. The descriptor costs as much as the exceptions do, whether one row is selected or a million.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flzzbfupuiq7zbholrirn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flzzbfupuiq7zbholrirn.png" alt="All rows minus three: one line to send, against 7.1 MB as a list" width="800" height="566"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Send the rule, and the filter it was made under
&lt;/h2&gt;

&lt;p&gt;A descriptor with &lt;code&gt;allMatching: true&lt;/code&gt; means nothing without the filter, so send both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;archive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;allMatching&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSelectionDescriptor&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/invoices/archive&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;allMatching&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFilterEnabled&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEffectiveFilter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;search&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSearchText&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="na"&gt;except&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server turns it back into a query, something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;invoice_lines&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;archived&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'France'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;73&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;77&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things are worth knowing before you build on this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your server has to apply the same filter.&lt;/strong&gt; &lt;code&gt;getEffectiveFilter()&lt;/code&gt; gives you the conditions the grid ran, and &lt;code&gt;getSearchText()&lt;/code&gt; the quick search, which narrows the rows too. Your endpoint is responsible for running them identically. A filter the server reads differently (case, time zone, a column it does not know) selects different rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rule is evaluated when the server runs it.&lt;/strong&gt; A line added in France between the click and the request is included, because it matches. That is usually what "all matching" means, but if your users expect "exactly the rows I saw", send ids.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rule follows the filter.&lt;/strong&gt; Select all in France, then switch the filter to Germany, and the selection is now Germany's 17,624 rows: it was always "everything the current filter matches". The three French exceptions travel along, harmlessly, since no German row has those ids. If your screen should not allow that, clear the selection when the filter changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvq3xiomzpraqy60m0lpd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvq3xiomzpraqy60m0lpd.png" alt="The same selection after switching to Germany: all 17,624 German rows, and the French exceptions still listed" width="800" height="545"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which rows does my code get back?
&lt;/h2&gt;

&lt;p&gt;This is the part of the Stack Overflow question that still trips people up, because two methods answer it differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;getSelectedRows()&lt;/code&gt; returns the selected rows &lt;strong&gt;that are in the current view&lt;/strong&gt;, in view order. A ticked row that the filter now hides is not in it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;getSelectedKeys()&lt;/code&gt; returns every selected key, visible or not. Under deferred selection the rule only covers the current view, so it walks the view to expand it into a list, which is the cost the mode exists to avoid.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;getSelectedCount()&lt;/code&gt; is the number to show the user.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With the eager form, the count includes ticked rows the filter now hides. So if you show "14,327 selected" and then act on &lt;code&gt;getSelectedRows()&lt;/code&gt; after the user changed the filter, you are acting on fewer rows than you said. Decide which one you mean and use the same method for both.&lt;/p&gt;

&lt;p&gt;All of it in one take: a filter, select all, three exceptions, a different filter, and the cost of turning it into a list:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fam0fsk0xp0pkpbxvyo93.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fam0fsk0xp0pkpbxvyo93.gif" alt="Filter, select all, untick three, switch filters, compare with the list" width="600" height="407"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two bugs this post found
&lt;/h2&gt;

&lt;p&gt;Preparing the screenshots for this post turned up two bugs in deferred select-all. Both are fixed in &lt;code&gt;@kanunilabs/datagrid-react&lt;/code&gt; 1.3.0 (with &lt;code&gt;@kanunilabs/datagrid-core&lt;/code&gt; 1.5.0), released on 5 October:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;selectionChanged&lt;/code&gt; event reported a select-all as no rows and a count of 0&lt;/strong&gt;, and after the user unticked a row it reported that row as the selection. "Export selected rows only" read the selection the same way and wrote every row, the unticked ones included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After a filter change, the count subtracted exceptions the new filter hides.&lt;/strong&gt; The Germany step above showed 17,621 selected while every one of the 17,624 German rows was ticked: the three French exceptions were taken off a count they were never part of.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On an older version, read the selection from &lt;code&gt;getSelectionDescriptor()&lt;/code&gt;, which was right all along, not from the event.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this costs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is free.&lt;/strong&gt; Selection, the checkbox column and deferred select-all are in the Community package.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your server does the selecting.&lt;/strong&gt; A deferred selection is a query, and your endpoint has to run it. If you cannot express your filters on the server, use the eager form and send ids.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything here is in the browser.&lt;/strong&gt; The demo holds all 1,067,371 lines in the page; the &lt;a href="https://kanunilabs.com/docs/datagrid/remote" rel="noopener noreferrer"&gt;server-side data&lt;/a&gt; docs cover the case where the server holds them and the grid only asks for a page at a time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The demo is at &lt;a href="https://kanunilabs.com/demos/selection" rel="noopener noreferrer"&gt;/demos/selection&lt;/a&gt;, and &lt;a href="https://kanunilabs.com/demos/selection?country=France" rel="noopener noreferrer"&gt;/demos/selection?country=France&lt;/a&gt; opens it filtered. The &lt;a href="https://kanunilabs.com/docs/datagrid/features#selection-and-keyboard" rel="noopener noreferrer"&gt;selection docs&lt;/a&gt; cover single and multiple modes, row-click behaviour and the keyboard.&lt;/p&gt;

&lt;p&gt;Data: Online Retail II, UCI Machine Learning Repository (Chen, 2019), CC BY 4.0.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: this article was drafted with AI assistance. The demo and every behaviour described were checked against our packages and the published dataset in October 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The hard part of an editable table is knowing what changed</title>
      <dc:creator>KanuniLabs</dc:creator>
      <pubDate>Sat, 03 Oct 2026 06:40:26 +0000</pubDate>
      <link>https://dev.to/kanunilabs/the-hard-part-of-an-editable-table-is-knowing-what-changed-3263</link>
      <guid>https://dev.to/kanunilabs/the-hard-part-of-an-editable-table-is-knowing-what-changed-3263</guid>
      <description>&lt;p&gt;Editing is the feature people ask data grid libraries for most, and "how do I get the row's values after an edit?" is one of the most-read grid questions on Stack Overflow. The question is telling. Nobody struggles to put an input box in a cell. They struggle with what comes after it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which cells did the user change, and what were they before?&lt;/li&gt;
&lt;li&gt;Is this value allowed, and how does the user find out it is not?&lt;/li&gt;
&lt;li&gt;Can they take an edit back?&lt;/li&gt;
&lt;li&gt;What do we send to the server, and in what shape?&lt;/li&gt;
&lt;li&gt;What happens to their work when the server says no?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post walks through each one on a real order, with the React code. The &lt;a href="https://kanunilabs.com/demos/order-editing" rel="noopener noreferrer"&gt;demo&lt;/a&gt; has 240 order lines a UK online retailer sold to customers in France in the first week of December 2011, from the &lt;a href="https://archive.ics.uci.edu/dataset/502" rel="noopener noreferrer"&gt;Online Retail II&lt;/a&gt; dataset. Its "server" is a function in the page that logs every request, so you can watch what a save sends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep edits out of the data until Save
&lt;/h2&gt;

&lt;p&gt;The first decision is where an edit lives before it is saved. The tempting answer is "in the row": the user types 24, you write 24 into the row object. Then Cancel has to remember that it used to be 10, and so does undo, and so does any code that asks "what changed?".&lt;/p&gt;

&lt;p&gt;The grid does the opposite. Pending edits are held in a separate change store, keyed by the row's key, and your data is not touched until a save succeeds. Cancel is then just "drop the pending edits". Keying by row rather than by position means an edit stays on its record when the user sorts, filters or pages away and back.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fod1hvuc1oe2sl8jjhl8s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fod1hvuc1oe2sl8jjhl8s.png" alt="Three pending edits: the changed cells carry a corner mark and the toolbar says how many rows will be saved" width="799" height="359"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In batch mode, every change waits until the user presses Save, and the toolbar counts the rows that will be sent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;EnterpriseDataGrid&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@kanunilabs/datagrid-react-enterprise&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;EnterpriseDataGrid&lt;/span&gt;
  &lt;span class="na"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;rowKey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;
  &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;toolbar&lt;/span&gt;
  &lt;span class="na"&gt;editing&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;batch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;allowUpdating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;columnId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;description&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;quantity&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;price&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;columnId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;crud&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Refuse a bad value where it is typed
&lt;/h2&gt;

&lt;p&gt;Rules are declared per column and checked as the user types, and again on save:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;A product needs a name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="na"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;range&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Quantity must be at least 1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;custom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Quantity must be a whole number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;range&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Price must be above zero&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Type −5 into a quantity and the editor refuses to close, with the reason under the cell:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fibtkvmuylgzdoigl9zjw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fibtkvmuylgzdoigl9zjw.png" alt="A quantity of −5 is refused in place, with the rule's message under the cell" width="799" height="359"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The message is the part we nearly got wrong. While taking the screenshots for this post we found that it was rendered in the right place and never seen: the cell clips anything that overflows it, and the next row is drawn on top of the one being edited. The user got a red underline and no reason. It is fixed in &lt;code&gt;@kanunilabs/datagrid-react-enterprise&lt;/code&gt; 1.3.2, released on 1 October.&lt;/p&gt;

&lt;h2&gt;
  
  
  Take an edit back
&lt;/h2&gt;

&lt;p&gt;Ctrl+Z undoes and Ctrl+Y redoes. Each step is one user action, not one cell: undoing a paste of 200 cells undoes the paste, not one cell of it. The history keeps the last 100 actions by default, and it is a public API (&lt;code&gt;undo()&lt;/code&gt;, &lt;code&gt;redo()&lt;/code&gt;, &lt;code&gt;canUndo()&lt;/code&gt;), so a toolbar button can use it too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Send only what changed
&lt;/h2&gt;

&lt;p&gt;When the user presses Save, the grid calls your handler once per changed row, with the changed columns only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/orders/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PATCH&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// { quantity: 24 }, not the whole row&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// the stored row&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the shape a REST endpoint or an &lt;code&gt;UPDATE … SET&lt;/code&gt; wants. With three edits in three rows, the demo's log shows three requests, each carrying one field:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs32ti0wr5t1yg1gi8ff1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs32ti0wr5t1yg1gi8ff1.png" alt="What the save sent: one PATCH per changed row, with only the changed column" width="800" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The handlers run one at a time: inserts first, then updates, then removes, so a back end with foreign keys sees a parent row before the child that refers to it. Saves themselves also run one at a time. Two saves in quick succession used to snapshot the same pending edit and send it twice; the second save now waits for the first.&lt;/p&gt;

&lt;h3&gt;
  
  
  The return value matters
&lt;/h3&gt;

&lt;p&gt;The comment on that &lt;code&gt;return res.json()&lt;/code&gt; line is there because the demo got it wrong first. The grid does not write your data on its own. If &lt;code&gt;update&lt;/code&gt; returns the stored row, the grid adopts it, including anything the server computed. If it returns nothing, the grid clears the pending edit and leaves your data alone, so the cell shows the old value again. Our first version of the demo returned nothing, and every save looked like it had been thrown away.&lt;/p&gt;

&lt;p&gt;If your endpoint answers with no body, return &lt;code&gt;{ ...row, ...values }&lt;/code&gt;, or update your own state after the save.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the server says no
&lt;/h2&gt;

&lt;p&gt;Tick "Make the next save fail" in the demo, change two rows and save. The first request comes back with a 500:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcz7oenf51qrseapyadp3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcz7oenf51qrseapyadp3.png" alt="A failed save: the server refused the first request, and both edits are still pending" width="799" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One rejected request fails the whole save. Every edit stays pending, still marked, still counted on the Save button, so the user can try again without retyping anything.&lt;/p&gt;

&lt;p&gt;There is a limit to this, and it is worth stating plainly: requests that already succeeded before the failing one are not rolled back. The grid does not know how to undo a write on your server, and pretending otherwise would be worse than saying so. If a batch has to be all or nothing, use &lt;code&gt;onSave&lt;/code&gt;, which receives every change in one call, and make your endpoint transactional.&lt;/p&gt;

&lt;p&gt;All of it in one take: an edit, a second edit, a refused value, undo, and a save that sends only what was left:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F17mfbivsjzet8idlxnvr.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F17mfbivsjzet8idlxnvr.gif" alt="Edit, refuse, undo, save" width="720" height="425"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What this costs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is paid.&lt;/strong&gt; Editing, validation, undo and the CRUD handlers are in the Enterprise edition of the grid. Without a licence key it runs with a watermark, so you can try everything here first. The read-only grid, sorting, filtering and the rest are in the free Community package.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pending edits live in the page.&lt;/strong&gt; They are held in memory until saved; this demo does nothing to keep them across a reload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partial batches are possible&lt;/strong&gt; with per-row handlers, as above. That is the trade for sending the shape a REST API wants.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The demo is at &lt;a href="https://kanunilabs.com/demos/order-editing" rel="noopener noreferrer"&gt;/demos/order-editing&lt;/a&gt;, and &lt;a href="https://kanunilabs.com/demos/order-editing?fail=1" rel="noopener noreferrer"&gt;/demos/order-editing?fail=1&lt;/a&gt; opens it with the failing server switched on. The &lt;a href="https://kanunilabs.com/docs/datagrid/editing" rel="noopener noreferrer"&gt;editing docs&lt;/a&gt; cover the other modes (cell, row, form and popup), the CRUD handlers and every rule type.&lt;/p&gt;

&lt;p&gt;Data: Online Retail II, UCI Machine Learning Repository (Chen, 2019), CC BY 4.0.&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to find cancellations in a million invoice lines</title>
      <dc:creator>KanuniLabs</dc:creator>
      <pubDate>Thu, 01 Oct 2026 11:10:53 +0000</pubDate>
      <link>https://dev.to/kanunilabs/how-to-find-cancellations-in-a-million-invoice-lines-2gh2</link>
      <guid>https://dev.to/kanunilabs/how-to-find-cancellations-in-a-million-invoice-lines-2gh2</guid>
      <description>&lt;p&gt;"How much did cancellations cost us, and what got cancelled?" is one of the first questions anyone asks of sales data. In a grid it looks like a one-filter job: show the lines with a negative quantity and read the total.&lt;/p&gt;

&lt;p&gt;On real data that filter gives the right amount of money and more than twice the real number of returned units. This post walks through why, using the same 1,067,371 transactions as the &lt;a href="https://kanunilabs.com/blog/sales-dashboard-on-a-million-real-rows-in-react" rel="noopener noreferrer"&gt;sales dashboard post&lt;/a&gt;: &lt;a href="https://archive.ics.uci.edu/dataset/502" rel="noopener noreferrer"&gt;Online Retail II&lt;/a&gt;, two years of invoices from a UK online retailer, loaded into the browser once. Every number below was read off the &lt;a href="https://kanunilabs.com/demos/online-retail" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; and checked against the source file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious filter
&lt;/h2&gt;

&lt;p&gt;Filter the Qty column to values below zero. The grid keeps 22,950 of the 1,067,371 lines, and the footer totals them: &lt;strong&gt;−1,064,078 units&lt;/strong&gt; and &lt;strong&gt;−£1,527,041.43&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmj20ngqqrpevq1xb13lp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmj20ngqqrpevq1xb13lp.png" alt="Quantity below zero: 22,950 lines, −1,064,078 units, −£1,527,041.43" width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In code, through the grid's &lt;code&gt;onReady&lt;/code&gt; handle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setColumnFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;quantity&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;columnId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;quantity&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The filter that matches the data
&lt;/h2&gt;

&lt;p&gt;The dataset's own description says how a cancellation is marked: its invoice number starts with a C. Filter on that instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setColumnFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invoice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;columnId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invoice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;startswith&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;C&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the grid keeps 19,494 lines: &lt;strong&gt;−490,992 units&lt;/strong&gt; and &lt;strong&gt;−£1,526,667.86&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwcr230boo9is9av07w0v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwcr230boo9is9av07w0v.png" alt="Invoice starts with C: 19,494 lines, −490,992 units, −£1,526,667.86" width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The demo has both filters as buttons above the grid, so you can switch between them and watch the footer:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu1klqo5kxyhdhv7tyxxe.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu1klqo5kxyhdhv7tyxxe.gif" alt="Switching between the two filters: the money barely moves, the units halve" width="760" height="431"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the difference is
&lt;/h2&gt;

&lt;p&gt;The two totals differ by £373.57 and by 573,085 units.&lt;/p&gt;

&lt;p&gt;The £373.57 is one line: a "Manual" adjustment on cancellation invoice C496350 with a quantity of +1, which the quantity filter misses.&lt;/p&gt;

&lt;p&gt;The 573,085 units are 3,457 lines that have a negative quantity, an ordinary invoice number and a unit price of £0. Sort the quantity-filtered view by revenue, largest first, and they come to the top, because every real return is negative:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq4zaiu34e1vvp1m1ls2q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq4zaiu34e1vvp1m1ls2q.png" alt="The extra lines: negative quantities at £0, with descriptions like " width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;These are stock write-offs. 2,689 of them have no description at all; the rest say things like "damages", "lost", "check" or "?". They move no money, which is why the revenue totals agree. They do move units, so a returns report built on &lt;code&gt;quantity &amp;lt; 0&lt;/code&gt; would count more than twice the goods customers actually sent back.&lt;/p&gt;

&lt;p&gt;So the question decides the filter. For the money, either one is close enough. For anything about units, return rates or products, filter on the invoice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The largest cancellations
&lt;/h2&gt;

&lt;p&gt;With the invoice filter on, sort Revenue ascending. The first line is the order from the last post: 80,995 paper craft birds, cancelled on 9 December 2011 as C581484 for −£168,469.60. The second is 74,215 ceramic storage jars, −£77,183.60.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvjzblkk2ruy92uxf8t9f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvjzblkk2ruy92uxf8t9f.png" alt="Cancellations sorted by revenue, largest first" width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually got cancelled
&lt;/h2&gt;

&lt;p&gt;Single lines mislead in the other direction, so the next step is a pivot: products in the rows, revenue and units summed, the ten largest by cancelled revenue.&lt;/p&gt;

&lt;p&gt;To slice by "is this a cancellation", the rows need a field that says so. The source has none, so we derive one while decoding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;C&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cancellation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;A&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Adjustment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;   &lt;span class="c1"&gt;// six bad-debt adjustments&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sale&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the pivot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;kind&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;kind&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Kind&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;column&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;filterValues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cancellation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;product&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;description&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Product&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;row&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sortBySummaryField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sortOrder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;asc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;topN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;summaryType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sum&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;units&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;quantity&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Units&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;summaryType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sum&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sortOrder: 'asc'&lt;/code&gt; because cancelled revenue is negative: the largest refund is the smallest number.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiehn5m071o4tpkfkse42.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiehn5m071o4tpkfkse42.png" alt="The ten products with the most cancelled revenue" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Product&lt;/th&gt;
&lt;th&gt;Cancelled revenue&lt;/th&gt;
&lt;th&gt;Units&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;td&gt;−£423,513&lt;/td&gt;
&lt;td&gt;−5,450&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AMAZON FEE&lt;/td&gt;
&lt;td&gt;−£294,773&lt;/td&gt;
&lt;td&gt;−39&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PAPER CRAFT , LITTLE BIRDIE&lt;/td&gt;
&lt;td&gt;−£168,470&lt;/td&gt;
&lt;td&gt;−80,995&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MEDIUM CERAMIC TOP STORAGE JAR&lt;/td&gt;
&lt;td&gt;−£77,480&lt;/td&gt;
&lt;td&gt;−74,494&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bank Charges&lt;/td&gt;
&lt;td&gt;−£36,097&lt;/td&gt;
&lt;td&gt;−77&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;REGENCY CAKESTAND 3 TIER&lt;/td&gt;
&lt;td&gt;−£16,750&lt;/td&gt;
&lt;td&gt;−1,468&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POSTAGE&lt;/td&gt;
&lt;td&gt;−£15,256&lt;/td&gt;
&lt;td&gt;−303&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discount&lt;/td&gt;
&lt;td&gt;−£13,882&lt;/td&gt;
&lt;td&gt;−3,068&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WHITE HANGING HEART T-LIGHT HOLDER&lt;/td&gt;
&lt;td&gt;−£9,390&lt;/td&gt;
&lt;td&gt;−3,638&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CRUK Commission&lt;/td&gt;
&lt;td&gt;−£7,933&lt;/td&gt;
&lt;td&gt;−16&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two largest are not products. "Manual" and "AMAZON FEE" together are −£718,285, 47% of everything cancelled. Six of the top ten are fees, charges, postage, discounts or manual corrections. A chart of "most returned products" that does not exclude them is mostly a chart of bookkeeping.&lt;/p&gt;

&lt;p&gt;Kind sits in the column area rather than the filter area on purpose: with &lt;code&gt;filterValues&lt;/code&gt; on it, the pivot shows a single "Cancellation" header, so the screen says what is being counted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which markets cancel most
&lt;/h2&gt;

&lt;p&gt;Swap the layout: countries in the rows, Kind in the columns, revenue in the cells, the ten largest markets.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftauszfee09d5ay7m8szr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftauszfee09d5ay7m8szr.png" alt="Sales, cancellations and adjustments for the ten largest markets" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Across the whole dataset, cancellations are £1,526,668 against £20,961,532 of sales, 7.3%. Per market, as a share of that market's sales:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Spain: 15.9% (−£17,319 on £109,179)&lt;/li&gt;
&lt;li&gt;France: 8.1%&lt;/li&gt;
&lt;li&gt;United Kingdom: 7.4%&lt;/li&gt;
&lt;li&gt;EIRE: 7.4%&lt;/li&gt;
&lt;li&gt;Netherlands: 1.0%&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Spain's rate is the highest and also the easiest to over-read: it is £17,319 across 91 cancellation lines, small enough for a handful of orders to move it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this approach costs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The convention is this dataset's.&lt;/strong&gt; "Invoice starts with C" is how this retailer's export marks a cancellation. Your system will have its own field or code; the lesson is to filter on that, not on the sign of a number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cancellation is not always a customer return.&lt;/strong&gt; Manual corrections, marketplace fees and bank charges are cancelled the same way. Decide what you are counting before you count it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The file has exact duplicates.&lt;/strong&gt; 34,335 lines appear more than once, 390 of them cancellations; you can see two pairs of AMAZON FEE lines in the sorted view above. We kept every row, as in the previous post, so the totals here match the published file. A real report would deduplicate first and come out slightly lower.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The data ends on 9 December 2011,&lt;/strong&gt; so the last month is a week, and the largest cancellation in the set happened on its last day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The pivot cuts long product names&lt;/strong&gt; in its row header at the default width. The table above has them in full.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The demo opens in each state from a link: &lt;a href="https://kanunilabs.com/demos/online-retail?view=negqty" rel="noopener noreferrer"&gt;quantity below zero&lt;/a&gt;, &lt;a href="https://kanunilabs.com/demos/online-retail?view=cancellations" rel="noopener noreferrer"&gt;cancellations only&lt;/a&gt;, &lt;a href="https://kanunilabs.com/demos/online-retail?view=cancellations&amp;amp;sort=revenue-asc" rel="noopener noreferrer"&gt;cancellations, largest first&lt;/a&gt;, &lt;a href="https://kanunilabs.com/demos/online-retail?pivot=cancellations" rel="noopener noreferrer"&gt;the most cancelled products&lt;/a&gt; and &lt;a href="https://kanunilabs.com/demos/online-retail?pivot=countries" rel="noopener noreferrer"&gt;cancellations by country&lt;/a&gt;. The filters, the footer totals, sorting a pivot by a measure and Top-N are all in the free Community packages; the &lt;a href="https://kanunilabs.com/docs/datagrid" rel="noopener noreferrer"&gt;DataGrid&lt;/a&gt; and &lt;a href="https://kanunilabs.com/docs/pivotgrid" rel="noopener noreferrer"&gt;PivotGrid&lt;/a&gt; docs cover each prop used here.&lt;/p&gt;

&lt;p&gt;Data: Online Retail II, UCI Machine Learning Repository (Chen, 2019), CC BY 4.0.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: this article was drafted with AI assistance. The demo, the data processing and every number in it were produced and checked against the published dataset and our packages in October 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to build a sales dashboard on a million real rows in React DataGrid</title>
      <dc:creator>KanuniLabs</dc:creator>
      <pubDate>Tue, 29 Sep 2026 08:32:59 +0000</pubDate>
      <link>https://dev.to/kanunilabs/how-to-build-a-sales-dashboard-on-a-million-real-rows-in-react-datagrid-4io8</link>
      <guid>https://dev.to/kanunilabs/how-to-build-a-sales-dashboard-on-a-million-real-rows-in-react-datagrid-4io8</guid>
      <description>&lt;p&gt;Most sales dashboards I have worked on send every question to a server. You change a filter, a request goes out, a SQL query runs and a page of rows comes back. That is the right design when the data is huge or private. It is also a lot of machinery for a question like "which country bought the most in November?" when the whole dataset would fit in a browser tab.&lt;/p&gt;

&lt;p&gt;So we tried it the other way round. We took a real sales dataset of just over a million rows, sent it to the browser once, and did everything else there: sorting, searching, grouping, pivoting and charting. This post walks through each part with the React code, and says where it gets slow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data
&lt;/h2&gt;

&lt;p&gt;We used &lt;a href="https://archive.ics.uci.edu/dataset/502" rel="noopener noreferrer"&gt;Online Retail II&lt;/a&gt; from the UCI Machine Learning Repository. It is two years of transactions from a UK online retailer that sells gifts and homeware, from December 2009 to December 2011: &lt;strong&gt;1,067,371 rows&lt;/strong&gt;, one per invoice line. Each row has an invoice number, a stock code, a product description, a quantity, a unit price in pounds, a timestamp, a customer ID and a country. It is published under CC BY 4.0, so you can build on it too.&lt;/p&gt;

&lt;p&gt;It is real data, so it is untidy in the way sales data always is. Cancelled invoices start with a "C" and carry negative quantities. Some lines are not products at all: "Manual", "AMAZON FEE", "Adjust bad debt". We kept every row.&lt;/p&gt;

&lt;p&gt;The finished page is live at &lt;a href="https://kanunilabs.com/demos/online-retail" rel="noopener noreferrer"&gt;/demos/online-retail&lt;/a&gt;. It has two parts, a data grid with every transaction and a pivot table with a chart, and we will build them in that order.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fngnc9ffcpb9sza1zv5p3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fngnc9ffcpb9sza1zv5p3.png" alt="The finished grid: 1,067,371 rows, with quantity and revenue totals in the footer" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting a million rows into the browser
&lt;/h2&gt;

&lt;p&gt;As a CSV the dataset is 91 MB, and 14.8 MB gzipped. That is too much to ask a visitor to download, but most of it is repetition. Five of the eight columns draw from a small set of values: 43 countries, about 5,300 products and about 53,600 invoices. We ship each of those columns as a dictionary plus one integer per row, which brings the download to &lt;strong&gt;5.8 MB&lt;/strong&gt; gzipped.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdr3338rlrk966el2re8j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdr3338rlrk966el2re8j.png" alt="The page downloads the dataset once, then decodes it in the browser" width="800" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Decoding it back into plain row objects is a loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/demos/online-retail/retail.v1.json.gz&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipeThrough&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DecompressionStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gzip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;dicts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dicts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dicts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
    &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dicts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
    &lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The version on the page decodes in slices of 50,000 rows and yields to the browser between slices, so the progress bar keeps moving while a million objects are built.&lt;/p&gt;

&lt;h2&gt;
  
  
  The grid
&lt;/h2&gt;

&lt;p&gt;The grid takes those rows as they are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DataGrid&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@kanunilabs/datagrid-react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gbp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GBP&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;columns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invoice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headerName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invoice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;description&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headerName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Product&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;quantity&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headerName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Qty&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headerName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;valueFormatter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;gbp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;country&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headerName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Country&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DataGrid&lt;/span&gt;
  &lt;span class="na"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;rowKey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;
  &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;toolbar&lt;/span&gt;
  &lt;span class="na"&gt;statusBar&lt;/span&gt;
  &lt;span class="na"&gt;groupPanel&lt;/span&gt;
  &lt;span class="na"&gt;summaries&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;columnId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;quantity&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sum&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;columnId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sum&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the rows on screen are in the DOM, so scrolling to the bottom of a million rows is the same work as scrolling to the top. The footer shows the totals for the whole result: 10,608,492 units and £19,287,250.55 over the two years.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sorting by revenue
&lt;/h2&gt;

&lt;p&gt;Click the Revenue header. The sort runs in a Web Worker, so the page keeps responding while a million rows are reordered. Our &lt;a href="https://kanunilabs.com/benchmarks" rel="noopener noreferrer"&gt;benchmarks page&lt;/a&gt; has the measured times on a million generated rows, and a script to run them yourself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft00x9thv582v5fu9kta3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft00x9thv582v5fu9kta3.png" alt="Sorted by revenue, largest line first" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The top line is a single order for 80,995 paper craft birds at £2.08 each, £168,469.60 in one row. Sort the other way and the first row is the same order again, cancelled the same day as invoice C581484 at −£168,469.60. The second-largest line, 74,215 ceramic storage jars, has a matching cancellation too. Nobody would plan a dashboard around that, but it is the first thing a sort shows you on real data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flfljafenl0dc43w6vpnx.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flfljafenl0dc43w6vpnx.gif" alt="Two clicks on Revenue: the largest refund first, then the largest sale" width="760" height="403"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Searching
&lt;/h2&gt;

&lt;p&gt;Type into the search box and the grid filters every column at once and highlights the match. "WHITE HANGING HEART" matches 5,918 of the 1,067,371 rows, and the footer updates to that result: 93,050 units and £257,533.90 in revenue.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2e1ks3sru3zo5znjux9g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2e1ks3sru3zo5znjux9g.png" alt="One product across a million rows, with the footer showing its totals" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The search matches what you see, so a search for "£2.95" works as well as a search for "2.95". That means the grid formats every row of every formatted column once, the first time you search, and this is where one line of our own code cost us a minute.&lt;/p&gt;

&lt;p&gt;We first wrote the formatter as &lt;code&gt;v.toLocaleString('en-GB', { style: 'currency', currency: 'GBP' })&lt;/code&gt;. With options, &lt;code&gt;toLocaleString&lt;/code&gt; builds a new &lt;code&gt;Intl.NumberFormat&lt;/code&gt; on every call, so the first search sat on "Preparing data" for over a minute in a development build. With one shared formatter, as in the code above, it took about six seconds on the same laptop. If you format numbers in a large grid, build the formatter once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grouping by country
&lt;/h2&gt;

&lt;p&gt;Drag the Country header into the bar above the grid and the rows fold into one group per country, each with its own row count and totals.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4mkjbfnzutezutv56v03.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4mkjbfnzutezutv56v03.png" alt="Grouped by country: Australia has 1,913 lines, 103,706 units and £167,129.07" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Grouping, the group bar and the footer totals are in the free Community package. The per-group totals shown here are part of the paid &lt;code&gt;EnterpriseDataGrid&lt;/code&gt;, which this page uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pivot of country by year
&lt;/h2&gt;

&lt;p&gt;A pivot table answers the next question, how each market did in each year, without writing a query. Three fields describe it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BasePivotGrid&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;PivotGrid&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@kanunilabs/pivotgrid-react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;country&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;country&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Country&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;row&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;areaIndex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;year&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;year&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Year&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;column&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;areaIndex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Revenue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;areaIndex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;summaryType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sum&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;relative&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;520&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PivotGrid&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;initialFields&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9og5jp4ulm2ej3t10xzc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9og5jp4ulm2ej3t10xzc.png" alt="Revenue and units by country and year" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Note the wrapper. The pivot lays itself out against its nearest positioned ancestor, so give it a container with a height and &lt;code&gt;position: relative&lt;/code&gt;. On our page, without it, the pivot drew itself over the grid above.&lt;/p&gt;

&lt;p&gt;Every field in the pivot can be dragged between the rows, columns, values and filter areas, and it re-aggregates the million rows on each drop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Revenue by month
&lt;/h2&gt;

&lt;p&gt;Swap the layout for months in the rows and revenue as the only measure, and the chart becomes readable. With countries in the rows it is not: the UK accounts for most of the revenue, so every other bar is a sliver.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F781jdols2qvgmib9lz70.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F781jdols2qvgmib9lz70.png" alt="Monthly revenue, December 2009 to December 2011" width="800" height="581"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;November is the peak in both years, £1,422,655 in 2010 and £1,461,756 in 2011. December 2011 looks like a collapse at £433,686, but the dataset stops on 9 December. That is worth checking before anyone builds a forecast on it.&lt;/p&gt;

&lt;p&gt;The chart follows the pivot, so filtering the pivot to one country redraws the chart for that country. Charts are part of the paid PivotGrid Enterprise package.&lt;/p&gt;

&lt;h2&gt;
  
  
  A dark theme
&lt;/h2&gt;

&lt;p&gt;The grid ships six themes. This one is &lt;code&gt;theme="kanuni-midnight"&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F064uehbdv4ae9bpml05m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F064uehbdv4ae9bpml05m.png" alt="The same grid in the dark theme" width="800" height="442"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What this approach costs
&lt;/h2&gt;

&lt;p&gt;Sending the whole dataset to the browser is a trade, and it is not the right one for every dashboard.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The download.&lt;/strong&gt; 5.8 MB, once per visit. Fine for an internal tool, heavy for a phone on a bad connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory.&lt;/strong&gt; A million row objects live in the tab for as long as it is open. We did not measure the footprint for this post.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First-time costs.&lt;/strong&gt; The first search indexes the formatted text, and the first pivot layout aggregates everything. Both are cached afterwards, but the first time is seconds rather than milliseconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is all in one tab.&lt;/strong&gt; If the data is private, changes every second, or is ten times bigger, a server is still the right place for it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The dashboard is at &lt;a href="https://kanunilabs.com/demos/online-retail" rel="noopener noreferrer"&gt;/demos/online-retail&lt;/a&gt;. A few links open it in a given state: &lt;a href="https://kanunilabs.com/demos/online-retail?sort=revenue" rel="noopener noreferrer"&gt;sorted by revenue&lt;/a&gt;, &lt;a href="https://kanunilabs.com/demos/online-retail?group=country" rel="noopener noreferrer"&gt;grouped by country&lt;/a&gt;, &lt;a href="https://kanunilabs.com/demos/online-retail?chart=1" rel="noopener noreferrer"&gt;with the monthly chart&lt;/a&gt; and &lt;a href="https://kanunilabs.com/demos/online-retail?theme=dark" rel="noopener noreferrer"&gt;in the dark theme&lt;/a&gt;. The &lt;a href="https://kanunilabs.com/docs/datagrid" rel="noopener noreferrer"&gt;DataGrid docs&lt;/a&gt; and &lt;a href="https://kanunilabs.com/docs/pivotgrid" rel="noopener noreferrer"&gt;PivotGrid docs&lt;/a&gt; cover every prop used here.&lt;/p&gt;

&lt;p&gt;Data: Online Retail II, UCI Machine Learning Repository (Chen, 2019), CC BY 4.0.&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Sorting a million rows in JavaScript, and where the time actually goes</title>
      <dc:creator>KanuniLabs</dc:creator>
      <pubDate>Mon, 28 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kanunilabs/sorting-a-million-rows-in-javascript-and-where-the-time-actually-goes-4dh2</link>
      <guid>https://dev.to/kanunilabs/sorting-a-million-rows-in-javascript-and-where-the-time-actually-goes-4dh2</guid>
      <description>&lt;p&gt;Our data grid can sort a million rows in the browser without freezing the page. Getting there took several separate changes, and each one removed a different kind of cost. This post goes through them in the order we made them, with numbers from one laptop (Intel i7-11390H, Node 24).&lt;/p&gt;

&lt;p&gt;Numbers from a laptop wobble. Where I quote the grid's engine, the figures come from a benchmark script that runs against our npm package, and the full tables, including the slow first-query numbers, are on our &lt;a href="https://kanunilabs.com/benchmarks" rel="noopener noreferrer"&gt;benchmarks page&lt;/a&gt;. The small examples below are standalone and have no dependencies, so you can paste them into a file and check my numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The starting point: a comparator over row objects
&lt;/h2&gt;

&lt;p&gt;The obvious sort is the one everyone writes first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;revenue&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A comparison sort of a million items makes roughly 20 million comparisons (n log₂ n, and log₂ of a million is about 20). Every one of those is a call into your comparator. Any work inside it happens 20 million times, so the comparator has to be almost free.&lt;/p&gt;

&lt;p&gt;Ours was not. Our first date sort parsed the date strings inside the comparator. Here is that mistake next to the fix, on a million ISO date strings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 3.2 s&lt;/span&gt;
&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderedAt&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// 0.53 s: parse once per row, then sort an index array&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Float64Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;orderedAt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trick is decorate-sort-undecorate: compute each row's sort key once, sort positions by key, then read rows in that order. The source array is never touched, which also matters for a grid, because the user's data is not ours to reorder. The &lt;code&gt;|| a - b&lt;/code&gt; keeps equal dates in their original order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Text: rank once, then compare integers
&lt;/h2&gt;

&lt;p&gt;Text is where comparators hurt most. Sorting strings for humans needs &lt;code&gt;Intl.Collator&lt;/code&gt;, and a collator comparison is far more expensive than subtracting two numbers. In our engine, sorting a million product names this way takes about 2.2 seconds.&lt;/p&gt;

&lt;p&gt;The fix is to notice that a text column usually repeats itself. We already store each text column as a dictionary of distinct strings plus an integer code per row. So we sort the dictionary once with the collator, give every distinct string a rank, and after that a text sort is an integer sort. On repeat sorts of the same column our engine takes about 90 ms.&lt;/p&gt;

&lt;p&gt;There is a catch, and our first benchmark tables hid it. Ranking the dictionary &lt;em&gt;is&lt;/em&gt; a collator sort. If a column has few distinct values (a region, a status, a country), that sort is tiny and the first sort costs almost nothing extra. If every value is distinct, as with the product names in our benchmark, the first sort has to rank a million strings and took 1.6 to 1.9 seconds. That is not much better than the plain comparator. The ranks are cached until the data changes, so only the first sort pays. We now publish both numbers side by side, because the fast one on its own is misleading.&lt;/p&gt;

&lt;p&gt;One bug came out of this. Our collator uses &lt;code&gt;sensitivity: 'base'&lt;/code&gt;, so it treats "Berlin" and "berlin" as equal. Our first ranking code gave them different ranks anyway, because they were at different positions in the sorted dictionary. That quietly changed multi-column sorts: rows that should have tied on city and fallen through to the second sort key were ordered by capitalisation instead. Strings the collator calls equal have to share a rank.&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers: stop comparing at all
&lt;/h2&gt;

&lt;p&gt;With keys in a &lt;code&gt;Float64Array&lt;/code&gt;, the comparator is just &lt;code&gt;keys[a] - keys[b]&lt;/code&gt;. It is still 20 million function calls. On the same million values:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Uint32Array.prototype.sort&lt;/code&gt; with a comparator&lt;/td&gt;
&lt;td&gt;357–427 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;radix sort, no comparator&lt;/td&gt;
&lt;td&gt;80–90 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A radix sort does not compare elements. It distributes them into 256 buckets by one byte of their key, then does it again for the next byte, eight times for a 64-bit key. Each pass is a straight loop over typed arrays. Because every pass is stable, sorting by the lowest byte first and the highest byte last gives the full order.&lt;/p&gt;

&lt;p&gt;The awkward part is that the bytes of a double are not in numeric order. The sign bit is at the top, so every negative number looks larger than every positive one, and negative numbers are stored as magnitudes, so -5 looks larger than -1. Both can be fixed by rewriting the 64 bits before sorting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;f64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Float64Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;u32&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// little-endian: [low, high]&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;radixSortIndices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// -0 and +0: equal numbers, different bits&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x80000000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// negative: flip every bit, so -5 sorts below -1&lt;/span&gt;
      &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="mh"&gt;0x80000000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// positive: set the top bit, above all negatives&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;dst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;counts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;pass&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;pass&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pass&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shift&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pass&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
      &lt;span class="nx"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I checked this against the stable comparator sort on a million random values, including negatives and a &lt;code&gt;-0&lt;/code&gt; next to a &lt;code&gt;0&lt;/code&gt;, and the two orders came out identical.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;-0&lt;/code&gt; line is the second bug our tests caught. &lt;code&gt;-0 === 0&lt;/code&gt; is true, so a comparator calls them a tie and keeps their original order. Their bit patterns differ in the sign bit, so the radix sort put every &lt;code&gt;-0&lt;/code&gt; before every &lt;code&gt;0&lt;/code&gt;. It is rare in real data, until someone computes &lt;code&gt;0 * -1&lt;/code&gt; in a formula column.&lt;/p&gt;

&lt;p&gt;The version in our engine does a little more than this one. It parks empty cells (&lt;code&gt;NaN&lt;/code&gt; in the key array) after everything else in both directions. It sorts descending by inverting the key bits rather than reversing the output, because reversing would also reverse tied rows and break stability. And it skips any pass where every key has the same byte, which happens often with real data. Below 50,000 rows it does not use radix at all: the setup costs more than it saves.&lt;/p&gt;

&lt;p&gt;Sorting by several columns uses the same stability: one radix pass per column, starting from the &lt;em&gt;last&lt;/em&gt; sort column and ending with the first. That is also why the two-column sort is our slowest columnar query on repeat runs, at about 210 ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving it off the main thread
&lt;/h2&gt;

&lt;p&gt;Even at 100 ms, a sort on the main thread is a visible hitch if someone is scrolling while it runs. So above 20,000 rows the sort runs in a Web Worker. The worker holds the typed-array columns, runs the sort, and sends back a &lt;code&gt;Uint32Array&lt;/code&gt; of row positions in the transfer list, so the result moves between threads without being copied.&lt;/p&gt;

&lt;p&gt;Building those typed arrays still happens on the main thread, because it calls user-supplied value getters, and functions cannot be posted to a worker. For all nine columns of our benchmark at a million rows it takes about 3.3 seconds in total. Two things make that tolerable. Columns are encoded lazily, only when a query first needs them. And at 100,000 rows and above the encoder yields to the browser as it goes, so the page keeps painting. It is still the slowest thing in the pipeline, and I would rather say so than leave it out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the fast path honest
&lt;/h2&gt;

&lt;p&gt;Every one of these optimisations is a second implementation of something that already worked. So we never deleted the slow one. The simple row-based engine stays in the codebase as the reference, and a set of equivalence tests run the same queries through both and require identical row orders.&lt;/p&gt;

&lt;p&gt;Those tests caught the collator tie and the &lt;code&gt;-0&lt;/code&gt; ordering above. The third bug they found was in filtering, not sorting. The reference engine converted values with &lt;code&gt;Number()&lt;/code&gt; before comparing, and &lt;code&gt;Number(null)&lt;/code&gt; is &lt;code&gt;0&lt;/code&gt;. So a filter for &lt;code&gt;= 0&lt;/code&gt;, or for an empty string, matched every empty cell. The rule we settled on is that an empty cell equals only another empty cell, in both engines.&lt;/p&gt;

&lt;p&gt;If you are writing a fast path for anything, keep the slow version around as the test oracle. In our case it found all three of these before a release did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The standalone examples above run in any recent Node. To time the engine itself, the script on the &lt;a href="https://kanunilabs.com/benchmarks" rel="noopener noreferrer"&gt;benchmarks page&lt;/a&gt; installs from npm and prints the same table we publish, including the first query for each column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @kanunilabs/datagrid-core
curl &lt;span class="nt"&gt;-O&lt;/span&gt; https://kanunilabs.com/benchmarks/datagrid-pipeline.bench.mjs
node datagrid-pipeline.bench.mjs 1000000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your numbers will be different. If they are very different, I would like to know about it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: this article was drafted with AI assistance. The code was run and the numbers were measured by us in September 2026; the engine figures come from the published benchmark script against &lt;code&gt;@kanunilabs/datagrid-core&lt;/code&gt; 1.2.0.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>performance</category>
      <category>webdev</category>
      <category>algorithms</category>
    </item>
    <item>
      <title>What "free" means when a data grid also sells a paid edition</title>
      <dc:creator>KanuniLabs</dc:creator>
      <pubDate>Thu, 24 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kanunilabs/what-free-means-when-a-data-grid-also-sells-a-paid-edition-1n5c</link>
      <guid>https://dev.to/kanunilabs/what-free-means-when-a-data-grid-also-sells-a-paid-edition-1n5c</guid>
      <description>&lt;p&gt;Plenty of data grid libraries with a paid edition also have a free one, and the word "free" on their homepages covers several different deals. The &lt;code&gt;license&lt;/code&gt; field in &lt;code&gt;package.json&lt;/code&gt; does not settle it either. Ours says &lt;code&gt;SEE LICENSE IN LICENSE&lt;/code&gt;, which tells you nothing until you open the file.&lt;/p&gt;

&lt;p&gt;We sell a grid, so we have a stake in this. I'll use our own licence as the worked example throughout, including the part where we got it wrong in public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things "free" can mean
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Open source.&lt;/strong&gt; MIT, Apache-2.0 and similar. You can read the code, change it, fork it and ship it inside your own library. When a vendor with an open source grid also sells features, those features normally live in a separate package under a separate, commercial licence. The free package stays open source; the paid one does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free of charge, but proprietary.&lt;/strong&gt; No payment and no key, but the code is not yours to modify or redistribute on its own terms. Whether commercial use is allowed depends entirely on the licence text, and it varies. Our Community packages are this kind. The grant section says you may:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;(a) install and use the Software as a dependency of your applications, including commercial applications; and&lt;br&gt;
(b) reproduce and distribute the Software solely as an incorporated, compiled part of such applications.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So a commercial SaaS app is fine. The restrictions section is where people should slow down. You may not modify it, and you may not distribute it "on its own, or as part of any toolkit, component, library, or product that competes with KanuniLabs products". If you are building a UI kit to sell, our free licence does not cover you, and I would rather you learn that from this post than from an email.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free to evaluate.&lt;/strong&gt; All the features, but not licensed for production. The usual signals are a watermark, a console message or a time limit. Some vendors let the evaluation build keep working indefinitely with the watermark in place. Our paid package does exactly that when it has no key. It is easy to mistake for a free tier, but the licence does not cover shipping it that way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What paid editions usually charge for
&lt;/h2&gt;

&lt;p&gt;Once you look past the marketing pages, paid editions tend to draw their line along a few axes, often more than one at a time.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Features.&lt;/strong&gt; Editing, Excel export, server-side data, charts and pivoting are common candidates. Sorting, filtering and virtual scrolling are nearly always in the free tier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seats.&lt;/strong&gt; Priced per developer who writes code against the grid, not per end user. Check whether "developer" includes the person who only fixes a CSS bug once a year.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment.&lt;/strong&gt; Some licences count applications, domains or production deployments. "Unlimited apps" and "one key per domain" can both be true of the same product, so read the definitions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Updates.&lt;/strong&gt; A perpetual licence usually means you keep the version you bought and get updates for a fixed window. A subscription usually means the licence ends when you stop paying. What &lt;em&gt;happens&lt;/em&gt; at that point matters more than the word used.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point deserves an actual test before you sign anything. Ask what your users see if the key is missing or expired. If the component throws, your app's uptime is now tied to your billing. If it renders with a watermark, the failure is ugly but survivable. We wrote about why our keys are verified offline in &lt;a href="https://kanunilabs.com/blog/why-a-licence-key-is-not-a-secret" rel="noopener noreferrer"&gt;an earlier post&lt;/a&gt;, so I won't repeat it here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where our line is
&lt;/h2&gt;

&lt;p&gt;For completeness, here is the whole deal, because a post like this is useless if the author stays vague about their own product.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Community&lt;/strong&gt; is free on npm, needs no key and shows no watermark. It covers 98 of the 136 features across our two components (50 of 82 in DataGrid, 48 of 54 in PivotGrid). Commercial use is allowed under the terms above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The paid edition&lt;/strong&gt; adds editing with validation and undo, range selection and fill handle, master-detail and tree data, server-side data, a chart panel, and styled Excel and PDF export. It is a separate package on a private registry, not on public npm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Without a key&lt;/strong&gt; the paid package still runs. It shows a watermark instead of breaking, because you cannot evaluate a feature you cannot run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;With a key&lt;/strong&gt; the watermark is gone. One key covers both components.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prices&lt;/strong&gt; are $19 a month or $185 a year for one developer, and $79 a month or $699 a year for up to five. If a subscription ends, what you have shipped keeps running without a watermark: the key covers every version released while you were paying. New versions and support stop.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The mistake on our pricing page
&lt;/h2&gt;

&lt;p&gt;In August an outside audit of our site found that the Community card on our pricing page listed "Excel &amp;amp; PDF export" for DataGrid. The free package does not have that. If you call &lt;code&gt;exportToExcel&lt;/code&gt; on the Community grid, you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[datagrid] Excel export is an Enterprise feature. Import EnterpriseDataGrid
from @kanunilabs/datagrid-react-enterprise (CSV export is available in Community).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nobody did this on purpose. The feature list in the code and the bullet points on the pricing page were written in two different places, and they drifted. A developer who installed the free package because of that bullet would have been right to feel misled.&lt;/p&gt;

&lt;p&gt;The fix was partly editing the card and partly making the build fail the next time it happens. A check now reads our feature inventory and refuses to build if any free-tier bullet names a paid feature. It is deliberately narrow: it only looks for names that can mean nothing else, like "range selection" or "master-detail", because a check that fires on innocent sentences gets turned off within a week.&lt;/p&gt;

&lt;p&gt;If you are evaluating a grid, ours or anyone's, install the free package and call the feature you are planning around before you commit to it. It takes five minutes. We wrote up the checks we would run in &lt;a href="https://dev.to/kanunilabs/is-that-free-data-grid-free-for-your-project-checks-you-can-run-in-a-terminal-4anf"&gt;a separate post&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: this article was drafted with AI assistance and checked against our code, licence text and pricing by the KanuniLabs team. The numbers are from the repository as of September 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>react</category>
      <category>webdev</category>
      <category>npm</category>
    </item>
    <item>
      <title>Is that free data grid free for your project? Checks you can run in a terminal</title>
      <dc:creator>KanuniLabs</dc:creator>
      <pubDate>Mon, 21 Sep 2026 08:26:40 +0000</pubDate>
      <link>https://dev.to/kanunilabs/is-that-free-data-grid-free-for-your-project-checks-you-can-run-in-a-terminal-4anf</link>
      <guid>https://dev.to/kanunilabs/is-that-free-data-grid-free-for-your-project-checks-you-can-run-in-a-terminal-4anf</guid>
      <description>&lt;p&gt;A few weeks ago we found that &lt;a href="https://kanunilabs.com/pricing" rel="noopener noreferrer"&gt;our own pricing page&lt;/a&gt; listed a paid feature under the free tier. Nobody had noticed for a while. It made me wonder how many people pick a component because of a bullet point, and never check the thing they actually install.&lt;/p&gt;

&lt;p&gt;So here are the checks I would run on any "free" grid before building on it. Every command below was run on &lt;a href="https://www.npmjs.com/package/@kanunilabs/datagrid-react" rel="noopener noreferrer"&gt;our own Community package&lt;/a&gt;, and the output is pasted as it came out (one long npm error is trimmed, and marked as such), including the parts that do not flatter us. None of this is legal advice. It is the homework you do before asking a lawyer a better question.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Read the licence field, then ignore it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm view @kanunilabs/datagrid-react license
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SEE LICENSE IN LICENSE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That string is the npm convention for "the licence is custom, go read the file". An SPDX name like &lt;code&gt;MIT&lt;/code&gt; is more informative, but it only describes that one package. If the features you need are in a second package, the second package has its own licence, and it is usually not MIT.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Read the LICENSE file from the tarball
&lt;/h2&gt;

&lt;p&gt;Not the README, not the website. The file that ships with the version you install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm pack @kanunilabs/datagrid-react
&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-xzf&lt;/span&gt; kanunilabs-datagrid-react-&lt;span class="k"&gt;*&lt;/span&gt;.tgz
&lt;span class="nb"&gt;cat &lt;/span&gt;package/LICENSE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Things to look for, roughly in order of how often they surprise people:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Commercial use.&lt;/strong&gt; Is it granted in words, or just not forbidden?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redistribution.&lt;/strong&gt; Can it ship inside your app? Inside a library you publish?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Modification.&lt;/strong&gt; Can you patch it if a bug blocks your release?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Competing products.&lt;/strong&gt; Some free licences exclude people building component libraries or dev tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Termination.&lt;/strong&gt; What ends the licence, and what you must do then.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ours grants use "including commercial applications" and distribution "as an incorporated, compiled part of such applications". It forbids modification, and it forbids shipping the package as part of a library or toolkit that competes with ours. If you are building an internal dashboard or a SaaS product, that is fine. If you are building a component kit to sell, it is not.&lt;/p&gt;

&lt;p&gt;Save a copy of this file next to your release notes. If a later version changes its terms, you want to be able to show which text you agreed to.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Find out where the features you need actually live
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm view @kanunilabs/datagrid-react dependencies peerDependencies &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@kanunilabs/datagrid-core"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.2.0"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"peerDependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"react"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;=18.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"react-dom"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;=18.0.0"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing paid is pulled in. The paid edition is a different package, and it is not on the public registry at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm view @kanunilabs/datagrid-react-enterprise version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;npm error code E404
npm error 404 Not Found - GET https://registry.npmjs.org/@kanunilabs%2fdatagrid-react-enterprise - Not found
npm error 404
(trimmed)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a quick way to read a vendor's docs. If the example for the feature you need imports from a package that the first command did not list, that feature is probably not in the free tier, whatever the feature table says.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Call the feature from the free package
&lt;/h2&gt;

&lt;p&gt;The fastest check of all. Take the one feature you would be most upset to lose, and call it on the free component. On ours, Excel export is a paid feature. You do not even need a browser to check, because the controller runs in Node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DataGridController&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@kanunilabs/datagrid-core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;grid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DataGridController&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="na"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;b&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="na"&gt;rowKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exportToExcel&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[datagrid] Excel export is an Enterprise feature. Import EnterpriseDataGrid from @kanunilabs/datagrid-react-enterprise (CSV export is available in Community).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the behaviour I would want from any library: a loud, specific error at the moment of the call. A method that silently does nothing is much worse, because you find out from a user.&lt;/p&gt;

&lt;p&gt;If you end up on a paid edition, repeat the test somewhere that is not localhost. Many licence checks treat localhost as always allowed, and ours is one of them. A key limited to certain domains will look fine on your machine and show a watermark on a staging domain you forgot to add to the key.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Search the build for licence and watermark code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rilE&lt;/span&gt; &lt;span class="s2"&gt;"watermark|unlicensed|trial|licen[cs]e key"&lt;/span&gt; package/dist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On our two Community packages this matched only the type declaration files (&lt;code&gt;index.d.ts&lt;/code&gt;, &lt;code&gt;index.d.cts&lt;/code&gt;), never the JavaScript. The matches are doc comments: one describes the overlay slot the paid edition uses for its watermark, one mentions the unlicensed state in an API description. The code that draws a watermark is not in the free packages.&lt;/p&gt;

&lt;p&gt;A match in the JavaScript is not proof of anything bad. It is a pointer to read that part of the bundle and find out when it runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Search for network calls
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rEo&lt;/span&gt; &lt;span class="s2"&gt;"fetch&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;|XMLHttpRequest|sendBeacon|new WebSocket"&lt;/span&gt; package/dist | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No output on ours. That means the Community packages do not phone home for licence checks or telemetry.&lt;/p&gt;

&lt;p&gt;A static search can miss calls built at runtime, so do the dynamic version too: load your app with the grid, open the network tab, filter out your own API, and use the grid for a minute. If anything leaves the page that you did not ask for, find out what it is before production does it for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What these checks do not cover
&lt;/h2&gt;

&lt;p&gt;They will not tell you whether a library is maintained, whether the vendor will still exist in three years, or what the paid tier costs once your team grows. They also cannot catch terms that live outside the package, such as a separate EULA on a website. For those, ask the vendor in writing.&lt;/p&gt;

&lt;p&gt;What they do give you is the licence you would actually be installing and the behaviour you would actually get, checked in about twenty minutes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: this article was drafted with AI assistance. Every command was run by us against the published packages (&lt;code&gt;@kanunilabs/datagrid-react&lt;/code&gt; 1.1.1, &lt;code&gt;@kanunilabs/datagrid-core&lt;/code&gt; 1.2.0) in September 2026; outputs are unedited except where marked as trimmed.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>npm</category>
      <category>react</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
