<?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: Tony Goodchild</title>
    <description>The latest articles on DEV Community by Tony Goodchild (@tonygoodchild).</description>
    <link>https://dev.to/tonygoodchild</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%2F4138285%2F0d9d3906-b917-46ec-88f0-2caa58084795.jpg</url>
      <title>DEV Community: Tony Goodchild</title>
      <link>https://dev.to/tonygoodchild</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tonygoodchild"/>
    <language>en</language>
    <item>
      <title>Every data grid claims to handle live data.</title>
      <dc:creator>Tony Goodchild</dc:creator>
      <pubDate>Tue, 13 Oct 2026 20:03:00 +0000</pubDate>
      <link>https://dev.to/tonygoodchild/every-data-grid-claims-to-handle-live-data-4j53</link>
      <guid>https://dev.to/tonygoodchild/every-data-grid-claims-to-handle-live-data-4j53</guid>
      <description>&lt;p&gt;Every data grid claims to handle live data. We wanted a workload that would not let us hand-wave, so we compiled Doom to WebAssembly and made a grid its screen. You can play it here: &lt;a href="https://toclocoinc.github.io/lattice-grid-demo-doom/" rel="noopener noreferrer"&gt;https://toclocoinc.github.io/lattice-grid-demo-doom/&lt;/a&gt; (arrows move, F or Shift fires, Space uses). The code is on GitHub: &lt;a href="https://github.com/toclocoinc/lattice-grid-demo-doom" rel="noopener noreferrer"&gt;https://github.com/toclocoinc/lattice-grid-demo-doom&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the story of how it works, what we measured and what changed in the grid because of it.&lt;/p&gt;

&lt;p&gt;The setup&lt;/p&gt;

&lt;p&gt;The engine is doomgeneric, a port of the original Doom source whose only job is to hand each rendered frame to the host as a raw pixel buffer. It is compiled with Emscripten and runs Freedoom's level data, which is freely redistributable. There is no SDL, no audio and no canvas in the page.&lt;/p&gt;

&lt;p&gt;Each frame arrives as 320 by 200 pixels. The page keeps the previous frame, diffs the new one against it, and works out which rows changed. Then it pushes those rows into a Lattice Grid through the ordinary row-update call, grid.rows.apply, the same call an application uses when a price ticks or a status flips. The grid does not know it is drawing a game.&lt;/p&gt;

&lt;p&gt;Pixels as cells, then rows as gradients&lt;/p&gt;

&lt;p&gt;The first version was the purest one: one grid cell per pixel, each cell's background colour coming from its value through a formatting rule. At 80 by 50 that is 4,000 cells, and it plays at around 33 frames a second. It is also the version that made us measure properly.&lt;/p&gt;

&lt;p&gt;Timing a rows.apply call against the next animation frame reported about 17 milliseconds whatever we threw at it. That number was the cost of enqueueing the update. The grid, like any serious renderer, defers its DOM writes to its own frame tick, so the honest measurement is the time from the call until the changed cell's computed background is actually on screen. We built a small harness for exactly that, and it became the yardstick for everything that followed.&lt;/p&gt;

&lt;p&gt;At 160 by 100 and above, the limit is the browser itself: restyling and painting ten thousand changed elements a frame is more than any DOM can do at game speed. So the shipped demo takes a different shape. The grid has one column and 200 rows. Each row's cell carries its line of pixels as a single hard-stop CSS gradient, with runs of the same colour merged, still applied through the formatting rule, still delivered through rows.apply. A frame that changes 35,000 pixels becomes about 170 row writes. Native 320 by 200 runs at 30 to 60 frames a second on a desktop Chrome, and the engine's own 35 frame cap is now the limiter.&lt;/p&gt;

&lt;p&gt;The page keeps both ideas visible. The resolution switch offers 160 by 100 and 80 by 50, and an automatic fallback drops to 160 by 100 if a machine cannot hold 30 frames a second during the first seconds of play.&lt;/p&gt;

&lt;p&gt;What changed in the grid&lt;/p&gt;

&lt;p&gt;A demo like this is only worth doing if the product gets better. Two things did, and both shipped this week to every user.&lt;/p&gt;

&lt;p&gt;Conditional formatting rules now cost the same as unstyled cells on a bulk update. Put a rule on every cell of a live board and 4,000 styled cells still update in one frame.&lt;/p&gt;

&lt;p&gt;A partial update now costs only the rows and cells it touched. Attributes, classes, text nodes and cell positions are written only when their value actually moves, and the overflow-title measurement runs only for a cell whose text or width changed. On an 80 by 50 grid, 4,000 changed styled cells reach the screen in about 49 milliseconds, and 900 in about 33, measured by the harness above.&lt;/p&gt;

&lt;p&gt;Neither change needs an option. A grid fed by a websocket, a polling loop or a trading feed simply does less work per tick.&lt;/p&gt;

&lt;p&gt;Try it&lt;/p&gt;

&lt;p&gt;Play it in the browser: &lt;a href="https://toclocoinc.github.io/lattice-grid-demo-doom/" rel="noopener noreferrer"&gt;https://toclocoinc.github.io/lattice-grid-demo-doom/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read the pipeline, it is under 200 lines: &lt;a href="https://github.com/toclocoinc/lattice-grid-demo-doom" rel="noopener noreferrer"&gt;https://github.com/toclocoinc/lattice-grid-demo-doom&lt;/a&gt;&lt;/p&gt;

</description>
      <category>github</category>
      <category>javascript</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Chart a derived grid: source and derived data in one live chart</title>
      <dc:creator>Tony Goodchild</dc:creator>
      <pubDate>Thu, 08 Oct 2026 11:05:00 +0000</pubDate>
      <link>https://dev.to/tonygoodchild/chart-a-derived-grid-source-and-derived-data-in-one-live-chart-1i1p</link>
      <guid>https://dev.to/tonygoodchild/chart-a-derived-grid-source-and-derived-data-in-one-live-chart-1i1p</guid>
      <description>&lt;h1&gt;
  
  
  Chart a derived grid: source and derived data in one live chart
&lt;/h1&gt;

&lt;p&gt;A revenue-by-region chart next to an orders table is two things to keep in step. Filter the table and the chart either follows or it doesn't, and the difference is usually a line of sync code nobody wrote.&lt;/p&gt;

&lt;p&gt;The three usual fixes all leak:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A second query.&lt;/strong&gt; Two round trips doing the same arithmetic, two places for the business logic to drift apart, and a second failure mode on a panel that only summarises the first.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A copied array.&lt;/strong&gt; &lt;code&gt;orders&lt;/code&gt; reduced into a summary once, on load. Nothing tells the copy when the original changes.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A spreadsheet export.&lt;/strong&gt; Correct for exactly the moment it was taken.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://www.latticegrid.dev" rel="noopener noreferrer"&gt;Lattice Grid&lt;/a&gt; has a different primitive: a &lt;strong&gt;derived grid&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a derived grid is
&lt;/h2&gt;

&lt;p&gt;A derived grid is a grid whose source is another grid rather than a dataset. You don't give it rows; you give it a recipe:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Which of the parent's rows to read&lt;/strong&gt; (&lt;code&gt;follow&lt;/code&gt;): &lt;code&gt;filtered&lt;/code&gt; (whatever the parent's filters leave), &lt;code&gt;selected&lt;/code&gt; (only the ticked rows), &lt;code&gt;grouped&lt;/code&gt; (one row per group of the user's own grouping) or &lt;code&gt;all&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What to do with them&lt;/strong&gt;: group and aggregate (&lt;code&gt;groupBy&lt;/code&gt; + &lt;code&gt;select&lt;/code&gt;), bucket dates (&lt;code&gt;bucket&lt;/code&gt;), join a related grid, sort, keep the top N.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It holds no rows of its own. When the parent's rows change, whether from a filter, an edit or new data arriving, it re-runs the recipe over the rows the parent already has in memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why that works
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;There is only one copy of the data.&lt;/strong&gt; The summary is a function of the source, not a second dataset, so it cannot drift.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No second round trip.&lt;/strong&gt; The rows already arrived once; the derivation is in-memory arithmetic over them.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rule lives in one place.&lt;/strong&gt; "Revenue by region" is declared once, next to the grid it summarises.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is still a grid.&lt;/strong&gt; Sorting, filtering, export, KPI tiles and charts all work on it unchanged, and they all inherit its liveness. A chart only ever reads a grid, so a chart over a derived grid is live by construction.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is cheap by default.&lt;/strong&gt; Re-derivation runs once per idle frame (&lt;code&gt;refresh: 'idle'&lt;/code&gt;), so a hundred edits in one burst cost one recalculation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A chart that follows a filter
&lt;/h2&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;createGrid&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;@toclocoinc/lattice-grid&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createChart&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;@toclocoinc/lattice-grid/modules/charts&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;book&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createGrid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#orders&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;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="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="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;title&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&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;region&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Region&lt;/span&gt;&lt;span class="dl"&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="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;set&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;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;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;title&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;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;dateString&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;amount&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Amount&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="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;orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// sixteen orders across two months and three regions  &lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// A second grid, built from the first.  &lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;byRegion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createGrid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#by-region&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;columns&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;region&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Region&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;title&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;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;source&lt;/span&gt;&lt;span class="p"&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;derived&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  
    &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;book&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  
    &lt;span class="na"&gt;follow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;filtered&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  
    &lt;span class="na"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;live&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  
    &lt;span class="na"&gt;groupBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;region&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  
    &lt;span class="na"&gt;select&lt;/span&gt;&lt;span class="p"&gt;:&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;amount&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fn&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;sort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;col&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;dir&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="p"&gt;},&lt;/span&gt;  
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// The chart reads byRegion, not a copy of its rows.  &lt;/span&gt;
&lt;span class="nf"&gt;createChart&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;byRegion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#chart&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;bar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;region&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;follow: 'filtered'&lt;/code&gt; means the derived grid reads whatever the orders grid's filters leave. Filter the orders to EMEA and Americas, and &lt;code&gt;byRegion&lt;/code&gt; drops to two rows: APAC is gone from the grid and from the chart in the same redraw, with no second call behind either.&lt;/p&gt;

&lt;p&gt;The other follow modes: &lt;code&gt;selected&lt;/code&gt; reads only the ticked rows, &lt;code&gt;grouped&lt;/code&gt; reads one row per group of the user's own grouping, and &lt;code&gt;all&lt;/code&gt; ignores the parent's filter (for an exceptions list that shouldn't shrink with the table).&lt;/p&gt;

&lt;h2&gt;
  
  
  Source and derived data in one chart
&lt;/h2&gt;

&lt;p&gt;A chart reads one grid. To overlay the raw orders and a derived monthly total, build a third grid whose &lt;code&gt;from&lt;/code&gt; is a &lt;strong&gt;union&lt;/strong&gt; of the two. Each source gets a &lt;code&gt;label&lt;/code&gt; and a &lt;code&gt;map&lt;/code&gt; that reshapes its rows, and every combined row keeps a &lt;code&gt;__source&lt;/code&gt; field saying where it came from.&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;monthlyTotal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createGrid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#monthly&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;columns&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;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;title&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;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;dateString&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;title&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;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;source&lt;/span&gt;&lt;span class="p"&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;derived&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;book&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;follow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;filtered&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;live&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  
    &lt;span class="na"&gt;groupBy&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;bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;of&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;by&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;select&lt;/span&gt;&lt;span class="p"&gt;:&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;amount&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fn&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;},&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;overlay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createGrid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#overlay&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;columns&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;__source&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Feed&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;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;title&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="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;daily&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Daily&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;monthly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Monthly&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;source&lt;/span&gt;&lt;span class="p"&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;derived&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;live&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  
    &lt;span class="na"&gt;from&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;grid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;book&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="p"&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;date&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;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;daily&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;amount&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;grid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;monthlyTotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;monthly total&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="p"&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;date&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;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;monthly&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;revenue&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;sort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;col&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;dir&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="p"&gt;},&lt;/span&gt;  
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;createChart&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;  
  &lt;span class="na"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;overlay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  
  &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#overlay-chart&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;combo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  
  &lt;span class="na"&gt;x&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;measures&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;col&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;daily&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fn&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;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;bar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;axis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;left&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Daily orders&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;col&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;monthly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fn&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;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;line&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;axis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;right&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Monthly total&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;map&lt;/code&gt; step is what makes the overlay work: the two sources don't share a column (one has &lt;code&gt;amount&lt;/code&gt;, the other &lt;code&gt;revenue&lt;/code&gt;), so each map renames its value into a column of its own. That lets one chart draw daily orders as bars and the monthly total as a line on its own right-hand axis, since a month of orders runs far larger than any single order.&lt;/p&gt;

&lt;p&gt;With the sixteen sample orders, &lt;code&gt;overlay&lt;/code&gt; holds eighteen rows: sixteen tagged &lt;code&gt;orders&lt;/code&gt; and two tagged &lt;code&gt;monthly total&lt;/code&gt; (January 3,725, February 3,090). Those two points are read from the same rows as the bars, not typed in separately. Filter the orders and &lt;code&gt;monthlyTotal&lt;/code&gt; re-derives first, &lt;code&gt;overlay&lt;/code&gt; next, and the chart redraws last, all on one frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to reach for one
&lt;/h2&gt;

&lt;p&gt;A derived grid re-derives on an idle frame by default (&lt;code&gt;refresh: 'idle'&lt;/code&gt;), so a hundred edits in one event cost one derivation. &lt;code&gt;refresh: 'live'&lt;/code&gt; re-derives on every change, which suits a demo and wastes effort on a feed updating faster than anyone can read. &lt;code&gt;refresh: 'manual'&lt;/code&gt; never re-derives on its own, for a panel someone refreshes with a button.&lt;/p&gt;

&lt;p&gt;Both demos run live, with the full code, here: &lt;strong&gt;&lt;a href="https://www.latticegrid.dev/blog/chart-a-derived-grid/" rel="noopener noreferrer"&gt;Chart a Derived Grid&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>javascript</category>
      <category>softwaredevelopment</category>
      <category>webdev</category>
    </item>
    <item>
      <title>We Migrated 166 Grids off AG Grid. Here's What Broke.</title>
      <dc:creator>Tony Goodchild</dc:creator>
      <pubDate>Fri, 02 Oct 2026 12:10:48 +0000</pubDate>
      <link>https://dev.to/tonygoodchild/we-migrated-166-grids-off-ag-grid-heres-what-broke-4i66</link>
      <guid>https://dev.to/tonygoodchild/we-migrated-166-grids-off-ag-grid-heres-what-broke-4i66</guid>
      <description>&lt;p&gt;We moved DemandFlow, our own B2B platform, off AG Grid and onto &lt;a href="https://www.latticegrid.dev" rel="noopener noreferrer"&gt;Lattice Grid,&lt;/a&gt; the grid we also make. That makes us an interested party, so weigh the good results accordingly. It also means every defect below was found by us, in our own product, before a customer could hit it.&lt;/p&gt;

&lt;p&gt;Every grid fix it produced shipped in Lattice Grid 1.56 and 1.57; the current release is 1.85.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The result&lt;/strong&gt;&lt;br&gt;
All 166 grids moved in about a week, and three libraries left the codebase. That week covered building the conversion layer, converting each grid and re-testing each one afterwards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AG Grid is gone from all 166 grids.&lt;/strong&gt;&lt;br&gt;
FullCalendar, which ran the scheduling screens, is replaced by Lattice Grid’s calendar module.&lt;br&gt;
DHTMLX Kanban, which ran the boards, is replaced by Lattice Grid’s kanban module, reading the same data as the grids.&lt;br&gt;
State handling for sort, filter, grouping and column layout went from about 700 lines to about 190, built on Lattice Grid’s state object and state.apply().&lt;br&gt;
Why a week was enough&lt;br&gt;
Every grid in DemandFlow was already created through one house function, df_lattice_create_grid. It took the AG Grid options a screen already had and translated them into a Lattice Grid configuration. That turned 166 separate integrations into one conversion function and 166 call sites.&lt;/p&gt;

&lt;p&gt;Around it sat a small set of shared helpers: one that builds context menus, generators that turn a field list into column definitions, and a few more for jobs every screen repeats. Converting a helper converted every screen that used it. Most per-grid work came down to checking the result, not rewriting the screen.&lt;/p&gt;

&lt;p&gt;It also let us get basic grids running quickly, then move each helper across as its own small piece of work instead of one large cut-over. The scheduling screens show how far that goes: moving them off FullCalendar took about an hour.&lt;/p&gt;

&lt;p&gt;If you are planning a move like this, copy that part first. Put every grid behind your own helpers while you are still on the old library, and the migration becomes a change to the helpers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What broke in our grid&lt;/strong&gt;&lt;br&gt;
Converting 166 real grids put configurations in front of Lattice Grid that our own tests never had reason to write: options carried over verbatim from AG Grid, values typed from memory, and edge cases that only appear with real users. Most of what surfaced followed one pattern, and it is the lesson worth keeping: rejected input should never become state. A grid that refuses a bad value but quietly keeps it is worse than one that never checked, because the host believes it is protected.&lt;/p&gt;

&lt;p&gt;Four examples show the pattern:&lt;/p&gt;

&lt;p&gt;A sort on a column that did not exist was rejected for sorting but still written into saved state, so the rejection repeated on every reload. It is now dropped before it is stored.&lt;br&gt;
An unknown filter operator was accepted and matched every row, while the filter panel showed as active. It is now refused, and the same check covers relative-date tokens.&lt;br&gt;
An unrecognised column key or column type was silently ignored. The grid now warns once, naming the key, the column and, for types, what it fell back to.&lt;br&gt;
The state:changed event fired for only some state changes, so auto-save built on it missed a user’s own sorts and column moves. It now fires once per change, whether from a gesture or an API call.&lt;br&gt;
The rejected-input cases now print a warning naming what was rejected and why, listed in the warnings reference. The full list of fixes, including smaller ones, is in the changelog for 1.56 and 1.57.&lt;/p&gt;

&lt;p&gt;Those warnings turned out to be the part of the migration that paid off fastest: “the warning messages are the best I have seen in a grid library.”&lt;/p&gt;

&lt;p&gt;The migration also drove three additions: checkboxOnly, so a click outside the checkbox column no longer selects the row; defaults(), for house-wide settings applied once instead of on 166 call sites; and documentation of which features run headlessly with no DOM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What broke in our own code&lt;/strong&gt;&lt;br&gt;
A conversion is also an audit of the code being converted. One screen’s AG Grid options defined getRowStyle twice. A JavaScript object literal keeps only the last definition of a repeated key, so the row shading tied to a ranking field never ran in production. Nobody noticed, because a grid without shading does not look broken; it looks plain.&lt;/p&gt;

&lt;p&gt;The lesson is to read every key before you carry it across. A mechanical migration through a shared seam is fast precisely because it trusts the old configuration, and that same trust copies old mistakes forward. Treat the source configuration as something to check, not as ground truth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you are considering the same move&lt;/strong&gt;&lt;br&gt;
This is one application’s experience, not a benchmark; we will not publish one without a runnable public harness behind it. Three places to start:&lt;/p&gt;

&lt;p&gt;The AG Grid migration guide maps AG Grid options, events and API calls to their Lattice Grid equivalents, with one real configuration converted line by line.&lt;br&gt;
The AG Grid alternative page covers licensing, feature coverage and where each grid is ahead.&lt;br&gt;
The warnings reference lists the warning you will see for each problem described above, and what it means.&lt;br&gt;
If anything else turns up from this migration, we will add it here.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>software</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Build a CRUD app with a JavaScript grid, Node and DynamoDB. Part 3: edit in place</title>
      <dc:creator>Tony Goodchild</dc:creator>
      <pubDate>Wed, 30 Sep 2026 14:08:01 +0000</pubDate>
      <link>https://dev.to/tonygoodchild/build-a-crud-app-with-a-javascript-grid-node-and-dynamodb-part-3-edit-in-place-1m0e</link>
      <guid>https://dev.to/tonygoodchild/build-a-crud-app-with-a-javascript-grid-node-and-dynamodb-part-3-edit-in-place-1m0e</guid>
      <description>&lt;p&gt;&lt;em&gt;This is the dev.to edition of a tutorial that lives, with a live copy of the finished grid you can try, at &lt;a href="https://www.latticegrid.dev/tutorials/crud-dynamodb-part-3/" rel="noopener noreferrer"&gt;latticegrid.dev/tutorials/crud-dynamodb-part-3&lt;/a&gt;. &lt;a href="https://dev.to/tonygoodchild/build-a-crud-app-with-a-javascript-grid-node-and-dynamodb-part-1-read-the-data-360b"&gt;Part 1&lt;/a&gt; built the table, the Lambda function and a page that reads from it; &lt;a href="https://dev.to/tonygoodchild/build-a-crud-app-with-a-javascript-grid-node-and-dynamodb-part-2-add-and-delete-p4p"&gt;Part 2&lt;/a&gt; added a product through the grid's form and deleted one from its menu.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Part 2 gave the page add and delete. This part makes the cells themselves editable, with each change patched to DynamoDB the moment it is committed and put back if the server refuses it. It also adds a full-screen button and moves the row form out of its drawer into a panel that is part of the page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source:&lt;/strong&gt; the &lt;code&gt;part-3&lt;/code&gt; folder of &lt;a href="https://github.com/toclocoinc/lattice-tutorial-crud-dynamodb" rel="noopener noreferrer"&gt;toclocoinc/lattice-tutorial-crud-dynamodb&lt;/a&gt; (&lt;a href="https://github.com/toclocoinc/lattice-tutorial-crud-dynamodb/archive/refs/heads/main.zip" rel="noopener noreferrer"&gt;Download ZIP&lt;/a&gt;). Needs &lt;a href="https://www.latticegrid.dev/" rel="noopener noreferrer"&gt;Lattice Grid&lt;/a&gt; 1.76.0 or later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: the function learns PATCH
&lt;/h2&gt;

&lt;p&gt;On the function's &lt;strong&gt;Code&lt;/strong&gt; tab, replace &lt;code&gt;index.mjs&lt;/code&gt; with &lt;code&gt;lambda/index.mjs&lt;/code&gt; from the download and choose &lt;strong&gt;Deploy&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PATCH&lt;/code&gt; takes &lt;code&gt;?sku=&lt;/code&gt; and a JSON body holding only the fields that changed. It builds a DynamoDB update expression from the fields it allows (&lt;code&gt;name&lt;/code&gt;, &lt;code&gt;category&lt;/code&gt;, &lt;code&gt;quantity&lt;/code&gt;, &lt;code&gt;price&lt;/code&gt;), stamps &lt;code&gt;updated&lt;/code&gt;, and refuses to update a product that does not exist, so a PATCH can never create one by accident. It answers with the whole product as stored.&lt;/p&gt;

&lt;p&gt;The allow-list matters. A PATCH endpoint is a public door in this tutorial, and without the list a caller could set any attribute on any product. With it, the function only ever changes the four fields the page edits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: allow PATCH through the URL
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Configuration&lt;/strong&gt;, &lt;strong&gt;Function URL&lt;/strong&gt;, &lt;strong&gt;Edit&lt;/strong&gt;. Under the CORS settings add &lt;code&gt;PATCH&lt;/code&gt; to &lt;strong&gt;Allow methods&lt;/strong&gt;, keeping &lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;POST&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt; and the &lt;code&gt;content-type&lt;/code&gt; header. Save. The IAM policy from part 2 already includes &lt;code&gt;UpdateItem&lt;/code&gt;, so nothing changes there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: the page
&lt;/h2&gt;

&lt;p&gt;Paste your Function URL into &lt;code&gt;web/index.html&lt;/code&gt; as before and open it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Editable cells.&lt;/strong&gt; Four columns now carry &lt;code&gt;edit: true&lt;/code&gt;. Double-click a quantity, type a new number, press Enter. The cell shows the new value at once, and the grid calls the &lt;code&gt;commit&lt;/code&gt; hook you gave it:&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;edit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;write&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;api&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;?sku=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;write&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;write&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;colId&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="nx"&gt;write&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="p"&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/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2h0eimlup98qdq9k7rog.webp" 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%2F2h0eimlup98qdq9k7rog.webp" alt="A quantity cell mid-edit, showing a typed value in the cell's own input" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;write&lt;/code&gt; names the row's key, the column and the new value, and the promise you return settles the write: resolve and the cell stays, reject and the grid puts the old value back and fires &lt;code&gt;cell:reverted&lt;/code&gt;, which the status line reports. That is optimistic write-back: the user never waits for the network, and a refusal is visible rather than silent.&lt;/p&gt;

&lt;p&gt;Try it against the rules: clear a name and press Enter. The function answers 400, the cell reverts, and the status line says why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full screen.&lt;/strong&gt; The button calls &lt;code&gt;grid.maximise.toggle()&lt;/code&gt;. The grid fills the window, remembering where it came from and its scroll position, and the same button or Escape puts it back. The grid's own rail offers the same action if you turn the tool panel on; a button is the shorter path for this page.&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%2F1vlnduu1527zbks7w8d8.webp" 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%2F1vlnduu1527zbks7w8d8.webp" alt="The grid filling the browser window after Full screen is pressed" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The form in a panel.&lt;/strong&gt; The row form has two new lines:&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;rowForm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;container&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#form&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;trigger&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="p"&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/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkzpsfe7h8nfdp54d2vk9.webp" 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%2Fkzpsfe7h8nfdp54d2vk9.webp" alt="The row form open in a side panel beside the grid, showing the fields of the selected row" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With a container, the form renders inside that element instead of over the grid as a drawer. &lt;code&gt;trigger: false&lt;/code&gt; takes double-click away from the form, because the cells now want it; the menu gains an &lt;strong&gt;Edit in form&lt;/strong&gt; item that calls &lt;code&gt;grid.form.open(p.key)&lt;/code&gt; instead. Right-click a row, choose it, and the row appears in the side panel with the same fields as part 2's form. Save goes through the same &lt;code&gt;commit&lt;/code&gt; hook as a cell edit, one call per changed field, so nothing about persistence changed when the form moved. &lt;strong&gt;Add product&lt;/strong&gt; opens the same panel over an empty record, and &lt;code&gt;create&lt;/code&gt; handles it as in part 2.&lt;/p&gt;

&lt;p&gt;That double-click decision is worth making on purpose in any page that has both. Left to the defaults, the form opens on double-click and the cell never enters edit mode, which reads as "editing does not work".&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: check the table
&lt;/h2&gt;

&lt;p&gt;Change a price, then look at the item in &lt;strong&gt;Explore table items&lt;/strong&gt;. The price and the &lt;code&gt;updated&lt;/code&gt; date have moved. Change it again in the console instead and reload the page; the grid shows the console's value, because the page has no copy of the truth beyond what the function returns.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you have
&lt;/h2&gt;

&lt;p&gt;A grid that reads, adds, edits and deletes against your own table, with every change confirmed by the server and every refusal shown. The page is under a hundred lines of script, and the parts that would take a week to build by hand, the form, the menu, the optimistic edit with revert, the full-screen mode, came from the grid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;Two things this series left out on purpose. The Function URL has no authentication, so anyone with the URL can change your products; the next step for a real application is an authorizer in front of it, and the grid does not care which. And the function returns every product on every load, which is right at sixty rows and wrong at sixty thousand; the grid's &lt;a href="https://www.latticegrid.dev/docs/pushdown/" rel="noopener noreferrer"&gt;pushdown sources&lt;/a&gt; move filtering, sorting and paging into the function so the browser only asks for what it shows. Both are covered in the &lt;a href="https://www.latticegrid.dev/guides/" rel="noopener noreferrer"&gt;guides&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaning up
&lt;/h2&gt;

&lt;p&gt;As in part 1: delete the function, the table and the role.&lt;/p&gt;

&lt;p&gt;Read the full source, including &lt;code&gt;lambda/index.mjs&lt;/code&gt; and &lt;code&gt;web/index.html&lt;/code&gt;, on the &lt;a href="https://github.com/toclocoinc/lattice-tutorial-crud-dynamodb" rel="noopener noreferrer"&gt;public repository&lt;/a&gt;, or grab it as a &lt;a href="https://github.com/toclocoinc/lattice-tutorial-crud-dynamodb/archive/refs/heads/main.zip" rel="noopener noreferrer"&gt;ZIP download&lt;/a&gt; and skip the cloning.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Lattice Grid is a JavaScript data grid with charts, dashboards and server-side pushdown, free to develop with on localhost. Inline editing with optimistic write-back, the row form and full-screen mode are part of the core grid: &lt;a href="https://www.latticegrid.dev/" rel="noopener noreferrer"&gt;latticegrid.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>aws</category>
      <category>dynamodb</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Build a CRUD app with a JavaScript grid, Node and DynamoDB. Part 2: add and delete</title>
      <dc:creator>Tony Goodchild</dc:creator>
      <pubDate>Wed, 30 Sep 2026 11:35:49 +0000</pubDate>
      <link>https://dev.to/tonygoodchild/build-a-crud-app-with-a-javascript-grid-node-and-dynamodb-part-2-add-and-delete-p4p</link>
      <guid>https://dev.to/tonygoodchild/build-a-crud-app-with-a-javascript-grid-node-and-dynamodb-part-2-add-and-delete-p4p</guid>
      <description>&lt;p&gt;&lt;em&gt;This is the dev.to edition of a tutorial that lives, with a live copy of the finished grid you can try, at &lt;a href="https://www.latticegrid.dev/tutorials/crud-dynamodb-part-2/" rel="noopener noreferrer"&gt;latticegrid.dev/tutorials/crud-dynamodb-part-2&lt;/a&gt;. &lt;a href="https://dev.to/tonygoodchild/build-a-crud-app-with-a-javascript-grid-node-and-dynamodb-part-1-read-the-data-360b"&gt;Part 1&lt;/a&gt; built the table, the Lambda function and a page that reads from it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Part 1 ended with a page that reads products from your own DynamoDB table through a Lambda function. This part adds the two verbs that make it an application: adding a product through the grid's built-in form, and deleting one from the right-click menu. Both land in DynamoDB before the page moves on.&lt;/p&gt;

&lt;p&gt;Everything from part 1 carries over. You will change the function's code, widen its permissions and its URL settings, and swap the page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source:&lt;/strong&gt; the &lt;code&gt;part-2&lt;/code&gt; folder of &lt;a href="https://github.com/toclocoinc/lattice-tutorial-crud-dynamodb" rel="noopener noreferrer"&gt;toclocoinc/lattice-tutorial-crud-dynamodb&lt;/a&gt; (&lt;a href="https://github.com/toclocoinc/lattice-tutorial-crud-dynamodb/archive/refs/heads/main.zip" rel="noopener noreferrer"&gt;Download ZIP&lt;/a&gt;). This part needs &lt;a href="https://www.latticegrid.dev/" rel="noopener noreferrer"&gt;Lattice Grid&lt;/a&gt; 1.76.0 or later, which is the version the page loads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: teach the function two more verbs
&lt;/h2&gt;

&lt;p&gt;Open &lt;strong&gt;Lambda&lt;/strong&gt;, then &lt;code&gt;products-read&lt;/code&gt;, then the &lt;strong&gt;Code&lt;/strong&gt; tab. Replace the contents of &lt;code&gt;index.mjs&lt;/code&gt; with &lt;code&gt;lambda/index.mjs&lt;/code&gt; from the download and choose &lt;strong&gt;Deploy&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The function now looks at the request's method:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GET&lt;/code&gt; returns every product, as before.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST&lt;/code&gt; takes a product as JSON, gives it a &lt;code&gt;sku&lt;/code&gt; if the page did not, stamps &lt;code&gt;updated&lt;/code&gt; with today's date, and writes it with a condition that refuses to overwrite an existing &lt;code&gt;sku&lt;/code&gt;. It answers with the product as stored.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DELETE&lt;/code&gt; takes &lt;code&gt;?sku=&lt;/code&gt; and removes that product.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The condition on the write is the one line worth pausing on. Without it, two people adding a product with the same &lt;code&gt;sku&lt;/code&gt; would silently overwrite each other. With it, the second one gets a clear "already exists" answer that the page can show.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: allow the new verbs through the URL
&lt;/h2&gt;

&lt;p&gt;The Function URL's CORS setting from part 1 allows every origin but only the &lt;code&gt;GET&lt;/code&gt; method, so the browser would refuse to send a &lt;code&gt;POST&lt;/code&gt; or a &lt;code&gt;DELETE&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On the function's &lt;strong&gt;Configuration&lt;/strong&gt; tab, choose &lt;strong&gt;Function URL&lt;/strong&gt;, then &lt;strong&gt;Edit&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Configure cross-origin resource sharing (CORS)&lt;/strong&gt;, set &lt;strong&gt;Allow methods&lt;/strong&gt; to &lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;POST&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt;, and &lt;strong&gt;Allow headers&lt;/strong&gt; to &lt;code&gt;content-type&lt;/code&gt;. The page sends that header with the JSON body of a POST.&lt;/li&gt;
&lt;li&gt;Save.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 3: give the function the permission it needs, and no more
&lt;/h2&gt;

&lt;p&gt;Part 1 attached &lt;code&gt;AmazonDynamoDBReadOnlyAccess&lt;/code&gt;, which is enough to read and nothing else. The function now writes, so it needs &lt;code&gt;PutItem&lt;/code&gt; and &lt;code&gt;DeleteItem&lt;/code&gt;, and the tidy way to grant them is a policy that names this one table.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On the &lt;strong&gt;Configuration&lt;/strong&gt; tab, choose &lt;strong&gt;Permissions&lt;/strong&gt;, then click the role name under &lt;strong&gt;Execution role&lt;/strong&gt;. IAM opens.&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Permissions policies&lt;/strong&gt;, tick &lt;code&gt;AmazonDynamoDBReadOnlyAccess&lt;/code&gt; and choose &lt;strong&gt;Remove&lt;/strong&gt;. Confirm.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Add permissions&lt;/strong&gt;. This is the same menu part 1 used to attach the read-only policy; this time choose &lt;strong&gt;Create inline policy&lt;/strong&gt; instead of &lt;strong&gt;Attach policies&lt;/strong&gt;. Switch the editor to &lt;strong&gt;JSON&lt;/strong&gt; and paste &lt;code&gt;iam/products-policy.json&lt;/code&gt; from the download. Replace &lt;code&gt;REGION&lt;/code&gt; with your region (&lt;code&gt;eu-west-2&lt;/code&gt; for London) and &lt;code&gt;ACCOUNT_ID&lt;/code&gt; with the twelve-digit account id shown at the top right of the console.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Next&lt;/strong&gt;, name it &lt;code&gt;products-table&lt;/code&gt;, and &lt;strong&gt;Create policy&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&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%2F0pomhplgm8uprzbtury1.webp" 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%2F0pomhplgm8uprzbtury1.webp" alt="The IAM role's Permissions policies tab, the Add permissions menu open over Attach policies and Create inline policy" width="800" height="308"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The policy allows scan, get, put, update and delete on the &lt;code&gt;Products&lt;/code&gt; table and nothing on any other table. Update is not used yet; part 3 needs it, and it saves a return trip to IAM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: the page
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;web/index.html&lt;/code&gt; from the download and paste your Function URL into the same line as in part 1. Open the file in a browser.&lt;/p&gt;

&lt;p&gt;Three things are new.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add product&lt;/strong&gt; opens the grid's built-in form for a new row. Fill in a name, category, quantity and price, and choose Save. The form validates the fields the way the grid's own editors do, then calls the &lt;code&gt;create&lt;/code&gt; hook you gave it:&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;rowForm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;fields&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;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;label&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="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;category&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Category&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;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;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;In stock&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;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;label&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="p"&gt;],&lt;/span&gt;
  &lt;span class="nx"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;api&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="dl"&gt;''&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;/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/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgfbb6blqnh05cqz6i9vk.webp" 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%2Fgfbb6blqnh05cqz6i9vk.webp" alt="The grid's built-in New row form open, with Product, Category, In stock and Price filled in" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;create&lt;/code&gt; posts the values to the function and returns what comes back: the product as DynamoDB stored it, with the &lt;code&gt;sku&lt;/code&gt; the server assigned. The grid adds that row, scrolls to it and selects it. If the function refuses, the form stays open with your values and a retry, and the status line shows the server's reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Right-click a row&lt;/strong&gt; and the menu has the grid's own items, then a Delete for that product:&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;contextMenu&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;defaults&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="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;separator&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;{&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;Delete &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;action&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="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="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;confirm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Delete &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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="k"&gt;return&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;api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DELETE&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;?sku=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&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;grid&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="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&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="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;&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%2Fgyhn4hjy510e79mz8sr4.webp" 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%2Fgyhn4hjy510e79mz8sr4.webp" alt="The row's right-click menu, ending in a Delete item named after the product on that row" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;p&lt;/code&gt; describes the cell that was clicked: &lt;code&gt;p.key&lt;/code&gt; is the row's key, the &lt;code&gt;sku&lt;/code&gt;, and &lt;code&gt;p.data&lt;/code&gt; is your product object. The action deletes on the server first and only then removes the row from the grid, so a failed delete leaves the row where it was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;api&lt;/code&gt; helper&lt;/strong&gt; is eight lines that wrap &lt;code&gt;fetch&lt;/code&gt;, set the JSON header when there is a body, and turn a non-2xx answer into an error carrying the function's message. Every call in the page goes through it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: check the table
&lt;/h2&gt;

&lt;p&gt;Add a product, then open &lt;strong&gt;DynamoDB&lt;/strong&gt;, &lt;strong&gt;Tables&lt;/strong&gt;, &lt;code&gt;Products&lt;/code&gt;, &lt;strong&gt;Explore table items&lt;/strong&gt;. The new product is there with the &lt;code&gt;sku&lt;/code&gt; the function chose. Delete it from the page and refresh the console; it is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you have
&lt;/h2&gt;

&lt;p&gt;A page that reads, adds and deletes against your own table, with every write happening on the server before the page reflects it. The grid supplied the form and the menu; you supplied two verbs and a policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.latticegrid.dev/tutorials/crud-dynamodb-part-3/" rel="noopener noreferrer"&gt;Part 3: edit in place&lt;/a&gt;.&lt;/strong&gt; Cells become editable and each committed change is patched to DynamoDB while the grid shows it, with the old value put back if the server says no. The page also goes full screen on a button, and the form moves from a drawer into a panel of your own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaning up
&lt;/h2&gt;

&lt;p&gt;As in part 1: delete the function, the table and the role. The inline policy goes with the role.&lt;/p&gt;

&lt;p&gt;Read the full source, including &lt;code&gt;lambda/index.mjs&lt;/code&gt; and &lt;code&gt;web/index.html&lt;/code&gt;, on the &lt;a href="https://github.com/toclocoinc/lattice-tutorial-crud-dynamodb" rel="noopener noreferrer"&gt;public repository&lt;/a&gt;, or grab it as a &lt;a href="https://github.com/toclocoinc/lattice-tutorial-crud-dynamodb/archive/refs/heads/main.zip" rel="noopener noreferrer"&gt;ZIP download&lt;/a&gt; and skip the cloning.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Lattice Grid is a JavaScript data grid with charts, dashboards and server-side pushdown, free to develop with on localhost. The row form and the context menu used here are part of the core grid: &lt;a href="https://www.latticegrid.dev/" rel="noopener noreferrer"&gt;latticegrid.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>aws</category>
      <category>dynamodb</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Build a CRUD app with a JavaScript grid, Node and DynamoDB. Part 1: read the data</title>
      <dc:creator>Tony Goodchild</dc:creator>
      <pubDate>Tue, 29 Sep 2026 17:13:01 +0000</pubDate>
      <link>https://dev.to/tonygoodchild/build-a-crud-app-with-a-javascript-grid-node-and-dynamodb-part-1-read-the-data-360b</link>
      <guid>https://dev.to/tonygoodchild/build-a-crud-app-with-a-javascript-grid-node-and-dynamodb-part-1-read-the-data-360b</guid>
      <description>&lt;p&gt;&lt;em&gt;This is the dev.to edition of a tutorial that lives, with a live copy of the finished grid you can try, at &lt;a href="https://www.latticegrid.dev/tutorials/crud-dynamodb-part-1/" rel="noopener noreferrer"&gt;latticegrid.dev/tutorials/crud-dynamodb-part-1&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is the first of a short series that builds a working application from&lt;br&gt;
nothing: a DynamoDB table, a Node Lambda function in front of it, and a web&lt;br&gt;
page that shows the data in a grid. Part 1 ends with a page that reads&lt;br&gt;
products from your own AWS account. Later parts add creating, editing and&lt;br&gt;
deleting rows.&lt;/p&gt;

&lt;p&gt;It is deliberately basic. Everything is done in the AWS Console, there is no&lt;br&gt;
framework, no build step and no infrastructure tooling to install. If you&lt;br&gt;
have used AWS before you will find some steps slow; they are written for&lt;br&gt;
someone who has not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source:&lt;/strong&gt; every file this guide asks you to paste or run is in the&lt;br&gt;
&lt;code&gt;part-1&lt;/code&gt; folder of&lt;br&gt;
&lt;a href="https://github.com/toclocoinc/lattice-tutorial-crud-dynamodb" rel="noopener noreferrer"&gt;toclocoinc/lattice-tutorial-crud-dynamodb&lt;/a&gt;&lt;br&gt;
(Code, Download ZIP).&lt;/p&gt;
&lt;h2&gt;
  
  
  What you need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An AWS account you can sign in to. Everything here fits in the free tier.&lt;/li&gt;
&lt;li&gt;Node.js 22 or later on your machine, for the one script that uploads the demo data.&lt;/li&gt;
&lt;li&gt;The AWS CLI configured with credentials for that account (&lt;code&gt;aws configure&lt;/code&gt;, or
&lt;code&gt;aws sso login&lt;/code&gt;). Only the upload script uses it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick one region and stay in it for every step. The examples use London,&lt;br&gt;
&lt;code&gt;eu-west-2&lt;/code&gt;; any region works.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1: create the table
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;In the AWS Console, open &lt;strong&gt;DynamoDB&lt;/strong&gt;, then &lt;strong&gt;Tables&lt;/strong&gt;, then &lt;strong&gt;Create table&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Table name: &lt;code&gt;Products&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Partition key: &lt;code&gt;sku&lt;/code&gt;, type &lt;strong&gt;String&lt;/strong&gt;. Leave the sort key empty.&lt;/li&gt;
&lt;li&gt;Table settings: leave &lt;strong&gt;Default settings&lt;/strong&gt; selected. The summary
underneath lists what that means; the line that matters is
&lt;strong&gt;Capacity mode: On-demand&lt;/strong&gt;, so you pay per request and nothing while
idle.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Create table&lt;/strong&gt;. It is ready in under a minute.
&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%2Fhphvqw4slwo2oloj0zqb.webp" alt="The DynamoDB Create table form, with the table name, partition key and settings fields" width="799" height="474"&gt;
&lt;/li&gt;
&lt;/ol&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%2F50auiyew0i7ojjfstckb.webp" 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%2F50auiyew0i7ojjfstckb.webp" alt="The default table settings summary, with Capacity mode set to On-demand" width="799" height="474"&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/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3pvpuenotz7q4cdgasbn.webp" 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%2F3pvpuenotz7q4cdgasbn.webp" alt="A console toast reading Creating the Products table. It will be available for use shortly." width="800" height="59"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nothing else about the table needs deciding now. DynamoDB has no schema beyond&lt;br&gt;
the key: every other field on a product is just whatever the item carries.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 2: upload the demo data
&lt;/h2&gt;

&lt;p&gt;The download has sixty products in &lt;code&gt;seed/products.json&lt;/code&gt;, each with a &lt;code&gt;sku&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;name&lt;/code&gt;, &lt;code&gt;category&lt;/code&gt;, &lt;code&gt;quantity&lt;/code&gt;, &lt;code&gt;price&lt;/code&gt; and an &lt;code&gt;updated&lt;/code&gt; date. The script&lt;br&gt;
beside it writes them to the table in batches of 25, which is the most a single&lt;br&gt;
DynamoDB write call accepts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cd part-1/seed
npm install
AWS_REGION=eu-west-2 node seed.mjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see three progress lines and then &lt;code&gt;done: 60 products in table&lt;br&gt;
"Products"&lt;/code&gt;. If you named the table something else, set &lt;code&gt;TABLE_NAME&lt;/code&gt; too.&lt;/p&gt;

&lt;p&gt;Check it in the console: open the table, choose &lt;strong&gt;Explore table items&lt;/strong&gt;, and&lt;br&gt;
the products are there. Notice the order is not the order in the file. DynamoDB&lt;br&gt;
returns items in the order it stores them, which is by a hash of the key. The&lt;br&gt;
grid will sort them for you in a moment.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3: create the Lambda function
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;strong&gt;Lambda&lt;/strong&gt;, then &lt;strong&gt;Create function&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Leave &lt;strong&gt;Author from scratch&lt;/strong&gt; selected. Function name: &lt;code&gt;products-read&lt;/code&gt;.&lt;br&gt;
Runtime: leave the default, &lt;strong&gt;Node.js 24.x&lt;/strong&gt;. (Node.js 22.x also works;&lt;br&gt;
both ship the AWS SDK the code imports.)&lt;br&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%2Fnenhp05i5bv63r6ppm96.webp" 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%2Fnenhp05i5bv63r6ppm96.webp" alt="The Lambda Create function form, Author from scratch selected, runtime Node.js 24.x" width="799" height="474"&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Open &lt;strong&gt;Additional settings&lt;/strong&gt; at the bottom of the form. Under&lt;br&gt;
&lt;strong&gt;Networking&lt;/strong&gt;, switch on &lt;strong&gt;Function URL&lt;/strong&gt;. A panel opens:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auth type&lt;/strong&gt;: choose &lt;strong&gt;NONE&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Tick &lt;strong&gt;Configure cross-origin resource sharing (CORS)&lt;/strong&gt;. The default
allows every origin, which is what a page opened from a file needs.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Save&lt;/strong&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%2Fgpbcaz0ztr7twpd7rztl.webp" alt="The Additional settings panel with Function URL and the other networking and security toggles" width="799" height="474"&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&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%2F5vd9n7jhffhljcoqv3rq.webp" 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%2F5vd9n7jhffhljcoqv3rq.webp" alt="The Configure function URL dialog, Auth type and Invoke mode options, and the CORS checkbox" width="799" height="474"&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/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx90xkhr4ob9dubg6abc6.webp" 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%2Fx90xkhr4ob9dubg6abc6.webp" alt="The saved Function URL settings summary: Auth type NONE, Invoke mode BUFFERED, CORS Yes" width="799" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Choose &lt;strong&gt;Create function&lt;/strong&gt;. A "Getting started" dialog appears over the new
function; choose &lt;strong&gt;Dismiss&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The function opens on its &lt;strong&gt;Code&lt;/strong&gt; tab with a small editor. In the explorer on&lt;br&gt;
the left, &lt;code&gt;index.mjs&lt;/code&gt; is already selected.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Replace the editor's contents with &lt;code&gt;lambda/index.mjs&lt;/code&gt; from the download,
then choose &lt;strong&gt;Deploy&lt;/strong&gt; in the panel below the editor. The panel's status
changes from "Changes not deployed" to "Current".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The function scans the whole table, one page at a time, and returns the items&lt;br&gt;
as JSON. Sixty products come back in one page; the loop is there so the same&lt;br&gt;
code still works when a table has grown past a page.&lt;/p&gt;

&lt;p&gt;Now give it permission to read the table, and tell it the table's name.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Open the &lt;strong&gt;Configuration&lt;/strong&gt; tab, then &lt;strong&gt;Permissions&lt;/strong&gt; in the list on the&lt;br&gt;
left. Under &lt;strong&gt;Execution role&lt;/strong&gt;, click the role name (it looks like&lt;br&gt;
&lt;code&gt;products-read-role-&lt;/code&gt; followed by a few letters). That opens the role in&lt;br&gt;
IAM in a new tab. Under &lt;strong&gt;Permissions policies&lt;/strong&gt;, choose &lt;strong&gt;Add&lt;br&gt;
permissions&lt;/strong&gt;, then &lt;strong&gt;Attach policies&lt;/strong&gt;. Search for&lt;br&gt;
&lt;code&gt;AmazonDynamoDBReadOnlyAccess&lt;/code&gt;, tick it, and choose &lt;strong&gt;Add permissions&lt;/strong&gt;.&lt;br&gt;
Read-only is enough for part 1; part 2 replaces it with a policy scoped to&lt;br&gt;
this one table. Close the IAM tab.&lt;br&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%2F0pomhplgm8uprzbtury1.webp" 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%2F0pomhplgm8uprzbtury1.webp" alt="The IAM role's Permissions policies tab, the Add permissions menu open over Attach policies" width="800" height="308"&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Back on the function's &lt;strong&gt;Configuration&lt;/strong&gt; tab, choose &lt;strong&gt;Environment&lt;br&gt;
variables&lt;/strong&gt;, then &lt;strong&gt;Edit&lt;/strong&gt;, then &lt;strong&gt;Add environment variable&lt;/strong&gt;. Key&lt;br&gt;
&lt;code&gt;TABLE_NAME&lt;/code&gt;, value &lt;code&gt;Products&lt;/code&gt;. Save. (Skip this if you kept the default&lt;br&gt;
table name; the code falls back to it.)&lt;br&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%2Fvwi1nqefyu89h8jdt3mv.webp" 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%2Fvwi1nqefyu89h8jdt3mv.webp" alt="The function's Environment variables panel with one entry, TABLE_NAME set to Products" width="799" height="211"&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Still under &lt;strong&gt;Configuration&lt;/strong&gt;, choose &lt;strong&gt;Function URL&lt;/strong&gt; and copy the URL. It&lt;br&gt;
looks like &lt;code&gt;https://abc123.lambda-url.eu-west-2.on.aws/&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Paste it into a browser tab. You should see JSON beginning &lt;code&gt;{"items":[{&lt;/code&gt;.&lt;br&gt;
If you see a permissions error instead, wait a minute; the policy you attached&lt;br&gt;
can take that long to apply.&lt;/p&gt;

&lt;p&gt;Two things to be clear about. Auth type NONE means anyone with the URL can read&lt;br&gt;
your products, which is fine for demo data and not fine for anything else;&lt;br&gt;
part 3 puts authentication in front of it. And allowing every origin is a&lt;br&gt;
shortcut for the tutorial; on a real site you would put your own domain there.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 4: the web page
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;web/index.html&lt;/code&gt; from the download in an editor. Near the top of the&lt;br&gt;
script is one line to change:&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;FUNCTION_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.lambda-url.eu-west-2.on.aws/&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;Replace it with your Function URL, save, and open the file in a browser. You&lt;br&gt;
do not need a web server for this page; a file URL is enough.&lt;/p&gt;

&lt;p&gt;The status line at the top reads "60 products from DynamoDB" and the grid&lt;br&gt;
below it shows them. Click &lt;strong&gt;Price&lt;/strong&gt; to sort by price. Click it again to&lt;br&gt;
reverse. Type in the filter row under &lt;strong&gt;Category&lt;/strong&gt; to narrow to one category.&lt;br&gt;
Drag a column header to move it.&lt;br&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%2Fda805rsfnybpuwq7b94p.webp" 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%2Fda805rsfnybpuwq7b94p.webp" alt="The finished web page: a status line reading 60 products from DynamoDB above the sortable, filterable grid" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is the whole page. Here is what the thirty lines of script do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The grid is created first, empty, with six columns and the filter row
switched on. Each column names the field it reads from a product. &lt;code&gt;quantity&lt;/code&gt;
and &lt;code&gt;price&lt;/code&gt; are numbers, so they align right and sort numerically. &lt;code&gt;price&lt;/code&gt;
is formatted as pounds. &lt;code&gt;updated&lt;/code&gt; is a date, so it sorts as one rather than
as text, and shows as "Jul 7, 2026" rather than the raw &lt;code&gt;2026-07-07&lt;/code&gt; the
table holds.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rowKey: 'sku'&lt;/code&gt; tells the grid which field identifies a row. The grid uses
it to keep selection and edits attached to the right product when rows move,
and part 2 uses it to know which item to update.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fetch&lt;/code&gt; calls the Lambda, and &lt;code&gt;grid.rows.load(data.items)&lt;/code&gt; puts the products
in. That is the only line that touches data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The grid comes from a CDN, one script and one stylesheet, and shows a small&lt;br&gt;
trial watermark because there is no licence key. Everything works with the&lt;br&gt;
watermark on; a key removes it and does nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you have
&lt;/h2&gt;

&lt;p&gt;A DynamoDB table with data in it, a Lambda function that reads it, and a page&lt;br&gt;
that shows it with sorting and filtering done in the browser. Three pieces,&lt;br&gt;
each one you could replace: a different table, a different function, a&lt;br&gt;
different front end.&lt;/p&gt;

&lt;p&gt;The one design decision worth noticing is that the Lambda returns everything&lt;br&gt;
and the grid does the rest. With sixty products that is right. With sixty&lt;br&gt;
thousand it is not, and a later part in this series moves sorting, filtering&lt;br&gt;
and paging into the function so the browser only asks for the rows it is&lt;br&gt;
showing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Part 2: create, edit and delete.&lt;/strong&gt; Parts 2 and 3 are already published on the site: &lt;a href="https://www.latticegrid.dev/tutorials/crud-dynamodb-part-2/" rel="noopener noreferrer"&gt;Part 2&lt;/a&gt; adds the row form and a delete menu, &lt;a href="https://www.latticegrid.dev/tutorials/crud-dynamodb-part-3/" rel="noopener noreferrer"&gt;Part 3&lt;/a&gt; adds inline editing with write-back, full screen and the form as a side panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaning up
&lt;/h2&gt;

&lt;p&gt;If you are not continuing, delete the Lambda function (Lambda, Functions,&lt;br&gt;
select &lt;code&gt;products-read&lt;/code&gt;, Actions, Delete) and the table (DynamoDB, Tables,&lt;br&gt;
select &lt;code&gt;Products&lt;/code&gt;, Delete). Deleting the function does not delete the role it&lt;br&gt;
created; find it in IAM under Roles as &lt;code&gt;products-read-role-…&lt;/code&gt; and delete that&lt;br&gt;
too. None of it costs anything at this size while idle, but a deleted resource&lt;br&gt;
cannot surprise you later.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>aws</category>
      <category>dynamodb</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Put Splunk Search Results in Your Own App's Data Grid</title>
      <dc:creator>Tony Goodchild</dc:creator>
      <pubDate>Mon, 28 Sep 2026 19:53:23 +0000</pubDate>
      <link>https://dev.to/tonygoodchild/put-splunk-search-results-in-your-own-apps-data-grid-5fn7</link>
      <guid>https://dev.to/tonygoodchild/put-splunk-search-results-in-your-own-apps-data-grid-5fn7</guid>
      <description>&lt;p&gt;Products that run on infrastructure have a strong chance of having their events in Splunk. The people who need to see those events are often in your application, not in Splunk, and often without a Splunk seat. The usual answers are an iframe around a Splunk dashboard, or a hand-built table over the REST API that re-implements filtering and paging badly.&lt;/p&gt;

&lt;p&gt;Lattice Grid has a Splunk adapter that does the translation for you. This post shows what it does, what it deliberately does not do, and the three ways to handle the credential depending on who the page is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;Splunk's on-disk index format is closed. There is no file to read and no bucket to open. The supported surface is the search REST API, so that is what the adapter speaks: it dispatches a search job, watches it, reads a window of results, and cancels the job the moment the grid asks for something else.&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;createGrid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;createPushdownSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;splunkAdapter&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;@toclocoinc/lattice-grid&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="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;compute&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;@toclocoinc/lattice-grid&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="nf"&gt;createGrid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;columns&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;event_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;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;createPushdownSource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;adapter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;splunkAdapter&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;https://splunk.example.com:8089&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// the management endpoint, not Splunk Web&lt;/span&gt;
      &lt;span class="na"&gt;search&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;index=security sourcetype=firewall&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;earliest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-24h&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bearer &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nx"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&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;Give it a base search and the grid is live over it. Typing into the filter row, clicking a column header to sort, or scrolling to the next page each becomes a Splunk search job. The browser only ever receives the rows in view.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a filter turns into
&lt;/h2&gt;

&lt;p&gt;SPL has two ways to say "keep the events where this holds". &lt;code&gt;| where&lt;/code&gt; is an eval expression: exact, case-sensitive, applied after the events have been read. The &lt;code&gt;search&lt;/code&gt; command's own terms are matched against the index, case-insensitively, with wildcards. The adapter emits the &lt;code&gt;search&lt;/code&gt; form, for two reasons. It is the faster one, because Splunk seeks rather than reads and discards. And it is the one whose result matches what the same filter means everywhere else in the grid, whose filter kernel compares strings case-insensitively unless a condition asks otherwise.&lt;/p&gt;

&lt;p&gt;That choice has a cost, and the adapter names it rather than hiding it. A condition that asks for &lt;code&gt;caseSensitive&lt;/code&gt; cannot be honoured by a case-insensitive matcher, so it is pushed case-insensitively and the grid says so once in the console. Returning quietly wider rows is the failure this adapter exists not to have.&lt;/p&gt;

&lt;p&gt;Values never leave their quotes. Strings are wrapped and escaped, including the asterisk, because an unescaped &lt;code&gt;*&lt;/code&gt; inside a quoted SPL value is a wildcard. A user who types &lt;code&gt;foo*bar&lt;/code&gt; into a filter box gets events containing &lt;code&gt;foo*bar&lt;/code&gt;, not everything from &lt;code&gt;foo&lt;/code&gt; to &lt;code&gt;bar&lt;/code&gt;. The wildcards that &lt;code&gt;contains&lt;/code&gt;, &lt;code&gt;startsWith&lt;/code&gt; and &lt;code&gt;endsWith&lt;/code&gt; need are added outside the escaped text, so the adapter's wildcards are wildcards and the user's are literal. Field names are validated against a pattern and refused by name otherwise, since the identifier is the one part of an SPL expression that cannot be quoted away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Time filters become the job's window
&lt;/h2&gt;

&lt;p&gt;Splunk's &lt;code&gt;earliest_time&lt;/code&gt; is inclusive and &lt;code&gt;latest_time&lt;/code&gt; is exclusive. So a &lt;code&gt;&amp;gt;=&lt;/code&gt; on the time column maps onto &lt;code&gt;earliest_time&lt;/code&gt; exactly and a &lt;code&gt;&amp;lt;&lt;/code&gt; maps onto &lt;code&gt;latest_time&lt;/code&gt; exactly. The adapter lifts those two out of the filter expression and makes them the job's own window, which is the difference between Splunk seeking to a time range and Splunk reading everything and discarding most of it.&lt;/p&gt;

&lt;p&gt;The bounds that do not map exactly, &lt;code&gt;&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;=&lt;/code&gt;, and any time condition inside an &lt;code&gt;or&lt;/code&gt;, stay in the expression as a numeric comparison on the time field. That is exact, just not as fast. Correct first, fast where correctness allows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seeing what was pushed
&lt;/h2&gt;

&lt;p&gt;After each query the source reports what ran in Splunk and what, if anything, stayed in the browser:&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;grid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rows:changed&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="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;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lastPlan&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;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unpushed&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;pushed part&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;pushed all&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;plan&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;For most filter shapes the answer is "pushed all". When it is not, the plan tells you which condition was finished client-side and why, so there is never a silent gap between the filter the user set and the rows they see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the credential lives
&lt;/h2&gt;

&lt;p&gt;The adapter holds no credential of its own. Every request is made with the &lt;code&gt;fetch&lt;/code&gt; and &lt;code&gt;headers&lt;/code&gt; you supply. It stores nothing, prompts for nothing and refreshes nothing, because an adapter that cached a token would be a place for it to leak from. Three shapes cover the three kinds of page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production: a proxy in front of Splunk.&lt;/strong&gt; Your application authenticates the visitor its own way. A small backend holds the Splunk service token, forwards only the search endpoints, restricts &lt;code&gt;index=&lt;/code&gt; to what that visitor may see, rate-limits, and adds the cross-origin headers for your origin. The grid points at the proxy and never sees a token:&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;adapter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;splunkAdapter&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;https://app.example.com/splunk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// your backend, not Splunk&lt;/span&gt;
  &lt;span class="na"&gt;search&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;index=security sourcetype=firewall&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;earliest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-24h&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;The proxy is about forty lines of Node, or a Lambda behind CloudFront. It forwards &lt;code&gt;POST /services/search/v2/jobs&lt;/code&gt;, the job's own &lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;/results&lt;/code&gt;, &lt;code&gt;/export&lt;/code&gt; and the &lt;code&gt;DELETE&lt;/code&gt; that cancels a job, and refuses everything else. The reference implementation is in the guide linked at the end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Internal tools: a per-user session key.&lt;/strong&gt; Behind your own login, each user signs into Splunk with their own account, and the page holds their short-lived session key in memory:&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;key&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="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/services/auth/login&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;username=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;user&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;amp;password=&lt;/span&gt;&lt;span class="dl"&gt;'&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;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;output_mode=json&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;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="nf"&gt;json&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;j&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;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessionKey&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;adapter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;splunkAdapter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;url&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;index=security sourcetype=firewall&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Splunk &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What leaks if the page is compromised is one visitor's own access, and it expires on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trying it out: your token in the page.&lt;/strong&gt; Fine for a developer pointed at a development instance, which is what the first example on this page is. Never ship a shared token to visitors. A token grants everything its role can do, across every index it can see, to anyone who reads the page.&lt;/p&gt;

&lt;p&gt;A token that expires goes in a &lt;code&gt;fetch&lt;/code&gt; wrapper rather than in &lt;code&gt;headers&lt;/code&gt;, since the wrapper is the only thing that can mint a fresh value per request.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you only have an export
&lt;/h2&gt;

&lt;p&gt;Without REST access to the instance, the grid still works from exported results, and exporting is something Splunk does well. From the UI, the CLI, or the REST results endpoint with &lt;code&gt;output_mode=csv&lt;/code&gt; or &lt;code&gt;json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;splunk search &lt;span class="s2"&gt;"search index=security sourcetype=firewall earliest=-24h"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-output&lt;/span&gt; csv &lt;span class="nt"&gt;-maxout&lt;/span&gt; 0 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; firewall-24h.csv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A CSV goes through &lt;code&gt;grid.import&lt;/code&gt;, which infers each column's type and previews the mapping before anything lands. A JSON or NDJSON export goes through &lt;code&gt;createUrlSource&lt;/code&gt;, which streams NDJSON line by line and renders the first rows before the rest has arrived. The REST route is the one worth automating: a scheduled job that lands a fresh export, with nobody exporting anything by hand.&lt;/p&gt;

&lt;p&gt;There is a third route for teams already using Splunk's Ingest Actions to land events in S3 as NDJSON or JSON. What arrives there is ordinary files in a bucket you own, and the grid's DuckDB adapter reads them directly, with filters pushed into DuckDB the same way they are pushed into Splunk here. That works because Ingest Actions writes an open format to a destination you control. Pointing the grid at Splunk's own storage is not something it does.&lt;/p&gt;

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

&lt;p&gt;There is no public Splunk instance or credential a website can publish, so the live demo runs the adapter against a mock of the search REST API. Everything above the mock is the real code path: &lt;a href="https://www.latticegrid.dev/demos/pushdown-splunk/" rel="noopener noreferrer"&gt;https://www.latticegrid.dev/demos/pushdown-splunk/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The full guide, including the reference proxy: &lt;a href="https://www.latticegrid.dev/docs/sources/splunk/" rel="noopener noreferrer"&gt;https://www.latticegrid.dev/docs/sources/splunk/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>splunk</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>Building a live network operations wall in one HTML file</title>
      <dc:creator>Tony Goodchild</dc:creator>
      <pubDate>Fri, 25 Sep 2026 14:57:19 +0000</pubDate>
      <link>https://dev.to/tonygoodchild/building-a-live-network-operations-wall-in-one-html-file-227h</link>
      <guid>https://dev.to/tonygoodchild/building-a-live-network-operations-wall-in-one-html-file-227h</guid>
      <description>&lt;p&gt;A NOC wall is the hardest kind of dashboard to build well. Nineteen panels, one stream of events, every panel wanting a different cut of the same data, and all of it updating every second without a repaint storm. Most teams end up with a data layer per panel, a timer per chart and a pile of glue code nobody wants to touch.&lt;/p&gt;

&lt;p&gt;This demo does it with one page, one data feed and no glue. It is live at &lt;a href="https://toclocoinc.github.io/lattice-grid-demo-noc-umd/" rel="noopener noreferrer"&gt;https://toclocoinc.github.io/lattice-grid-demo-noc-umd/&lt;/a&gt; and the source is public at &lt;a href="https://github.com/toclocoinc/lattice-grid-demo-noc-umd" rel="noopener noreferrer"&gt;https://github.com/toclocoinc/lattice-grid-demo-noc-umd&lt;/a&gt;. Everything below is in that repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wall
&lt;/h2&gt;

&lt;p&gt;The page is a 20 by 20 layout of movable, resizable windows. A branding tile and six KPI tiles run along the top, with two clocks for London and New York. Underneath sit a world map of sites, a core network topology, the active alarms grid, service health, network traffic, link utilisation, incident trend, a site status donut, the top affected sites and a scrolling alarm ticker.&lt;/p&gt;

&lt;p&gt;Every one of those windows is fed by the same stream. That is the point of the demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  The layout component
&lt;/h2&gt;

&lt;p&gt;The layout module gives you a grid of windows and nothing else to think about. Each window is declared with an id, a title, a position and a size:&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="p"&gt;{&lt;/span&gt; &lt;span class="nl"&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;map&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Global network overview&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;xPos&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="nx"&gt;yPos&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="nx"&gt;xSize&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="nx"&gt;ySize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The module renders the chrome, handles drag, resize, maximise and close, and hands you a body element per window (&lt;code&gt;&amp;lt;id&amp;gt;-body&lt;/code&gt;). You mount whatever you like into it: a grid, a chart, a KPI tile, a plain div. Windows that should not move, such as the branding tile, are simply declared &lt;code&gt;movable: false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There is no framework in the page. It is plain HTML, a handful of script tags from the CDN, and one script block.&lt;/p&gt;

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

&lt;p&gt;The data router is where the wall stops being nineteen problems and becomes one. Every event in the feed carries a &lt;code&gt;kind&lt;/code&gt;: site, alarm, service, sample, element, chip or metric. The router is created once, keyed on that field:&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="nf"&gt;createDataRouter&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;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;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="nx"&gt;overlap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;coalesce&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then each viewer attaches to the route it wants, before the stream starts:&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alarmGrid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alarm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;siteGrid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;site&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;serviceGrid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;service&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trafficGrid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sample&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;That is all the wiring. A row with an existing id is an upsert. Bursts are coalesced. A grid attached to the alarm route gets only alarms, the site grid gets only sites, and neither knows the other exists. When a BGP session flaps for the fourth time the alarm row escalates in place, the KPI counting active incidents ticks up, the ticker prints the update and the incident trend adds a bar. Nobody wrote code to keep those in step.&lt;/p&gt;

&lt;p&gt;The best part is derived sources. A chart that wants "the five busiest links" does not need a second feed. It sits on a derived grid:&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;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&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;derived&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;linkGrid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sort&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;load&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;desc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt; &lt;span class="nx"&gt;limit&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The site status donut is a derived grid grouped by status with a count. The top affected sites panel is a derived grid over the alarm grid, grouped by site. Each is a viewer of a viewer, and they stay live for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  The charts
&lt;/h2&gt;

&lt;p&gt;Six chart types share the wall, and each one is a viewer of a grid, so the charts never hold data of their own.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;World map&lt;/strong&gt; of every site, drawn with the world geometry pack, marker colour by availability, label by city.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network topology&lt;/strong&gt; with pinned nodes for the internet edge, two cloud regions, four core routers and four regions. Nodes are glyphs from the icon registry, links are coloured by utilisation rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Link utilisation&lt;/strong&gt; as horizontal bars, coloured by the same rules and labelled with the value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network traffic&lt;/strong&gt; as a line chart of the sample route, one series per direction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incident trend&lt;/strong&gt; as a stacked bar per minute, one series per raised severity, on a rolling time axis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Site status&lt;/strong&gt; donut with a named colour scheme, a legend with counts and the total in the centre.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The service health grid uses the grid itself as a chart surface: an icon per service, availability coloured as text by SLA rule, and a sparkline cell rendered from the last few readings. An alarms module sits on that grid and raises its own alarm when availability drops below the SLA thresholds, which feeds the same ticker as the network alarms.&lt;/p&gt;

&lt;p&gt;Colour rules are declared once as formatting rules and shared. When a link goes amber in the topology it goes amber in the bar chart, because it is the same rule.&lt;/p&gt;

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

&lt;p&gt;The feed is real. The backbone is a real carrier (AS3257), its sites are the carrier's PeeringDB facilities, so the world map shows real cities with real coordinates. Route updates come from RIPE RIS Live, the public BGP stream from RIPE's collectors. Internet outage detections come from IODA at Georgia Tech.&lt;/p&gt;

&lt;p&gt;Live BGP does not make for a reliable public demo, so the page ships with a ten minute recording of the RIS stream and replays it in a loop. The replay is stamped with the wall clock as it plays, so the per-minute incident trend and the alarm ages behave as though the events were arriving now. The page also supports a live mode that opens the RIS websocket and reads PeeringDB directly, and a simulated mode for offline use.&lt;/p&gt;

&lt;p&gt;All of that is in a single data file that produces the events. The router does not care which mode is running.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the demo drove
&lt;/h2&gt;

&lt;p&gt;Building the wall in public found gaps worth closing, and they shipped in the same week. The geometry packs and every chart gained UMD and CommonJS builds so the map works from a script tag (1.71.1). Bars can be coloured by rule and labelled (1.71.1). Chart series can be coloured by name, legends can show counts and a donut can carry a centre value (1.71.3). Each of those is in the demo today.&lt;/p&gt;

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

&lt;p&gt;Open the live page, drag the windows around, and watch the alarms grid and the ticker for a minute. Then read the page source. It is one file, and the wiring is shorter than this post.&lt;/p&gt;

&lt;p&gt;Live demo: &lt;a href="https://toclocoinc.github.io/lattice-grid-demo-noc-umd/" rel="noopener noreferrer"&gt;https://toclocoinc.github.io/lattice-grid-demo-noc-umd/&lt;/a&gt;&lt;br&gt;
Source: &lt;a href="https://github.com/toclocoinc/lattice-grid-demo-noc-umd" rel="noopener noreferrer"&gt;https://github.com/toclocoinc/lattice-grid-demo-noc-umd&lt;/a&gt;&lt;br&gt;
Lattice Grid: &lt;a href="https://www.latticegrid.dev" rel="noopener noreferrer"&gt;https://www.latticegrid.dev&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>dataviz</category>
      <category>showdev</category>
    </item>
    <item>
      <title>We built a data grid for technical data. Here is what a column type has to know.</title>
      <dc:creator>Tony Goodchild</dc:creator>
      <pubDate>Thu, 24 Sep 2026 12:27:32 +0000</pubDate>
      <link>https://dev.to/tonygoodchild/we-built-a-data-grid-for-technical-data-here-is-what-a-column-type-has-to-know-8co</link>
      <guid>https://dev.to/tonygoodchild/we-built-a-data-grid-for-technical-data-here-is-what-a-column-type-has-to-know-8co</guid>
      <description>&lt;p&gt;Most data grids know at least four kinds of value: text, number, date, boolean. That is fine for an order list. It falls apart the moment the data is a link budget, a pressure log, a register dump or a fleet of IP addresses, because every one of those has rules that a plain number does not know. &lt;/p&gt;

&lt;p&gt;A gigabyte column that shows 0.5 is wrong. A temperature column with a footer total is wrong. An average bearing of 359° and 1° that reads 180° is wrong in a way that looks right. You can format the output easily, but then you have to start building custom comparators for sorting, and have to enforce how data is entered.&lt;/p&gt;

&lt;p&gt;I built Lattice Grid for applications (like our others) that show real amounts of technical data, so the type system had to carry that knowledge instead of leaving it to every screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A type is a bundle of behaviour, not a label&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In Lattice Grid, setting type on a column configures everything at once: how a value is formatted, how typed text is parsed back into a value, how two values compare when sorting, how the column is stored in the columnar backing, what lands in Excel, and what goes on the clipboard.&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;columns&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;span&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;metres&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;       &lt;span class="c1"&gt;// 1500     → "1.5 km"&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;inlet&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;pressure&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;     &lt;span class="c1"&gt;// 200000   → "200 kPa"&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;cap&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;capacitance&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;  &lt;span class="c1"&gt;// 4.7e-11  → "47 pF"&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;bearing&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;degrees&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;inletTemp&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;celsius&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;gateway&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;ipv4&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;flags&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;hex32&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;We have 7 core types (text, number, boolean, date, dateString, object, lookup) and 88 technical ones ship, grouped by domain:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Temporal&lt;/strong&gt;: datetime, timestamp, time, duration&lt;br&gt;
&lt;strong&gt;Network&lt;/strong&gt;: ipv4, ipv6, cidr, mac&lt;br&gt;
&lt;strong&gt;Numeric bases&lt;/strong&gt;: hex, hex8, hex16, hex32, binary, binary8, octal&lt;br&gt;
&lt;strong&gt;Computing units&lt;/strong&gt;: bytes, megabytes, gigabytes, bitrate, gigabits&lt;br&gt;
&lt;strong&gt;Physical and engineering units&lt;/strong&gt;: length, mass, duration, speed, acceleration, area, volume, energy, power, force, pressure, torque, density, flow and angle&lt;br&gt;
&lt;strong&gt;Electrical and scientific&lt;/strong&gt;: voltage, current, resistance, capacitance, inductance, charge, conductance, flux density, illuminance, absorbed and equivalent dose, radioactivity, molarity, viscosity, thermal conductivity, frequency and more&lt;br&gt;
&lt;strong&gt;Temperature&lt;/strong&gt;: celsius, fahrenheit, kelvin&lt;br&gt;
&lt;strong&gt;Currency&lt;/strong&gt;: currency, usd, eur, gbp, jpy&lt;br&gt;
&lt;strong&gt;Structured&lt;/strong&gt;: json, colour, rating, percent&lt;br&gt;
&lt;strong&gt;Units&lt;/strong&gt;: store one number, make only the display unit-aware&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Units: store one number, make only the display unit-aware&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The design rule for every unit type is the same. The column stores a plain number in a named base unit, and only display and input know about units. A gigabyte column holding 0.5 shows 512 MB, accepts 512M typed over it, and stores 0.5 throughout. Because the backing is still a typed array, sorting, filtering, grouping and totals are ordinary arithmetic and never touch rendered text.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;display: 'auto'&lt;/code&gt; walks the coherent SI ladder and picks the largest unit that fits. significantFigures rounds to a fixed precision rather than a fixed number of decimals, and rounding happens before the unit is chosen, so 999,999 bytes to three figures is 1.00 MB, not 1,000 kB. compound: ['ft', 'in'] renders one stored number as 5 ft 11 in and parses it back the same way.&lt;/p&gt;

&lt;p&gt;Seventeen unit systems ship, and you can add your own:&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;registerUnitSystem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;defineUnit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;createUnitType&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;@toclocoinc/lattice-grid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// factor is how many BASE units one of these is. Exactly one must be 1.&lt;/span&gt;
&lt;span class="nf"&gt;registerUnitSystem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;yarn&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;defineUnit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tex&lt;/span&gt;&lt;span class="dl"&gt;'&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="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="nf"&gt;defineUnit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ktex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e3&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;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;k&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="nf"&gt;defineUnit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;den&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;9&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;denier&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;createGrid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;dataTypes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;linearDensity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;createUnitType&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;yarn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auto&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;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;count&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;linearDensity&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;strong&gt;The rules that protect the data&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three decisions in the unit layer are there because the alternative is silent corruption.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ambiguous units are refused, not guessed&lt;/strong&gt;. A US gallon and an imperial gallon differ by about a fifth, and "ton" means three different masses. Each has its own symbol (gal (US), ton (UK)), and the bare word is rejected on input rather than resolved to a default. The same rule catches case: mV and MV are a billion apart, so both exact spellings work and mv is refused.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Customary units are accepted but never chosen&lt;/strong&gt;. With the calorie, the BTU and the kilojoule all on one ladder, auto would render 4,000 J as 3.79 BTU because the BTU happens to be the larger unit. So auto walks SI only; ask for a BTU by name and you get one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Some quantities are not units at all&lt;/strong&gt;. Temperature carries an offset, so no factor converts it: celsius, fahrenheit and kelvin convert on input (type 72 F into a Celsius column and it stores 22.2) and refuse to be summed, because twenty degrees plus twenty degrees is not forty. Angles wrap, so a degrees column averages by direction and reports nothing when the angles cancel, while the sum stays arithmetic because 720° of total rotation is a real figure. Currency is an amount and a code, never a fixed factor; you pass a rate source to createCurrencyType, and a rate that is needed but absent renders as a loud missingRate marker, never as zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time: a wall clock and an instant are different types&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A date column stores '2024-03-11' as a string, not a Date. new Date('2024-03-11') is midnight UTC, which renders as the 10th in New York, so a delivery date set in London would show a day early in Mumbai. Storing the wall-clock string means the date a user typed is the date everyone sees. It is also faster: ISO 8601 sorts lexicographically in chronological order, and repeated dates dictionary-encode well.&lt;/p&gt;

&lt;p&gt;When you mean a genuine moment, an audit time or a cross-region log line, use timestamp. It ingests epoch-millis, a Date or a zone-bearing ISO string, stores epoch-millis UTC, and sorts, filters and compares on the instant, so rows from different origin zones order by true chronology and changing the display zone never reorders them. The display zone resolves from the column, then the grid, then the viewer, and is nameable in the cell (Europe/London (BST)), with showOrigin: true when the origin differs. Grouping buckets by civil day in the display zone, so a 23- or 25-hour daylight-saving day still collapses to one bucket. Excel export writes the display-zone wall clock and names the zone, never a silent shift to UTC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Types the grid reads from your data&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A column that declares no type takes one from the rows. The first hundred non-empty values are sampled, and a type is adopted only if every one matches; anything mixed stays text and says so once on the console.&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;columns&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;sku&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;        &lt;span class="c1"&gt;// text&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="c1"&gt;// number: aligned right, numeric filter&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;shipped&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;    &lt;span class="c1"&gt;// date&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;expedited&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;  &lt;span class="c1"&gt;// boolean: checkbox editor&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Only the core names are ever inferred, so a column of IP addresses or durations is text until you name the type. Inference never overrules a decision you made, and type: false turns sampling off. Sorting is not what you gain by declaring: the default comparator is already value-aware, so an undeclared IPv4 column already orders correctly across 128.0.0.1. What a declared type settles is everything around the sort: the editor, the parser, the formatter, the filter kind, the storage, and the export.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Building a type to order&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two factories cover most custom cases. createUnitType you have seen. createRadixType builds a register view:&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;dataTypes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;reg32&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LatticeGrid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createRadixType&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;radix&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;bitWidth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pad&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;signed&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;group&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="p"&gt;},&lt;/span&gt;
&lt;span class="nx"&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;flags&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;reg32&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="c1"&gt;// 170 → 0b00000000 00000000 00000000 10101010&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Base 10 is deliberately not among the radices: a decimal number is what number is for, with grouping, decimals and currency that a radix formatter has no concept of. Pass radix: 10 and the console names the supported set and falls back to hex, rather than producing something that looks like a number column and is not one.&lt;/p&gt;

&lt;p&gt;When a factory is not enough, a type is a plain object with a small set of properties for you to set:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Member&lt;/th&gt;
&lt;th&gt;What it decides&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;base&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The storage family: text, number, boolean, date, dateString or object&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;extends&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The type to inherit from; &lt;code&gt;defaults&lt;/code&gt; merge rather than replace, so a derived type overrides one default and keeps the rest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;matches&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Whether a sampled value belongs to this type, for inference&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;format&lt;/code&gt; / &lt;code&gt;parse&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Display text out, typed or pasted text back in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;compare&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sort order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;defaults&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The filter kind, editor, footer total, alignment and cell renderer a column gets for free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;storage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;float64&lt;/code&gt;, &lt;code&gt;int32&lt;/code&gt;, &lt;code&gt;bitset&lt;/code&gt;, &lt;code&gt;dictionary&lt;/code&gt; or &lt;code&gt;object&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;totals&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which aggregates mean something for this type, and the type's own reduction for each&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last member is the one we are really finding useful. A type can declare which aggregates are meaningful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Formats, when you need them&lt;/strong&gt;&lt;br&gt;
On top of a type sits an optional format, with a shorthand for the common cases and a full spec when the shorthand runs out:&lt;/p&gt;

&lt;p&gt;A key in format that the resolved type does not read, a typo or a key from a different shape, is never silently dropped. It warns once, by column and key name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I spent a lot of time talking to the people who were using our SAAS for 8 hours a day, looking at grids on the screen. It was their frustrations and comments that led to Lattice Grid. Making their lives easier so they don't have to type in 10GB when something they are pasting into a cell says 10000MB. Making sure they don't enter 10,000GB by mistake.&lt;/p&gt;

&lt;p&gt;The grid carries the rules, so your users do not have to carry them in their heads, and a wrong number fails loudly at construction instead of quietly in a report later.&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://www.latticegrid.dev/demos/built-in-types/" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.latticegrid.dev%2Fog%2Fdemos%2Fbuilt-in-types.png" height="420" class="m-0" width="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://www.latticegrid.dev/demos/built-in-types/" rel="noopener noreferrer" class="c-link"&gt;
            The built-in types: JavaScript Data Grid Demo | Lattice Grid
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            text, number, dateString, boolean, lookup, object, json, secret and image, one column each. A live JavaScript data grid demo you can drive in the browser.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.latticegrid.dev%2Ffavicon.svg" width="100" height="100"&gt;
          latticegrid.dev
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>datagrid</category>
      <category>frontend</category>
    </item>
  </channel>
</rss>
