<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>SvGrid Blog</title>
    <link>https://svgrid.com/blog/</link>
    <description>Practical, copy-paste tips for building data grids in Svelte 5 with SvGrid: sorting, Excel-style filters, virtualization, inline editing, grouping, server-side data, theming, accessibility, and real-time updates.</description>
    <language>en</language>
    <lastBuildDate>Sat, 29 Aug 2026 17:16:09 GMT</lastBuildDate>
    <atom:link href="https://svgrid.com/feed.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Building a Real-Time Trading Grid in Svelte</title>
      <link>https://svgrid.com/blog/real-time-trading-grid/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/real-time-trading-grid/</guid>
      <pubDate>Sat, 29 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Kamelia M</dc:creator>
      <category>trading</category>
      <category>finance</category>
      <category>realtime</category>
      <category>use case</category>
      <category>svelte data grid</category>
      <description>How to wire a WebSocket tick feed into SvGrid without dropping frames - rAF batching, stable row identity, flash animations, and the exact patterns that scale past 150 ticks per second.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/real-time-trading-grid.png" width="1200" height="630" alt="" /></p><p>A busy trading feed will push 150 price ticks per second across 100+ instruments. A new message every 6-7 ms. The frame budget is 16 ms. If you write a Svelte state update on every single message, you are scheduling ten reactive microtasks per frame and the grid bogs down before the market even opens.</p>
<p><img src="https://svgrid.com/blog-media/realtime-orders.png" alt="Building a Real-Time Trading Grid in Svelte"></p>
<p>The fix is not complicated, but it is non-obvious: never write to <code>$state</code> directly from a WebSocket callback. Collect all incoming ticks into a plain <code>Map</code> and flush the whole batch in a single <code>requestAnimationFrame</code>. That one change drops render overhead by roughly 80% on a busy feed, and it is the foundation everything else builds on.</p>
<h2 id="why-plain-mutation-is-not-enough">Why plain mutation is not enough</h2><p>Svelte 5 reactivity is fine-grained - change <code>rows[i].price</code> and only cells reading <code>price</code> update. But <code>rows[i].price = x</code> does not actually trigger anything, because the object reference on <code>rows[i]</code> did not change. You have to replace the object:</p>
<pre><code class="language-ts">rows[i] = { ...rows[i], price: newPrice }
</code></pre>
<p>This is the correct pattern but it makes the &quot;update on every tick&quot; problem worse, not better, because now you are creating a new object reference for every message. On a 150 tick/second feed that is 150 object allocations and 150 Svelte reactive flushes per second. The rAF batcher solves both problems at once: it coalesces multiple ticks for the same symbol and limits total flushes to the display refresh rate.</p>
<h2 id="the-tick-batcher">The tick batcher</h2><pre><code class="language-ts">// feed.ts
export type Direction = &#39;up&#39; | &#39;down&#39; | null

export type Instrument = {
  id: string
  symbol: string
  sector: string
  price: number
  change: number      // % from open, e.g. 2.34 or -1.07
  volume: number
  direction: Direction
  flashAt: number     // timestamp of last tick, used to expire flash class
}

export function connectFeed(
  rows: Instrument[],
  onFlush: () =&gt; void,
): () =&gt; void {
  // Build a lookup so we never scan the array on every tick
  const index = new Map&lt;string, number&gt;()
  rows.forEach((r, i) =&gt; index.set(r.id, i))

  const pending = new Map&lt;string, { price: number; change: number }&gt;()
  let scheduled = false

  function flush() {
    scheduled = false
    const now = Date.now()
    for (const [id, tick] of pending) {
      const i = index.get(id)
      if (i == null) continue
      const prev = rows[i]!
      rows[i] = {
        ...prev,
        price: tick.price,
        change: tick.change,
        direction: tick.price &gt; prev.price ? &#39;up&#39;
               : tick.price &lt; prev.price ? &#39;down&#39;
               : null,
        flashAt: now,
      }
    }
    pending.clear()
    onFlush()
  }

  // Replace this block with a real WebSocket in production:
  // const ws = new WebSocket(&#39;wss://feed.example.com/ticks&#39;)
  // ws.onmessage = (e) =&gt; { ... }
  let prng = 0xC0FFEE42
  function rand() {
    prng = (prng * 1664525 + 1013904223) &gt;&gt;&gt; 0
    return prng / 0xFFFFFFFF
  }

  const timer = setInterval(() =&gt; {
    const i = Math.floor(rand() * rows.length)
    const row = rows[i]!
    pending.set(row.id, {
      price: Math.max(0.01, row.price + (rand() - 0.49) * 2.5),
      change: row.change + (rand() - 0.5) * 0.4,
    })
    if (!scheduled) {
      scheduled = true
      requestAnimationFrame(flush)
    }
  }, 7) // simulates ~143 messages/second

  return () =&gt; clearInterval(timer)
}
</code></pre>
<p>Two details worth noting. First, the <code>index</code> map avoids an <code>Array.findIndex</code> call on every tick - at 150 ticks/second that would be 150 linear scans per second on top of everything else. Second, if two ticks for the same symbol arrive before the next rAF, the second overwrites the first in <code>pending</code>. The grid sees one update per symbol per frame, which is exactly right. Intermediate prices within a single 16 ms frame are invisible to a human anyway.</p>
<h2 id="wiring-to-svgrid">Wiring to SvGrid</h2><p>Conditional formatting on the <code>change</code> column drives profit/loss color without any snippet overhead. The <code>price</code> column gets a flash class from a <code>cellClass</code> function that reads the <code>direction</code> field.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import { onMount, onDestroy } from &#39;svelte&#39;
  import SvGrid from &#39;@svgrid/grid&#39;
  import {
    tableFeatures,
    rowSortingFeature,
    type ColumnDef,
    type SvGridApi,
  } from &#39;@svgrid/grid&#39;
  import { connectFeed, type Instrument } from &#39;./feed.ts&#39;

  // Build initial rows from seed data
  let prng2 = 0xFEEDBEEF
  function r2() {
    prng2 = (prng2 * 1664525 + 1013904223) &gt;&gt;&gt; 0
    return prng2 / 0xFFFFFFFF
  }

  const SEED = [
    { symbol: &#39;ACME&#39;, sector: &#39;Tech&#39;,    price: 184.12 },
    { symbol: &#39;GLBX&#39;, sector: &#39;Tech&#39;,    price:  62.55 },
    { symbol: &#39;INIT&#39;, sector: &#39;Tech&#39;,    price: 318.04 },
    { symbol: &#39;UMBR&#39;, sector: &#39;Health&#39;,  price:  92.18 },
    { symbol: &#39;HOOL&#39;, sector: &#39;Finance&#39;, price: 145.66 },
    { symbol: &#39;PIED&#39;, sector: &#39;Tech&#39;,    price:  41.20 },
    { symbol: &#39;STRK&#39;, sector: &#39;Energy&#39;,  price: 213.55 },
    { symbol: &#39;WAYN&#39;, sector: &#39;Finance&#39;, price: 287.10 },
    { symbol: &#39;WONK&#39;, sector: &#39;Health&#39;,  price: 188.42 },
    { symbol: &#39;TYRL&#39;, sector: &#39;Energy&#39;,  price: 105.05 },
    { symbol: &#39;CYBR&#39;, sector: &#39;Tech&#39;,    price: 224.18 },
    { symbol: &#39;AURA&#39;, sector: &#39;Health&#39;,  price:  76.55 },
  ]

  let rows = $state&lt;Instrument[]&gt;(
    SEED.map((s) =&gt; ({
      id: s.symbol,
      symbol: s.symbol,
      sector: s.sector,
      price: s.price,
      change: Math.round((r2() - 0.5) * 6 * 100) / 100,
      volume: Math.floor(500_000 + r2() * 9_500_000),
      direction: null,
      flashAt: 0,
    }))
  )

  const features = tableFeatures({ rowSortingFeature })

  const columns: ColumnDef&lt;typeof features, Instrument&gt;[] = [
    {
      id: &#39;symbol&#39;,
      field: &#39;symbol&#39;,
      header: &#39;Symbol&#39;,
      width: 90,
    },
    {
      id: &#39;sector&#39;,
      field: &#39;sector&#39;,
      header: &#39;Sector&#39;,
      width: 100,
    },
    {
      id: &#39;price&#39;,
      field: &#39;price&#39;,
      header: &#39;Price&#39;,
      width: 100,
      type: &#39;number&#39;,
      // cellClass is evaluated per cell on each render cycle
      // direction is null 400ms after each tick, so the class fades naturally
      cellClass: (row: Instrument) =&gt;
        row.direction === &#39;up&#39; ? &#39;flash-up&#39;
        : row.direction === &#39;down&#39; ? &#39;flash-down&#39;
        : &#39;&#39;,
      format: { minimumFractionDigits: 2, maximumFractionDigits: 2 },
    },
    {
      id: &#39;change&#39;,
      field: &#39;change&#39;,
      header: &#39;Chg %&#39;,
      width: 90,
      type: &#39;number&#39;,
      format: { minimumFractionDigits: 2, maximumFractionDigits: 2 },
      conditionalFormat: [
        {
          condition: ({ value }) =&gt; (value as number) &gt;= 0,
          style: { color: &#39;var(--color-profit)&#39;, fontWeight: &#39;600&#39; },
        },
        {
          condition: ({ value }) =&gt; (value as number) &lt; 0,
          style: { color: &#39;var(--color-loss)&#39;, fontWeight: &#39;600&#39; },
        },
      ],
    },
    {
      id: &#39;volume&#39;,
      field: &#39;volume&#39;,
      header: &#39;Volume&#39;,
      width: 110,
      type: &#39;number&#39;,
      format: { maximumFractionDigits: 0 },
    },
  ]

  let api = $state&lt;SvGridApi | null&gt;(null)
  let stopFeed: (() =&gt; void) | null = null

  onMount(() =&gt; {
    stopFeed = connectFeed(rows, () =&gt; {
      // After each rAF flush, expire direction flags that are old enough
      // This clears the flash class and lets the CSS transition fade it out
      const now = Date.now()
      for (let i = 0; i &lt; rows.length; i++) {
        const row = rows[i]!
        if (row.direction !== null &amp;&amp; now - row.flashAt &gt; 400) {
          rows[i] = { ...row, direction: null }
        }
      }
    })
  })

  onDestroy(() =&gt; stopFeed?.())
&lt;/script&gt;

&lt;div class=&quot;trading-wrap&quot;&gt;
  &lt;SvGrid
    data={rows}
    {columns}
    {features}
    rowId=&quot;id&quot;
    sortable
    height={420}
    onApiReady={(a) =&gt; { api = a }}
  /&gt;
&lt;/div&gt;

&lt;style&gt;
  .trading-wrap {
    --color-profit: #16a34a;
    --color-loss:   #dc2626;
    font-family: &#39;JetBrains Mono&#39;, monospace;
    font-size: 13px;
  }

  :global(.flash-up) {
    background-color: #bbf7d0 !important;
    transition: background-color 400ms ease-out;
  }

  :global(.flash-down) {
    background-color: #fecaca !important;
    transition: background-color 400ms ease-out;
  }
&lt;/style&gt;
</code></pre>
<h2 id="stable-row-identity-and-why-it-matters">Stable row identity and why it matters</h2><p>The <code>rowId=&quot;id&quot;</code> prop is not optional in this use case. Without it, SvGrid keys rows by array index. After the user sorts by <code>change</code> to find today&#39;s biggest movers, the row at index 0 is now a different instrument than before. A tick arrives for <code>CYBR</code>, updates <code>rows[i]</code> by the old index, and patches the wrong DOM node.</p>
<p>With <code>rowId</code>, the virtualization layer tracks each row by its <code>id</code> string. Sort order changes which DOM node is visible at position 0, but each DOM node stays bound to the same instrument across re-renders. This also means the grid does not tear down and rebuild DOM nodes during a sort - it repositions them. At 12 rows that is invisible. At 200 rows the difference is measurable.</p>
<h2 id="the-flash-cycle">The flash cycle</h2><p>Flash is entirely CSS-driven. When a tick arrives, <code>direction</code> is set to <code>'up'</code> or <code>'down'</code>. The <code>cellClass</code> function returns <code>'flash-up'</code> or <code>'flash-down'</code>, which applies a background color. The CSS <code>transition: background-color 400ms ease-out</code> then animates that color back toward transparent over 400 ms.</p>
<p>The <code>onFlush</code> callback does a single pass over all rows and clears <code>direction</code> on any row whose <code>flashAt</code> is more than 400 ms old. No <code>setTimeout</code> per tick, no timer accumulation. At 143 ticks/second across 12 rows you would otherwise accumulate over 100,000 live timers in twelve minutes. The single-pass approach keeps the timer count at exactly one.</p>
<h2 id="keeping-format-separate-from-value">Keeping format separate from value</h2><p>The <code>format</code> option on the <code>price</code> column controls display only. The underlying cell value stays a raw <code>number</code>. This matters because SvGrid uses the raw value for sorting: if you pre-format price as the string <code>&quot;$184.12&quot;</code> and store that in the accessor, lexicographic sort puts <code>&quot;$9.99&quot;</code> after <code>&quot;$99.00&quot;</code> and before <code>&quot;$100.00&quot;</code>. Always store the number, let <code>format</code> handle rendering.</p>
<p>The same logic applies to <code>change</code>. A percent change of <code>-0.07</code> sorts correctly between <code>-0.08</code> and <code>-0.06</code> as a number. Formatted as <code>&quot;-0.07%&quot;</code> it sorts as a string and the ordering breaks for negative values.</p>
<h2 id="scaling-past-12-rows">Scaling past 12 rows</h2><p>The patterns above handle 200 rows at 150 ticks/second comfortably on a mid-range laptop. The rAF flush rate is capped at 60fps regardless of feed speed. The index map makes per-tick row lookup O(1). Object allocation per frame is bounded by the number of distinct symbols that ticked, not the feed rate.</p>
<p>If you need to go further - say 1,000 rows with per-cell sparklines - the next lever is restricting which rows are actually reactive. Keep the visible window in a separate <code>$state</code> slice and only update rows whose index falls in the virtualized viewport. The <code>api.getDisplayedRows()</code> call returns exactly that set, updated after each sort or scroll.</p>
<pre><code class="language-ts">// Only update rows currently visible in the viewport
const displayedIds = new Set(api.getDisplayedRows().map((r) =&gt; r.id))
for (const [id, tick] of pending) {
  if (!displayedIds.has(id)) continue   // skip off-screen rows entirely
  // ... apply update
}
</code></pre>
<p>Off-screen rows still hold correct data - their <code>price</code> field updates in the backing array via the index map - but Svelte never needs to schedule a DOM update for them. When the user scrolls, the virtualizer reads the current values from the array and renders fresh cells. No visible staleness, significantly lower CPU on very large boards.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/logistics-fleet-tracking-grid/">Building a Logistics / Fleet Tracking Grid in Svelte</a></li>
<li><a href="https://svgrid.com/blog/iot-sensor-dashboard/">Building an IoT Sensor Dashboard in Svelte</a></li>
<li><a href="https://svgrid.com/blog/project-task-board-grid/">Building a Project / Task Board with a Svelte Data Grid</a></li>
<li><a href="https://svgrid.com/blog/order-management-dashboard/">Building an Order Management Dashboard in Svelte</a></li>
<li><a href="https://svgrid.com/blog/log-viewer-large-logs/">Building a Log Viewer for Large Logs in Svelte</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Reactivity with Large Arrays and Objects in Svelte 5</title>
      <link>https://svgrid.com/blog/reactivity-large-arrays-objects/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/reactivity-large-arrays-objects/</guid>
      <pubDate>Fri, 28 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Victor Vidolov</dc:creator>
      <category>svelte 5</category>
      <category>reactivity</category>
      <category>arrays</category>
      <category>performance</category>
      <category>engineering</category>
      <description>Deep proxies are powerful but not free. Here is when to pay the cost and when to use $state.raw to keep bulk data loads fast in a SvGrid app.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/reactivity-large-arrays-objects.png" width="1200" height="630" alt="" /></p><p>Every Svelte 5 object you put in <code>$state</code> gets wrapped in a <code>Proxy</code>. One object, negligible. Fifty thousand objects with ten properties each - that is 500,000 proxy handlers allocated before the first row paints. This is the quiet culprit behind grids that stutter on initial load even when the renderer itself is virtualized and fast.</p>
<p><img src="https://svgrid.com/blog-media/million-rows.png" alt="Reactivity with Large Arrays and Objects in Svelte 5"></p>
<h2 id="why-deep-proxying-hurts-at-scale">Why deep proxying hurts at scale</h2><p>Svelte 5&#39;s fine-grained reactivity works by intercepting property reads and writes on every object in your state tree. The upside: write <code>rows[42].status = 'shipped'</code> and only the single cell displaying that status updates. The downside: building that dependency graph requires walking every object recursively at construction time.</p>
<p>Measure it with a quick benchmark. Drop 50,000 plain objects into <code>$state</code> versus <code>$state.raw</code> and time the assignment:</p>
<pre><code class="language-ts">// With deep proxy (plain $state):
// ~90-130 ms on a mid-range laptop
let rows = $state&lt;Order[]&gt;(await fetchOrders()) // Svelte recurses into all 50k objects

// With $state.raw:
// ~1-3 ms - Svelte stores the reference, nothing more
let rows = $state.raw&lt;Order[]&gt;(await fetchOrders())
</code></pre>
<p>The numbers vary by machine and object shape, but the gap is always large enough to be visible to users. A 100 ms freeze before any row appears is not a rendering problem - it is a state initialization problem.</p>
<p>SvGrid&#39;s virtualizer only ever reads 20 to 60 rows at a time, so the DOM work scales regardless. But the proxy wrapping happens before the virtualizer touches anything. Virtualization and <code>$state.raw</code> solve two different problems; you need both for large server-driven datasets.</p>
<h2 id="when-to-reach-for-stateraw">When to reach for <code>$state.raw</code></h2><p>The rule is straightforward: if your code replaces the array as a unit rather than mutating individual elements, use <code>$state.raw</code>. The classic scenario is a paginated server grid where each page load replaces the whole dataset:</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import {
    SvGrid,
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    rowPaginationFeature,
    type ColumnDef,
    type SvGridApi,
  } from &#39;@svgrid/grid&#39;

  type Order = {
    id: string
    customer: string
    sku: string
    qty: number
    amount: number
    status: &#39;placed&#39; | &#39;paid&#39; | &#39;picking&#39; | &#39;shipped&#39; | &#39;delivered&#39;
    region: &#39;NA&#39; | &#39;EU&#39; | &#39;APAC&#39;
    placedAt: string
  }

  // Raw state: Svelte tracks the binding, not the contents.
  // Reassign rows to trigger a rerender. Do NOT mutate elements in place.
  let rows = $state.raw&lt;Order[]&gt;([])
  let loading = $state(false)
  let currentPage = $state(1)

  const features = tableFeatures({
    rowSortingFeature,
    columnFilteringFeature,
    rowPaginationFeature,
  })

  const columns: ColumnDef&lt;typeof features, Order&gt;[] = [
    { id: &#39;id&#39;,       field: &#39;id&#39;,       header: &#39;Order ID&#39;, width: 120 },
    { id: &#39;customer&#39;, field: &#39;customer&#39;, header: &#39;Customer&#39;,  width: 160 },
    { id: &#39;sku&#39;,      field: &#39;sku&#39;,      header: &#39;SKU&#39;,       width: 140 },
    { id: &#39;qty&#39;,      field: &#39;qty&#39;,      header: &#39;Qty&#39;,       width: 70  },
    { id: &#39;amount&#39;,   field: &#39;amount&#39;,   header: &#39;Amount&#39;,    width: 100 },
    { id: &#39;status&#39;,   field: &#39;status&#39;,   header: &#39;Status&#39;,    width: 120 },
    { id: &#39;region&#39;,   field: &#39;region&#39;,   header: &#39;Region&#39;,    width: 90  },
    { id: &#39;placedAt&#39;, field: &#39;placedAt&#39;, header: &#39;Placed&#39;,    width: 160 },
  ]

  let api = $state&lt;SvGridApi&lt;typeof features, Order&gt; | null&gt;(null)

  async function loadPage(page: number) {
    loading = true
    try {
      // Whole-array reassignment - O(1) from Svelte&#39;s perspective with $state.raw
      rows = await fetchOrders(page, 500)
      currentPage = page
    } finally {
      loading = false
    }
  }

  $effect(() =&gt; { loadPage(1) })
&lt;/script&gt;

{#if loading}
  &lt;p class=&quot;loading-indicator&quot;&gt;Loading...&lt;/p&gt;
{/if}

&lt;SvGrid
  {features}
  {columns}
  data={rows}
  onApiReady={(g) =&gt; { api = g }}
  style=&quot;height: 600px&quot;
/&gt;

&lt;div class=&quot;pagination-controls&quot;&gt;
  &lt;button onclick={() =&gt; loadPage(currentPage - 1)} disabled={currentPage &lt;= 1 || loading}&gt;
    Previous
  &lt;/button&gt;
  &lt;span&gt;Page {currentPage}&lt;/span&gt;
  &lt;button onclick={() =&gt; loadPage(currentPage + 1)} disabled={loading}&gt;
    Next
  &lt;/button&gt;
&lt;/div&gt;
</code></pre>
<p>This pattern loads fast at any page size because Svelte never recurses into the rows array. The grid gets a raw array, the virtualizer slices it for display, and the whole thing stays off the reactivity tracking graph until the next page reassignment.</p>
<h2 id="editing-rows-without-rebuilding-the-world">Editing rows without rebuilding the world</h2><p><code>$state.raw</code> imposes one firm rule: mutating an element in place does not trigger a rerender. <code>rows[0].status = 'paid'</code> silently does nothing visible. This trips people up the first time, usually in an edit callback.</p>
<p>The correct pattern is surgical replacement - create a new array with one new object, keep every other reference stable:</p>
<pre><code class="language-ts">// Wrong: creates 50,000 new object references
// SvGrid&#39;s row model sees every row as changed and re-renders all of them
rows = rows.map(r =&gt; r.id === targetId ? { ...r, status: &#39;paid&#39; } : { ...r })

// Also wrong: mutates in place on a $state.raw value - no rerender at all
rows.find(r =&gt; r.id === targetId)!.status = &#39;paid&#39;

// Right: one new object, 49,999 stable references
// SvGrid can diff the array and only update the changed row
function updateStatus(id: string, status: Order[&#39;status&#39;]) {
  const i = rows.findIndex(r =&gt; r.id === id)
  if (i === -1) return
  const next = rows.slice() // shallow copy of the array wrapper
  next[i] = { ...rows[i]!, status }
  rows = next
}
</code></pre>
<p>If you find yourself doing in-place edits frequently - inline cell editing, live form validation, optimistic UI updates - plain <code>$state</code> is the better tool. The proxy cost is real but the ergonomics pay it back. Use <code>$state.raw</code> for datasets that arrive from a server and get replaced wholesale; use plain <code>$state</code> for user-editable datasets that change field by field.</p>
<h2 id="live-updates-from-a-websocket-feed">Live updates from a WebSocket feed</h2><p>The wrong instinct when rows arrive from a WebSocket is to rebuild the array every time. At 5 messages per second on a 10,000-row dataset, each full reassignment triggers a rerender of the entire visible window even if only one row changed.</p>
<p>SvGrid&#39;s <code>applyTransaction</code> handles streaming inserts, updates, and removals without touching the rest of the display model:</p>
<pre><code class="language-ts">let api = $state&lt;SvGridApi&lt;typeof features, Order&gt; | null&gt;(null)

function connectFeed() {
  const ws = new WebSocket(&#39;wss://orders.example.com/live&#39;)

  ws.onmessage = (event) =&gt; {
    const msg: { type: &#39;insert&#39; | &#39;update&#39; | &#39;delete&#39;; row: Order } = JSON.parse(event.data)

    if (!api) return

    if (msg.type === &#39;insert&#39;) {
      api.applyTransaction({ add: [msg.row] })
    } else if (msg.type === &#39;update&#39;) {
      api.applyTransaction({ update: [msg.row] })
    } else if (msg.type === &#39;delete&#39;) {
      api.applyTransaction({ remove: [msg.row] })
    }
  }
}
</code></pre>
<p>This avoids the array reassignment entirely. SvGrid merges each transaction into the existing row model, re-derives sort and filter state for the affected rows only, and queues a targeted DOM update. At 50 messages per second you get smooth incremental updates rather than 50 full repaints.</p>
<h2 id="mixing-stateraw-and-state-in-the-same-component">Mixing <code>$state.raw</code> and <code>$state</code> in the same component</h2><p>You do not have to choose one approach for an entire component. A practical pattern is raw state for the data and regular reactive state for UI concerns - selection, edit drafts, loading flags:</p>
<pre><code class="language-ts">// Server data: raw, replaced as a unit
let rows = $state.raw&lt;Order[]&gt;([])

// UI state: fine-grained, mutated in place
let selectedIds = $state(new Set&lt;string&gt;())
let editDraft = $state&lt;Partial&lt;Order&gt; | null&gt;(null)
let filterText = $state(&#39;&#39;)

// Derive the filtered view without touching rows itself
let visible = $derived(
  filterText.trim()
    ? rows.filter(r =&gt;
        r.customer.toLowerCase().includes(filterText.toLowerCase()) ||
        r.sku.toLowerCase().includes(filterText.toLowerCase())
      )
    : rows
)
</code></pre>
<p><code>$derived</code> reading a <code>$state.raw</code> array registers a dependency on the array reference, not its contents. So <code>visible</code> recomputes when <code>rows</code> or <code>filterText</code> changes, but not when you mutate a row element in place - which is consistent with the raw contract and actually what you want here.</p>
<h2 id="the-threshold-question">The threshold question</h2><p>Below roughly 5,000 rows on a modern machine, the difference between <code>$state</code> and <code>$state.raw</code> is under 5 ms and not worth the ergonomic tradeoff. Profile with <code>performance.mark</code> around your state assignment if you are unsure where your dataset lands. If you do not see a gap, use plain <code>$state</code> and enjoy the simpler mutation model.</p>
<p>Above 20,000 rows, the proxy overhead is consistently user-visible on mid-range hardware. At 50,000 rows it is the dominant cost on page load - not the network, not the rendering, not the sort. Switch to <code>$state.raw</code>, adopt the surgical replacement pattern for edits, and use <code>applyTransaction</code> for streaming updates. The three together keep a large grid feeling immediate regardless of dataset size.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/derived-vs-derived-by/">$derived vs $derived.by in Svelte 5</a></li>
<li><a href="https://svgrid.com/blog/why-we-bet-on-svelte-5-runes/">Why We Bet on Svelte 5 Runes for a High-Performance Data Grid</a></li>
<li><a href="https://svgrid.com/blog/migrating-svelte-4-table-to-svelte-5/">Migrating a Svelte 4 Table Component to Svelte 5</a></li>
<li><a href="https://svgrid.com/blog/bindable-props-grid-controls/">$bindable Props for Grid Controls in Svelte 5</a></li>
<li><a href="https://svgrid.com/blog/immutable-updates-without-killing-performance/">Immutable Grid Updates Without Killing Performance</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Building a Project / Task Board with a Svelte Data Grid</title>
      <link>https://svgrid.com/blog/project-task-board-grid/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/project-task-board-grid/</guid>
      <pubDate>Thu, 27 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Victor Vidolov</dc:creator>
      <category>tasks</category>
      <category>project management</category>
      <category>grouping</category>
      <category>use case</category>
      <category>svelte data grid</category>
      <description>How to build a task management grid with grouping by status or assignee, inline edits, subtask tree rows, and saved views - without reaching for a dedicated project management tool.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/project-task-board-grid.png" width="1200" height="630" alt="" /></p><p>Kanban boards look great in demos. In practice, when a sprint has 80 tasks spread across 6 engineers, a drag-and-drop board becomes a scrolling nightmare. A grid view with grouping, inline editing, and fast keyboard navigation handles that volume much better. I&#39;ve seen teams switch from purpose-built project tools to a well-configured data grid and never look back.</p>
<p><img src="https://svgrid.com/blog-media/wbs-tree.png" alt="A work-breakdown project tree in SvGrid">
<em>A project breakdown as tree rows in SvGrid.</em></p>
<h2 id="the-data-model-that-makes-everything-else-easier">The data model that makes everything else easier</h2><p>Before writing a single column definition, get the data shape right. Tasks with subtasks need a <code>parentId</code> field - SvGrid&#39;s tree data feature uses this to build the hierarchy client-side. You don&#39;t need a recursive structure; a flat array with parent references is enough.</p>
<pre><code class="language-ts">// types.ts
export interface Task {
  id: string
  parentId: string | null
  title: string
  assignee: string
  status: &#39;todo&#39; | &#39;in-progress&#39; | &#39;done&#39; | &#39;blocked&#39;
  priority: &#39;low&#39; | &#39;medium&#39; | &#39;high&#39; | &#39;critical&#39;
  dueDate: string | null  // ISO date string
  storyPoints: number | null
  sprint: string
}
</code></pre>
<p>The <code>parentId: null</code> tasks are root-level; anything with a parentId is a subtask. This flat-with-references pattern is what most APIs return anyway, so you can often skip a transformation step.</p>
<h2 id="column-definitions-what-to-show-and-how-to-edit-it">Column definitions: what to show and how to edit it</h2><p>The column setup does most of the heavy lifting. Status and priority are the most-edited fields in any task board, so make them fast to edit in place rather than opening a dialog.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import type { ColumnDef } from &#39;@svgrid/grid&#39;
  import type { Task } from &#39;./types&#39;

  let api: any

  const statusOptions = [&#39;todo&#39;, &#39;in-progress&#39;, &#39;done&#39;, &#39;blocked&#39;]
  const priorityOptions = [&#39;low&#39;, &#39;medium&#39;, &#39;high&#39;, &#39;critical&#39;]

  const statusColors: Record&lt;string, string&gt; = {
    &#39;todo&#39;: &#39;#94a3b8&#39;,
    &#39;in-progress&#39;: &#39;#3b82f6&#39;,
    &#39;done&#39;: &#39;#22c55e&#39;,
    &#39;blocked&#39;: &#39;#ef4444&#39;,
  }

  const priorityColors: Record&lt;string, string&gt; = {
    &#39;low&#39;: &#39;#94a3b8&#39;,
    &#39;medium&#39;: &#39;#f59e0b&#39;,
    &#39;high&#39;: &#39;#f97316&#39;,
    &#39;critical&#39;: &#39;#dc2626&#39;,
  }

  const columns: ColumnDef&lt;any, Task&gt;[] = [
    {
      id: &#39;title&#39;,
      field: &#39;title&#39;,
      header: &#39;Task&#39;,
      width: 320,
      editable: true,
      pinned: &#39;left&#39;,
    },
    {
      id: &#39;assignee&#39;,
      field: &#39;assignee&#39;,
      header: &#39;Assignee&#39;,
      width: 140,
      editable: true,
    },
    {
      id: &#39;status&#39;,
      field: &#39;status&#39;,
      header: &#39;Status&#39;,
      width: 130,
      editable: true,
      cell: statusCell,
      conditionalFormat: [
        { condition: ({ row }) =&gt; row.original.status === &#39;blocked&#39;,
          style: { background: &#39;#fef2f2&#39; } },
      ],
    },
    {
      id: &#39;priority&#39;,
      field: &#39;priority&#39;,
      header: &#39;Priority&#39;,
      width: 110,
      editable: true,
      cell: priorityCell,
    },
    {
      id: &#39;dueDate&#39;,
      field: &#39;dueDate&#39;,
      header: &#39;Due&#39;,
      width: 110,
      type: &#39;date&#39;,
      editable: true,
      conditionalFormat: [
        {
          condition: ({ value }) =&gt; {
            if (!value) return false
            return new Date(value) &lt; new Date()
          },
          style: { color: &#39;#dc2626&#39;, fontWeight: &#39;600&#39; },
        },
      ],
    },
    {
      id: &#39;storyPoints&#39;,
      field: &#39;storyPoints&#39;,
      header: &#39;SP&#39;,
      width: 60,
      type: &#39;number&#39;,
      editable: true,
    },
    {
      id: &#39;sprint&#39;,
      field: &#39;sprint&#39;,
      header: &#39;Sprint&#39;,
      width: 110,
      editable: true,
    },
  ]
&lt;/script&gt;

{#snippet statusCell({ value }: { value: string })}
  &lt;span class=&quot;status-badge&quot; style=&quot;background: {statusColors[value]}20; color: {statusColors[value]}; border: 1px solid {statusColors[value]}40&quot;&gt;
    {value}
  &lt;/span&gt;
{/snippet}

{#snippet priorityCell({ value }: { value: string })}
  &lt;span class=&quot;priority-dot&quot; style=&quot;color: {priorityColors[value]}&quot;&gt;
    ● {value}
  &lt;/span&gt;
{/snippet}

&lt;SvGrid
  {data}
  {columns}
  sortable
  filterable
  groupable
  editable
  showFilterRow={true}
  enableCellSelection={true}
  rowHeight={36}
  virtualization={true}
  onApiReady={(a) =&gt; { api = a }}
/&gt;
</code></pre>
<p>The <code>conditionalFormat</code> on the due date column is one of those small details that makes a task board genuinely useful - overdue tasks turn red automatically without any custom cell renderer.</p>
<h2 id="grouping-by-status-gives-you-a-lightweight-board-view">Grouping by status gives you a lightweight board view</h2><p>Grouping is where the grid earns its keep over a plain table. Group by status and you get something that looks like a Kanban board but lets you sort within each group, bulk-select, and see aggregated story points per column.</p>
<pre><code class="language-ts">// Toggle grouping mode programmatically
function groupByStatus() {
  api.setGroupBy([&#39;status&#39;])
  api.expandAllGroups()
}

function groupByAssignee() {
  api.setGroupBy([&#39;assignee&#39;])
  api.expandAllGroups()
}

function groupBySprint() {
  api.setGroupBy([&#39;sprint&#39;, &#39;status&#39;])
  api.expandAllGroups()
}

function clearGrouping() {
  api.setGroupBy([])
}
</code></pre>
<p>The two-level grouping <code>['sprint', 'status']</code> is particularly useful for sprint planning - you see each sprint broken into status buckets with counts and story point totals per group.</p>
<h2 id="subtasks-as-tree-rows">Subtasks as tree rows</h2><p>SvGrid handles tree data by reading <code>parentId</code> and building the hierarchy automatically. Parent tasks show expand/collapse controls; subtasks are indented. You get rollup aggregation on the parent - so if a parent has 3 done subtasks out of 5, you can show &quot;3/5&quot; in a custom cell.</p>
<p>The tree feature activates when you pass a <code>getSubRows</code> option. For a flat array with <code>parentId</code>:</p>
<pre><code class="language-ts">import SvGrid from &#39;@svgrid/grid&#39;

// SvGrid resolves the hierarchy from your flat data
// Pass getSubRows to define how children are found
const gridOptions = {
  data: tasks,     // flat Task[] with parentId
  columns,
  getSubRows: (row: Task, allRows: Task[]) =&gt;
    allRows.filter(r =&gt; r.parentId === row.id),
}
</code></pre>
<p>One thing to watch: if your dataset is large (thousands of tasks), the <code>getSubRows</code> traversal runs on every render cycle. For that scale, pre-build a <code>children</code> map once and reference it:</p>
<pre><code class="language-ts">const childMap = new Map&lt;string | null, Task[]&gt;()
for (const task of tasks) {
  const bucket = childMap.get(task.parentId) ?? []
  bucket.push(task)
  childMap.set(task.parentId, bucket)
}

const getSubRows = (row: Task) =&gt; childMap.get(row.id) ?? []
</code></pre>
<p>The pre-built map turns O(n) per-row lookups into O(1). For 5,000 tasks with deep subtask nesting, this is the difference between 60fps and noticeable jank.</p>
<h2 id="saving-views-so-engineers-can-get-back-to-their-context">Saving views so engineers can get back to their context</h2><p>Every engineer has a personal filter they run every day: &quot;show me my in-progress tasks for Sprint 12, sorted by priority&quot;. Making that view persistent costs three lines:</p>
<pre><code class="language-ts">import { createNamedViews, localStorageViews } from &#39;@svgrid/grid&#39;

const views = createNamedViews({
  storage: localStorageViews(&#39;task-board&#39;),
})

// Save current filters, grouping, column order, and sort
function saveView(name: string) {
  const state = api.getState()
  views.save(name, state)
}

// Restore a saved view
function loadView(name: string) {
  const state = views.load(name)
  if (state) api.setState(state)
}

// Get the list of saved view names for a dropdown
const savedViews = views.list()
</code></pre>
<p><code>getState()</code> captures filters, sorts, grouping, column widths, visibility, and pinning in one call. <code>setState()</code> restores all of it. You can serialize this to a URL for shareable views, or push it to a database so views survive across devices.</p>
<h2 id="bulk-operations-for-fast-triage">Bulk operations for fast triage</h2><p>When a sprint ends and you need to move 15 tasks from &quot;in-progress&quot; to &quot;done&quot;, selecting each task individually is painful. Select all with <code>Ctrl+A</code>, or select a range, then apply a transaction:</p>
<pre><code class="language-ts">function bulkMarkDone() {
  const selected = api.getSelectedRows()
  const updates = selected.map(row =&gt; ({
    ...row,
    status: &#39;done&#39; as const,
  }))
  api.applyTransaction({ update: updates })
}

function bulkReassign(newAssignee: string) {
  const selected = api.getSelectedRows()
  api.applyTransaction({
    update: selected.map(row =&gt; ({ ...row, assignee: newAssignee })),
  })
}
</code></pre>
<p><code>applyTransaction</code> is optimistic - the grid updates instantly. Wire up your API call alongside it and roll back if the server rejects it. That pattern keeps the UI snappy even with a slow backend.</p>
<h2 id="when-a-dedicated-tool-makes-more-sense">When a dedicated tool makes more sense</h2><p>The grid approach works well when you need speed, density, and customization. It struggles when your primary workflow is visual drag-and-drop ordering (a real Kanban board is better for that), or when non-technical users need a polished no-config experience.</p>
<p>If your users are engineers or analysts who live in spreadsheets, the grid will feel natural. If they&#39;re used to a visual board, expect a learning curve. The good news is you can ship both - a Kanban view and a grid view pulling from the same data - because the grid is just a rendering layer over your task state. Setting the <code>board</code> prop renders these same rows as draggable cards; see <a href="https://svgrid.com/blog/svelte-kanban-board/">building a Kanban board in Svelte 5</a> for that side of it.</p>
<p>The conditional row highlighting for blocked tasks, the overdue date coloring, the instant group-by toggle - these are the details that make an internal tool feel finished. They&#39;re also the kind of details that take weeks to build in a custom table but come out of the box here.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/real-time-trading-grid/">Building a Real-Time Trading Grid in Svelte</a></li>
<li><a href="https://svgrid.com/blog/order-management-dashboard/">Building an Order Management Dashboard in Svelte</a></li>
<li><a href="https://svgrid.com/blog/logistics-fleet-tracking-grid/">Building a Logistics / Fleet Tracking Grid in Svelte</a></li>
<li><a href="https://svgrid.com/blog/log-viewer-large-logs/">Building a Log Viewer for Large Logs in Svelte</a></li>
<li><a href="https://svgrid.com/blog/iot-sensor-dashboard/">Building an IoT Sensor Dashboard in Svelte</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Progress and Percentage Bar Cells in SvGrid</title>
      <link>https://svgrid.com/blog/progress-bar-cells/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/progress-bar-cells/</guid>
      <pubDate>Wed, 26 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Victor Vidolov</dc:creator>
      <category>progress bar</category>
      <category>cells</category>
      <category>custom cells</category>
      <category>recipe</category>
      <category>svelte data grid</category>
      <description>Build in-cell progress bars in your Svelte 5 data grid - with color thresholds, accessible markup, and sorting that still works.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/progress-bar-cells.png" width="1200" height="630" alt="" /></p><p>Raw numbers in a data grid are hard to scan. A column full of values like 73, 12, 98, 41 tells you almost nothing at a glance - you have to read each one and carry context from cell to cell. A bar changes that. You see 98 as &quot;basically done&quot; and 12 as &quot;barely started&quot; before your brain even processes the digits.</p>
<p><img src="https://svgrid.com/blog-media/custom-cells-themes.png" alt="Progress and Percentage Bar Cells in SvGrid"></p>
<p>SvGrid cells accept any Svelte snippet, so a progress bar is just HTML and a couple of CSS rules. The trick is knowing which concerns belong to the cell renderer and which belong to the column definition - get that boundary wrong and you lose sorting, filtering, and conditional formatting.</p>
<h2 id="the-bar-lives-in-the-snippet-the-value-lives-in-the-field">The bar lives in the snippet, the value lives in the field</h2><p>This is the only rule worth internalizing before writing any custom cell: the column&#39;s <code>field</code> or <code>accessorFn</code> owns the value. The snippet owns the presentation. If you compute or transform the value inside your snippet, you break every feature that reads the column&#39;s value - sorting, filtering, column stats, copy-to-clipboard.</p>
<p>Here is the correct pattern:</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid, { renderSnippet, type ColumnDef } from &#39;@svgrid/grid&#39;

  type Row = { task: string; progress: number }

  const data: Row[] = [
    { task: &#39;Data migration&#39;, progress: 87 },
    { task: &#39;UI redesign&#39;, progress: 34 },
    { task: &#39;API integration&#39;, progress: 100 },
    { task: &#39;QA testing&#39;, progress: 12 },
    { task: &#39;Documentation&#39;, progress: 61 },
  ]
&lt;/script&gt;

{#snippet ProgressCell(p: { value: number })}
  {@const pct = Math.max(0, Math.min(100, p.value))}
  &lt;div
    class=&quot;progress-track&quot;
    role=&quot;progressbar&quot;
    aria-valuenow={pct}
    aria-valuemin={0}
    aria-valuemax={100}
    aria-label=&quot;{pct}% complete&quot;
  &gt;
    &lt;div class=&quot;progress-fill&quot; style=&quot;width:{pct}%&quot;&gt;&lt;/div&gt;
    &lt;span class=&quot;progress-label&quot;&gt;{pct}%&lt;/span&gt;
  &lt;/div&gt;
{/snippet}

{@const columns: ColumnDef[] = [
  { id: &#39;task&#39;, field: &#39;task&#39;, header: &#39;Task&#39;, width: 200 },
  {
    id: &#39;progress&#39;,
    field: &#39;progress&#39;,
    header: &#39;Progress&#39;,
    width: 160,
    cell: (ctx) =&gt; renderSnippet(ProgressCell, { value: ctx.getValue&lt;number&gt;() }),
  },
]}

&lt;SvGrid {data} {columns} sortable /&gt;
</code></pre>
<p>The column reads from <code>field: 'progress'</code>, so clicking the header sorts by the real number. The snippet receives the value and renders the bar. These concerns never mix.</p>
<h2 id="styling-the-track-and-fill">Styling the track and fill</h2><p>CSS variables from SvGrid&#39;s token set keep the bar consistent with whatever theme is active:</p>
<pre><code class="language-css">.progress-track {
  position: relative;
  height: 16px;
  border-radius: 8px;
  background: color-mix(in srgb, var(--sg-fg) 8%, transparent);
  overflow: hidden;
  margin: 0 4px;
}

.progress-fill {
  height: 100%;
  border-radius: 8px;
  background: var(--sg-accent);
  transition: width 150ms ease;
}

.progress-label {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 11px;
  font-weight: 600;
  color: var(--sg-fg);
  mix-blend-mode: difference;
}
</code></pre>
<p><code>mix-blend-mode: difference</code> on the label keeps the text readable whether it sits on the filled portion or the empty track. If you find blend modes unreliable in your target browsers, use two overlapping spans instead - one clipped to the fill region and one for the empty region, each with a contrasting color.</p>
<h2 id="color-thresholds">Color thresholds</h2><p>A single-color bar is better than a number. A threshold-colored bar is better still. Color maps progress to status: green means on track, amber means at risk, red means blocked. You can drive this entirely from the snippet, since color is presentation:</p>
<pre><code class="language-svelte">{#snippet ThresholdBar(p: { value: number })}
  {@const pct = Math.max(0, Math.min(100, p.value))}
  {@const color =
    pct &gt;= 80 ? &#39;var(--color-success, #22c55e)&#39; :
    pct &gt;= 40 ? &#39;var(--color-warning, #f59e0b)&#39; :
                &#39;var(--color-danger,  #ef4444)&#39;}

  &lt;div
    class=&quot;progress-track&quot;
    role=&quot;progressbar&quot;
    aria-valuenow={pct}
    aria-valuemin={0}
    aria-valuemax={100}
    aria-label=&quot;{pct}% complete&quot;
  &gt;
    &lt;div class=&quot;progress-fill&quot; style=&quot;width:{pct}%; background:{color}&quot;&gt;&lt;/div&gt;
    &lt;span class=&quot;progress-label&quot;&gt;{pct}%&lt;/span&gt;
  &lt;/div&gt;
{/snippet}
</code></pre>
<p>The thresholds are expressed as plain numbers - easy to adjust, easy to read. If your threshold boundaries come from server config or user preferences, pull them in through a prop or a context store rather than hardcoding them.</p>
<h2 id="when-you-want-svgrids-conditional-formatting-instead">When you want SvGrid&#39;s conditional formatting instead</h2><p>If you want the whole cell - background, text color, font weight - to change based on the value, the <code>conditionalFormat</code> column option is the cleaner path. It avoids writing threshold logic inside your snippet:</p>
<pre><code class="language-ts">import { resolveCellFormat, type ColumnDef } from &#39;@svgrid/grid&#39;

const columns: ColumnDef[] = [
  {
    id: &#39;progress&#39;,
    field: &#39;progress&#39;,
    header: &#39;Progress&#39;,
    width: 160,
    cell: (ctx) =&gt; renderSnippet(ProgressCell, { value: ctx.getValue&lt;number&gt;() }),
    conditionalFormat: [
      {
        condition: ({ value }) =&gt; value &lt; 40,
        style: { color: &#39;#ef4444&#39;, fontWeight: &#39;700&#39; },
      },
      {
        condition: ({ value }) =&gt; value &gt;= 80,
        style: { color: &#39;#16a34a&#39; },
      },
    ],
  },
]
</code></pre>
<p>The conditional format applies to the cell wrapper, so it affects the label text and can tint the cell background independently of the bar fill. Use it when the visual state should be obvious even before the bar width registers, such as a status overview where users scan the label color more than the bar length.</p>
<h2 id="stacked-and-segmented-variants">Stacked and segmented variants</h2><p>Some use cases need a segmented bar - completed, in-progress, and blocked as three colored segments in one cell. The data shape changes (you need three fields or a structured value), but the rendering principle stays the same:</p>
<pre><code class="language-svelte">{#snippet SegmentedBar(p: { done: number; inProgress: number; blocked: number })}
  {@const total = p.done + p.inProgress + p.blocked}
  {@const pDone = total &gt; 0 ? (p.done / total) * 100 : 0}
  {@const pActive = total &gt; 0 ? (p.inProgress / total) * 100 : 0}
  {@const pBlocked = total &gt; 0 ? (p.blocked / total) * 100 : 0}

  &lt;div class=&quot;seg-track&quot; role=&quot;img&quot; aria-label=&quot;Done {p.done}, In progress {p.inProgress}, Blocked {p.blocked}&quot;&gt;
    &lt;div class=&quot;seg done&quot;   style=&quot;width:{pDone}%&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;seg active&quot; style=&quot;width:{pActive}%&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;seg blocked&quot; style=&quot;width:{pBlocked}%&quot;&gt;&lt;/div&gt;
  &lt;/div&gt;
{/snippet}
</code></pre>
<pre><code class="language-css">.seg-track { display: flex; height: 14px; border-radius: 7px; overflow: hidden; gap: 1px; }
.seg       { height: 100%; }
.seg.done    { background: #22c55e; }
.seg.active  { background: #f59e0b; }
.seg.blocked { background: #ef4444; }
</code></pre>
<p>For segmented bars the <code>field</code> on the column still matters - point it at whichever sub-value you want to sort by. If you want to sort by &quot;done count&quot;, set <code>field: 'done'</code> and the accessor pulls from the right property.</p>
<h2 id="one-thing-to-watch-with-virtualization">One thing to watch with virtualization</h2><p>SvGrid virtualizes rows by default. When a row scrolls out of view its DOM node is reused. If your progress bar uses a CSS transition on <code>width</code>, you may briefly see the fill animate from the previous row&#39;s value to the new one as cells are recycled. This is usually imperceptible at normal scroll speeds, but if it bothers you, either remove the transition or reset it on mount:</p>
<pre><code class="language-svelte">{#snippet ProgressCell(p: { value: number })}
  {@const pct = Math.max(0, Math.min(100, p.value))}
  &lt;div class=&quot;progress-track&quot; ...&gt;
    &lt;div
      class=&quot;progress-fill&quot;
      style=&quot;width:{pct}%; transition: none&quot;
    &gt;&lt;/div&gt;
  &lt;/div&gt;
{/snippet}
</code></pre>
<p>Removing the transition entirely is the pragmatic call for most grids. Reserve animated bars for dashboards where rows do not scroll - where you are showing live updates to a fixed set of items and the animation communicates that a value just changed.</p>
<p>Progress bars are one of those additions that take ten minutes to build and immediately make a grid feel like a product rather than a spreadsheet. The pattern scales from a single completion column to a full project-tracking view with segment bars, threshold colors, and sorted-by-value headers.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/cell-tooltips/">Cell Tooltips Done Right in a Data Grid</a></li>
<li><a href="https://svgrid.com/blog/avatar-and-image-cells/">Avatar and Image Cells in SvGrid</a></li>
<li><a href="https://svgrid.com/blog/actions-column-edit-delete/">An Actions Column (Edit, Delete) in SvGrid</a></li>
<li><a href="https://svgrid.com/blog/custom-cell-renderers-with-snippets/">Custom Cell Renderers with Svelte Snippets</a></li>
<li><a href="https://svgrid.com/blog/multi-level-column-headers/">Multi-Level (Grouped) Column Headers in SvGrid</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>svelte-headless-table and the Svelte 5 upgrade: your three options</title>
      <link>https://svgrid.com/blog/svelte-headless-table-svelte-5-options/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/svelte-headless-table-svelte-5-options/</guid>
      <pubDate>Wed, 26 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Boyko Markov</dc:creator>
      <category>svelte-headless-table</category>
      <category>svelte 5</category>
      <category>migration</category>
      <category>alternatives</category>
      <category>svelte data grid</category>
      <description>svelte-headless-table has not shipped since October 2024 and declares svelte@^4. Here are the three real paths off it - the maintained fork, TanStack Table v9, or a rendered grid - and how to tell which one is yours.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/svelte-headless-table-svelte-5-options.png" width="1200" height="630" alt="" /></p><p>If you are upgrading a Svelte app to 5 and <code>svelte-headless-table</code> is in your <code>package.json</code>, you have already hit the wall: the install warns about a peer conflict, and nobody has published a fix.</p>
<p>This post is the decision, not the sales pitch. We maintain <a href="https://svgrid.com/">SvGrid</a>, which is one of the three options below - and for a good number of you it is the wrong one. The recommendation logic here is the same one we would give you in a GitHub thread.</p>
<h2 id="what-actually-broke">What actually broke</h2><p>Three facts, all checkable on the registry as of 26 August 2026:</p>
<ul>
<li>The last release of <code>svelte-headless-table</code> is <strong>0.18.3, published 28 October 2024</strong>. That is 22 months of silence.</li>
<li>It declares <code>peerDependencies: { &quot;svelte&quot;: &quot;^4.0.0&quot; }</code>. Installing it next to Svelte 5 is a peer-range conflict - an <code>ERESOLVE</code> error on npm, a warning-then-breakage on pnpm.</li>
<li>It still pulls roughly <strong>64,000 downloads a month</strong>. This is not a dead library with three users. It is a widely used one that stopped moving.</li>
</ul>
<p>One thing that is <em>not</em> broken, despite what a lot of migration posts (including, until today, one of our own docs pages) claim: <strong>Svelte 5 did not remove <code>let:</code> or slots.</strong> They are deprecated in favour of snippets and they still render. The official migration guide is explicit that they &quot;continue to work&quot;. So your <code>&lt;Subscribe let:row&gt;</code> blocks are not a compile error.</p>
<p>The real friction is narrower and worth stating precisely, because it changes the decision:</p>
<ol>
<li><strong>The peer range</strong> is a hard install-time stop.</li>
<li><strong>Slotted content cannot be passed to a component that renders with <code>{@render ...}</code>.</strong> The moment your codebase moves to snippets, the boundary between your runes components and headless-table&#39;s slot-based <code>Subscribe</code> starts to chafe.</li>
<li><strong>Nothing is coming.</strong> Stores still work in Svelte 5, so this is not urgent in the way a breaking change would be - but you are building on a library whose maintainer has moved on.</li>
</ol>
<p>That combination means: you have time, you are not in a crisis, and you should pick deliberately rather than reflexively rewriting.</p>
<h2 id="option-1-the-maintained-fork">Option 1: the maintained fork</h2><p><strong><a href="https://www.npmjs.com/package/@humanspeak/svelte-headless-table"><code>@humanspeak/svelte-headless-table</code></a></strong> - v6.0.13, published 6 August 2026, <code>peerDependencies: svelte ^5.30.0</code>. Same API.</p>
<pre><code class="language-bash">npm remove svelte-headless-table
npm i @humanspeak/svelte-headless-table
</code></pre>
<p>Then change your import paths. That is the migration.</p>
<p><strong>Pick this if</strong> your table works and you only need it to install on Svelte 5. It is the cheapest path by a wide margin, and &quot;cheapest path that works&quot; is usually the correct engineering answer. Roughly 8,900 downloads a month say others reached the same conclusion.</p>
<p><strong>The honest risk:</strong> it is a fork with one maintainer, so you are trading a stalled upstream for a smaller one. Check its commit activity before you commit to it - if that is not a trade you want to make, keep reading.</p>
<h2 id="option-2-tanstack-table-v9">Option 2: TanStack Table v9</h2><p><strong><a href="https://www.npmjs.com/package/@tanstack/svelte-table"><code>@tanstack/svelte-table</code></a></strong> - v9.1.2, published 9 August 2026, <code>peerDependencies: svelte ^5.0.0</code>. Around 194,000 downloads a month.</p>
<p>The category leader now has a first-party Svelte 5 adapter, so the old &quot;TanStack has no Svelte 5 story&quot; advice is out of date. If you like headless tables as a category - you want to own the markup, and the library should only own the row model - this is the largest, best-funded option in that category.</p>
<p><strong>Pick this if</strong> you want to stay headless and you value ecosystem size. The concepts port over cleanly: column defs are column defs, and headless-table&#39;s plugins map onto TanStack&#39;s row models.</p>
<p><strong>The honest cost:</strong> it is still headless-only. Every <code>&lt;table&gt;</code>, <code>&lt;thead&gt;</code>, and <code>&lt;tr&gt;</code> remains yours to write, style, virtualize, and make accessible. You are re-doing the markup work, not inheriting it. And v9 is a real migration - the API is not headless-table&#39;s.</p>
<h2 id="option-3-a-grid-that-ships-the-renderer">Option 3: a grid that ships the renderer</h2><p>This is us. <strong><a href="https://svgrid.com/">SvGrid</a></strong> is Svelte 5 runes-native, MIT-licensed at the core, and it inverts the trade: instead of a row model plus your markup, you get a <code>&lt;SvGrid&gt;</code> component with virtualization, sticky headers, keyboard navigation, and ARIA already handled - with a <a href="https://svgrid.com/docs/why-headless/">headless core</a> underneath at <code>@svgrid/grid/core</code> for when you do need the DOM.</p>
<pre><code class="language-bash">npx @svgrid/migrate             # preview the changes
npx @svgrid/migrate src --write # apply them
</code></pre>
<p>The codemod translates column definitions and plugin config, deletes the <code>Subscribe</code>/<code>Render</code> scaffolding, and reports what it could not map instead of dropping it silently. Full detail in the <a href="https://svgrid.com/docs/help/migrating-from-svelte-headless-table/">migration guide</a>.</p>
<p><strong>Pick this if</strong> the markup is the part you are tired of. The clearest signal is if you have hand-rolled virtualization, a filter menu, or column resizing on top of headless-table and you resent maintaining them.</p>
<p><strong>The honest cost:</strong> you give up DOM control at the component level, and some things genuinely do not come across - <code>createRender(MyComponent)</code> cell renderers become snippets, and custom plugins have no equivalent because there is no plugin system to port them into. That is a rewrite, not a migration. Advanced features (export, pivot, board and scheduler views) are a paid add-on; the grid itself is not.</p>
<h2 id="choosing">Choosing</h2><table>
<thead>
<tr>
<th>If this is you</th>
<th>Take</th>
</tr>
</thead>
<tbody><tr>
<td>The table works; I just need Svelte 5 to install</td>
<td><strong>The fork</strong></td>
</tr>
<tr>
<td>I want to own the markup, with the biggest ecosystem behind me</td>
<td><strong>TanStack v9</strong></td>
</tr>
<tr>
<td>I am tired of maintaining table markup</td>
<td><strong>A rendered grid</strong></td>
</tr>
<tr>
<td>I depend on custom headless-table plugins</td>
<td><strong>The fork</strong> - the others are a rewrite</td>
</tr>
<tr>
<td>My &quot;table&quot; is 20 rows and no interaction</td>
<td><strong>None of them.</strong> An <code>{#each}</code> is less code</td>
</tr>
</tbody></table>
<p>That last row is not a joke. A meaningful share of headless-table usage is a static list that never needed a table library, and the upgrade is a good moment to delete a dependency instead of replacing it.</p>
<h2 id="the-one-thing-not-to-do">The one thing not to do</h2><p>Do not pin <code>svelte@4</code> to avoid the decision. That trades a scoped afternoon of work now for a compounding one later, as the rest of your dependencies move to Svelte 5 and you fall off the upgrade path for every one of them. All three options above are live, maintained, and installable today - which is a better position than most stranded libraries leave their users in.</p>
<p>If you want the side-by-side on our option specifically, it is at <a href="https://svgrid.com/compare/svelte-headless-table/">SvGrid vs svelte-headless-table</a>, including the cases where we tell you to use something else.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/svelte-data-grid-comparisons/">Svelte Data Grid Comparisons and Alternatives (2026)</a></li>
<li><a href="https://svgrid.com/blog/porting-mui-x-datagrid-to-svelte/">Porting a React MUI X DataGrid Screen to Svelte</a></li>
<li><a href="https://svgrid.com/blog/migrating-from-datatables-to-svelte/">Migrating from DataTables.net to a Svelte Data Grid</a></li>
<li><a href="https://svgrid.com/blog/migrating-from-ag-grid-react-to-svelte/">Migrating from ag-grid-react to a Svelte Stack</a></li>
<li><a href="https://svgrid.com/blog/ag-grid-alternatives-for-svelte/">AG Grid Alternatives for Svelte Developers</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Porting a React MUI X DataGrid Screen to Svelte</title>
      <link>https://svgrid.com/blog/porting-mui-x-datagrid-to-svelte/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/porting-mui-x-datagrid-to-svelte/</guid>
      <pubDate>Tue, 25 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Kamelia M</dc:creator>
      <category>migration</category>
      <category>mui x datagrid</category>
      <category>react</category>
      <category>comparison</category>
      <category>svelte data grid</category>
      <description>A practical mapping from MUI X DataGrid to SvGrid - columns, cell renderers, server-side data, and the hooks that disappear when you switch to Svelte runes.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/porting-mui-x-datagrid-to-svelte.png" width="1200" height="630" alt="" /></p><p>MUI X DataGrid is a solid grid for React apps. When teams migrate to Svelte 5, the grid screen is usually what they dread most - partly because of the Pro tier&#39;s API surface, partly because <code>renderCell</code> looks entangled with JSX in ways that seem hard to untangle. In practice, the porting work is more mechanical than it looks. The column model maps almost one-to-one, and a chunk of the React boilerplate just evaporates.</p>
<p><img src="https://svgrid.com/blog-media/quick-start.png" alt="Porting a React MUI X DataGrid Screen to Svelte"></p>
<h2 id="the-column-model-renamed">The column model, renamed</h2><p>The biggest conceptual change is small in practice. <code>GridColDef</code> becomes <code>ColumnDef</code>, <code>headerName</code> becomes <code>header</code>, and that&#39;s mostly it for the basic fields.</p>
<pre><code class="language-ts">// MUI X DataGrid (React)
import { GridColDef } from &#39;@mui/x-data-grid&#39;

const columns: GridColDef[] = [
  { field: &#39;id&#39;, headerName: &#39;ID&#39;, width: 80 },
  { field: &#39;name&#39;, headerName: &#39;Full Name&#39;, width: 200 },
  {
    field: &#39;salary&#39;,
    headerName: &#39;Salary&#39;,
    width: 130,
    type: &#39;number&#39;,
    valueFormatter: ({ value }) =&gt; `$${Number(value).toLocaleString()}`,
  },
  {
    field: &#39;department&#39;,
    headerName: &#39;Department&#39;,
    width: 160,
    valueGetter: (params) =&gt; params.row.dept?.name ?? &#39;-&#39;,
  },
]
</code></pre>
<pre><code class="language-ts">// SvGrid (Svelte 5)
import type { ColumnDef } from &#39;@svgrid/grid&#39;

const columns: ColumnDef&lt;typeof features, Row&gt;[] = [
  { id: &#39;id&#39;, field: &#39;id&#39;, header: &#39;ID&#39;, width: 80 },
  { id: &#39;name&#39;, field: &#39;name&#39;, header: &#39;Full Name&#39;, width: 200 },
  {
    id: &#39;salary&#39;,
    field: &#39;salary&#39;,
    header: &#39;Salary&#39;,
    width: 130,
    type: &#39;number&#39;,
    format: { type: &#39;currency&#39;, currency: &#39;USD&#39; },
  },
  {
    id: &#39;department&#39;,
    header: &#39;Department&#39;,
    width: 160,
    accessorFn: (row) =&gt; row.dept?.name ?? &#39;-&#39;,
  },
]
</code></pre>
<p><code>valueGetter</code> becomes <code>accessorFn</code>. <code>valueFormatter</code> becomes <code>format</code> for standard cases (currency, date, number precision) or a <code>formatter</code> function when you need custom logic. The <code>id</code> field is required in SvGrid - it drives keyed rendering under virtualization.</p>
<p>Here is the full concept map:</p>
<table>
<thead>
<tr>
<th>MUI X DataGrid</th>
<th>SvGrid</th>
</tr>
</thead>
<tbody><tr>
<td><code>rows</code> prop</td>
<td><code>data</code> prop</td>
</tr>
<tr>
<td><code>columns: GridColDef[]</code></td>
<td><code>columns: ColumnDef[]</code></td>
</tr>
<tr>
<td><code>headerName</code></td>
<td><code>header</code></td>
</tr>
<tr>
<td><code>valueGetter</code></td>
<td><code>accessorFn</code></td>
</tr>
<tr>
<td><code>valueFormatter</code></td>
<td><code>format</code> / <code>formatter</code></td>
</tr>
<tr>
<td><code>renderCell</code></td>
<td><code>cell</code> snippet</td>
</tr>
<tr>
<td><code>renderEditCell</code></td>
<td><code>editorType</code> or custom editor</td>
</tr>
<tr>
<td><code>sortingMode=&quot;server&quot;</code></td>
<td><code>createServerDataSource</code></td>
</tr>
<tr>
<td><code>paginationModel</code> + <code>onPaginationModelChange</code></td>
<td><code>pageable</code> + <code>createServerDataSource</code></td>
</tr>
<tr>
<td>DataGridPro pivot, Excel export</td>
<td><code>@svgrid/enterprise</code></td>
</tr>
</tbody></table>
<h2 id="cell-renderers-jsx-to-snippets">Cell renderers: JSX to snippets</h2><p>MUI X uses <code>renderCell</code> returning JSX. SvGrid uses Svelte 5 snippets. The mental model is the same; the syntax is different, and frankly cleaner once you have written a few.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import {
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    type ColumnDef,
  } from &#39;@svgrid/grid&#39;

  type Row = { id: number; name: string; status: &#39;active&#39; | &#39;inactive&#39; | &#39;pending&#39;; score: number }

  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })

  const data: Row[] = [
    { id: 1, name: &#39;Alice&#39;, status: &#39;active&#39;, score: 92 },
    { id: 2, name: &#39;Bob&#39;, status: &#39;pending&#39;, score: 41 },
    { id: 3, name: &#39;Carol&#39;, status: &#39;inactive&#39;, score: 67 },
  ]

  const columns: ColumnDef&lt;typeof features, Row&gt;[] = [
    { id: &#39;name&#39;, field: &#39;name&#39;, header: &#39;Name&#39;, width: 180 },
    { id: &#39;status&#39;, field: &#39;status&#39;, header: &#39;Status&#39;, width: 120, cell: statusCell },
    {
      id: &#39;score&#39;,
      field: &#39;score&#39;,
      header: &#39;Score&#39;,
      width: 100,
      type: &#39;number&#39;,
      conditionalFormat: [
        { condition: ({ value }) =&gt; value &lt; 50, style: { color: &#39;#c0392b&#39;, fontWeight: &#39;bold&#39; } },
        { condition: ({ value }) =&gt; value &gt;= 90, style: { color: &#39;#27ae60&#39; } },
      ],
    },
  ]
&lt;/script&gt;

{#snippet statusCell({ value }: { value: string })}
  &lt;span class=&quot;badge badge--{value}&quot;&gt;{value}&lt;/span&gt;
{/snippet}

&lt;SvGrid {data} {columns} {features} sortable filterable rowHeight={36} /&gt;

&lt;style&gt;
  .badge { padding: 2px 8px; border-radius: 4px; font-size: 0.8em; text-transform: capitalize; }
  .badge--active { background: #d4edda; color: #155724; }
  .badge--inactive { background: #f8d7da; color: #721c24; }
  .badge--pending { background: #fff3cd; color: #856404; }
&lt;/style&gt;
</code></pre>
<p>The snippet is defined in the same file and referenced directly in the column def. No callback wrapper, no import from a separate component. Svelte&#39;s snippet scoping means the badge class logic stays where you can see it.</p>
<h2 id="server-side-data-and-the-hooks-that-disappear">Server-side data and the hooks that disappear</h2><p>The React version of a server-side MUI X DataGrid typically involves <code>useState</code> for pagination and sort, <code>useEffect</code> to trigger fetches when those state values change, and a <code>rowCount</code> prop to tell the grid the true total. It ends up being 30-40 lines of wiring before you even write the fetch call.</p>
<p>SvGrid wraps this with <code>createServerDataSource</code>:</p>
<pre><code class="language-ts">import { createServerDataSource } from &#39;@svgrid/grid&#39;

const ds = createServerDataSource({
  fetch: async ({ page, pageSize, sort, filters }) =&gt; {
    const params = new URLSearchParams({
      page: String(page),
      size: String(pageSize),
    })

    if (sort.length) {
      params.set(&#39;sortField&#39;, sort[0].field)
      params.set(&#39;sortDir&#39;, sort[0].direction)
    }

    for (const f of filters) {
      params.set(`filter_${f.field}`, f.value)
    }

    const res = await fetch(`/api/employees?${params}`)
    const json = await res.json()
    return { rows: json.data, total: json.total }
  },
})
</code></pre>
<p>Then the component is:</p>
<pre><code class="language-svelte">&lt;SvGrid data={ds} {columns} {features} sortable filterable pageable rowHeight={36} /&gt;
</code></pre>
<p>The <code>useEffect</code> dependency array, the <code>rowCount</code> state sync, the three separate model-change callbacks - none of that exists. The data source handles it. When sort or filter changes, the data source re-fetches automatically.</p>
<h2 id="editable-cells">Editable cells</h2><p>MUI X editable columns use <code>editable: true</code> on the column def and optionally <code>renderEditCell</code> for custom editors. SvGrid follows the same pattern: <code>editable: true</code> on the column and <code>editorType</code> for overriding the default editor.</p>
<pre><code class="language-ts">const editableColumns: ColumnDef&lt;typeof features, Row&gt;[] = [
  { id: &#39;name&#39;, field: &#39;name&#39;, header: &#39;Name&#39;, width: 180, editable: true },
  {
    id: &#39;salary&#39;,
    field: &#39;salary&#39;,
    header: &#39;Salary&#39;,
    width: 130,
    type: &#39;number&#39;,
    editable: true,
  },
  {
    id: &#39;status&#39;,
    field: &#39;status&#39;,
    header: &#39;Status&#39;,
    width: 140,
    editable: true,
    editorType: &#39;select&#39;,
    editorOptions: { items: [&#39;active&#39;, &#39;inactive&#39;, &#39;pending&#39;] },
  },
]
</code></pre>
<p>MUI X&#39;s <code>processRowUpdate</code> becomes SvGrid&#39;s <code>onCellEdit</code> callback or a reactive <code>$effect</code> on the data. Undo/redo (a MUI Premium feature) ships in SvGrid&#39;s free tier via <code>api.undo()</code> and <code>api.redo()</code>.</p>
<h2 id="what-transfers-and-what-does-not">What transfers and what does not</h2><p>Most MUI X DataGrid functionality has a direct equivalent in SvGrid. The two cases that do not translate cleanly are the built-in tree data view and the column reorder animation. If your app relies heavily on either, factor that into the migration timeline.</p>
<p>Everything else transfers faster than expected. Svelte reactivity eliminates an entire class of state synchronization bugs that come from keeping pagination, sort, and filter state in separate <code>useState</code> calls that have to stay in sync. The server-side data source removes that problem at the source. Cell snippets are genuinely easier to read than <code>renderCell</code> callbacks once you stop expecting JSX syntax.</p>
<p>For apps with custom MUI X slot overrides deep in the theme layer, that part won&#39;t translate directly. You will need to rebuild those pieces with SvGrid&#39;s CSS custom properties (<code>--sg-bg</code>, <code>--sg-accent</code>, <code>--sg-header-bg</code>, <code>--sg-border</code>) and the <code>headerCell</code> snippet API. That is the part I would budget extra time for - not the column defs, not the cell renderers, but the theme customization layer if your design system relies on it heavily.</p>
<p>Pro and Premium features like pivot tables and Excel export map to <a href="https://svgrid.com/pricing/">@svgrid/enterprise</a>. The free community tier covers sorting, filtering, grouping, virtualization, editing, and pagination - which handles the majority of real-world grid screens.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/migrating-from-ag-grid-react-to-svelte/">Migrating from ag-grid-react to a Svelte Stack</a></li>
<li><a href="https://svgrid.com/blog/migrating-from-datatables-to-svelte/">Migrating from DataTables.net to a Svelte Data Grid</a></li>
<li><a href="https://svgrid.com/blog/svelte-data-grid-comparisons/">Svelte Data Grid Comparisons and Alternatives (2026)</a></li>
<li><a href="https://svgrid.com/blog/svelte-headless-table-svelte-5-options/">svelte-headless-table and the Svelte 5 upgrade: your three options</a></li>
<li><a href="https://svgrid.com/blog/open-source-vs-commercial-svelte-grids/">Open-Source vs Commercial Svelte Data Grids</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Paste from Excel into a Svelte Data Grid</title>
      <link>https://svgrid.com/blog/paste-from-excel/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/paste-from-excel/</guid>
      <pubDate>Mon, 24 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Kamelia M</dc:creator>
      <category>paste</category>
      <category>clipboard</category>
      <category>excel</category>
      <category>editing</category>
      <category>recipe</category>
      <description>How to wire up clipboard paste so users can drop a copied Excel or Google Sheets block directly into SvGrid - TSV parsing, type coercion, validation, and row growth all covered.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/paste-from-excel.png" width="1200" height="630" alt="" /></p><p>Excel users have muscle memory: copy a block, tab to the grid, Ctrl+V. If that doesn&#39;t work, your grid is a viewer, not an editor. Getting paste right is what turns SvGrid into a legitimate data-entry surface.</p>
<p><img src="https://svgrid.com/blog-media/spreadsheet.png" alt="A spreadsheet-style SvGrid with a ribbon">
<em>A spreadsheet-style SvGrid accepting pasted tabular data.</em></p>
<h2 id="what-the-clipboard-actually-contains">What the clipboard actually contains</h2><p>When someone copies a block from Excel or Google Sheets and your page receives a paste event, the <code>text/plain</code> MIME type carries a TSV (tab-separated values) string. Rows are separated by <code>\r\n</code> or <code>\n</code>, cells by tabs. That&#39;s all there is to it - no exotic format, no API key, no browser extension.</p>
<pre><code class="language-ts">// paste-utils.ts
export function parseClipboard(text: string): string[][] {
  return text
    .replace(/\r\n/g, &#39;\n&#39;)
    .replace(/\r/g, &#39;\n&#39;)
    .split(&#39;\n&#39;)
    .filter(Boolean)
    .map((line) =&gt; line.split(&#39;\t&#39;))
}

// Type-coerce a raw pasted string to match a column&#39;s data type.
// Without this, pasted &quot;42&quot; stays a string and numeric sorts break.
export function coercePastedValue(
  raw: string,
  type: &#39;number&#39; | &#39;date&#39; | &#39;boolean&#39; | &#39;string&#39; = &#39;string&#39;
): unknown {
  if (type === &#39;number&#39;) {
    const n = Number(raw.replace(/,/g, &#39;&#39;))
    return isNaN(n) ? raw : n
  }
  if (type === &#39;date&#39;) {
    const d = new Date(raw)
    return isNaN(d.getTime()) ? raw : d.toISOString()
  }
  if (type === &#39;boolean&#39;) return raw === &#39;TRUE&#39; || raw === &#39;1&#39; || raw === &#39;true&#39;
  return raw
}
</code></pre>
<p>The comma-stripping in the number branch handles localized Excel exports where <code>1,234</code> means one thousand two hundred thirty-four, not a string with a comma in it.</p>
<h2 id="wiring-the-paste-handler-into-svgrid">Wiring the paste handler into SvGrid</h2><p>The cleanest approach is to listen for <code>paste</code> on the grid container and use the SvGrid API to read the active cell position and commit changes as a transaction. That way undo/redo works out of the box.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import type { SvGridApi, ColumnDef } from &#39;@svgrid/grid&#39;
  import { parseClipboard, coercePastedValue } from &#39;./paste-utils&#39;

  type Row = { id: number; name: string; qty: number; price: number; shipped: string }

  let api: SvGridApi | undefined = $state()

  const columns: ColumnDef&lt;any, Row&gt;[] = [
    { id: &#39;id&#39;,      field: &#39;id&#39;,      header: &#39;ID&#39;,       width: 60,  type: &#39;number&#39; },
    { id: &#39;name&#39;,    field: &#39;name&#39;,    header: &#39;Product&#39;,  width: 200 },
    { id: &#39;qty&#39;,     field: &#39;qty&#39;,     header: &#39;Qty&#39;,      width: 80,  type: &#39;number&#39;, editable: true },
    { id: &#39;price&#39;,   field: &#39;price&#39;,   header: &#39;Price&#39;,    width: 100, type: &#39;number&#39;, editable: true },
    { id: &#39;shipped&#39;, field: &#39;shipped&#39;, header: &#39;Shipped&#39;,  width: 120, type: &#39;date&#39;,   editable: true },
  ]

  // columns in display order, matching what the user sees left-to-right
  const editableFields: { field: keyof Row; type: string }[] = [
    { field: &#39;qty&#39;,     type: &#39;number&#39; },
    { field: &#39;price&#39;,   type: &#39;number&#39; },
    { field: &#39;shipped&#39;, type: &#39;date&#39;   },
  ]

  let rows: Row[] = $state([
    { id: 1, name: &#39;Widget A&#39;, qty: 10, price: 4.99,  shipped: &#39;2026-01-10&#39; },
    { id: 2, name: &#39;Widget B&#39;, qty: 25, price: 12.50, shipped: &#39;2026-01-15&#39; },
    { id: 3, name: &#39;Widget C&#39;, qty: 5,  price: 8.00,  shipped: &#39;2026-01-20&#39; },
  ])

  function handlePaste(e: ClipboardEvent) {
    if (!api) return
    const raw = e.clipboardData?.getData(&#39;text/plain&#39;) ?? &#39;&#39;
    if (!raw) return
    e.preventDefault()

    const matrix = parseClipboard(raw)
    // Active cell tells us where to start writing
    const activeCell = (api as any).getActiveCell?.()
    const startRow = activeCell?.rowIndex ?? 0
    // Only allow pasting into editable columns (qty, price, shipped here)
    const startColIndex = 0

    const updates: Row[] = []
    const invalid: { row: number; col: string; value: string }[] = []

    matrix.forEach((line, r) =&gt; {
      const rowIndex = startRow + r
      const existing = rows[rowIndex]
      if (!existing) return // clip to existing rows (see note below)

      const next = { ...existing }
      line.forEach((val, c) =&gt; {
        const colDef = editableFields[startColIndex + c]
        if (!colDef) return
        const coerced = coercePastedValue(val, colDef.type as any)
        // Simple guard: reject blanks for number columns
        if (colDef.type === &#39;number&#39; &amp;&amp; typeof coerced === &#39;string&#39;) {
          invalid.push({ row: rowIndex, col: colDef.field, value: val })
          return
        }
        ;(next as any)[colDef.field] = coerced
      })
      updates.push(next)
    })

    if (invalid.length) {
      console.warn(`Paste: ${invalid.length} cell(s) had invalid values and were skipped`, invalid)
    }

    api.applyTransaction({ update: updates })
  }
&lt;/script&gt;

&lt;div onpaste={handlePaste} role=&quot;grid&quot; tabindex=&quot;-1&quot;&gt;
  &lt;SvGrid
    data={rows}
    {columns}
    editable
    enableCellSelection={true}
    onApiReady={(a) =&gt; { api = a }}
  /&gt;
&lt;/div&gt;
</code></pre>
<p>The outer <code>div</code> captures paste before it reaches any focused input, so the handler fires whether the user is focused on a cell or just on the grid container. The <code>role=&quot;grid&quot;</code> and <code>tabindex</code> keep keyboard focus sensible.</p>
<h2 id="growing-the-grid-when-the-paste-block-overflows">Growing the grid when the paste block overflows</h2><p>Clipping silently at the last row frustrates users who are pasting into a blank region. A better policy is to append new rows for any overflow. The shape of the new rows needs sensible defaults - for our example, an auto-incremented ID and empty strings elsewhere.</p>
<pre><code class="language-ts">function applyPasteWithGrowth(
  rows: Row[],
  matrix: string[][],
  startRow: number,
  editableFields: { field: keyof Row; type: string }[]
): { updated: Row[]; appended: Row[] } {
  const updated: Row[] = []
  const appended: Row[] = []
  const maxId = Math.max(...rows.map((r) =&gt; r.id), 0)

  matrix.forEach((line, r) =&gt; {
    const rowIndex = startRow + r
    const isNew = rowIndex &gt;= rows.length
    const base: Row = isNew
      ? { id: maxId + appended.length + 1, name: &#39;&#39;, qty: 0, price: 0, shipped: &#39;&#39; }
      : { ...rows[rowIndex] }

    line.forEach((val, c) =&gt; {
      const colDef = editableFields[c]
      if (!colDef) return
      ;(base as any)[colDef.field] = coercePastedValue(val, colDef.type as any)
    })

    if (isNew) appended.push(base)
    else updated.push(base)
  })

  return { updated, appended }
}

// Then in the paste handler:
// const { updated, appended } = applyPasteWithGrowth(rows, matrix, startRow, editableFields)
// api.applyTransaction({ update: updated, add: appended })
</code></pre>
<p>Whether you clip or grow depends on your use case. A product catalog where rows have meaning (fixed set of SKUs) should clip. A data-import flow where users are filling an empty table should grow.</p>
<h2 id="validation-that-surfaces-errors-without-blocking-work">Validation that surfaces errors without blocking work</h2><p>Silently skipping bad cells is annoying. Blocking the entire paste because one cell has a bad value is worse. The right middle ground: apply everything that&#39;s valid, collect the invalid cells, and surface a non-modal summary.</p>
<p>The <code>invalid</code> array from the handler above is the hook for this. Feed it into a Svelte snippet that renders a dismissible banner above the grid:</p>
<pre><code class="language-svelte">{#if pasteErrors.length}
  &lt;div class=&quot;paste-errors&quot; role=&quot;alert&quot;&gt;
    {pasteErrors.length} cell{pasteErrors.length === 1 ? &#39;&#39; : &#39;s&#39;} skipped:
    {pasteErrors.map((e) =&gt; `${e.col} row ${e.row + 1} (&quot;${e.value}&quot;)`).join(&#39;, &#39;)}
    &lt;button onclick={() =&gt; (pasteErrors = [])}&gt;Dismiss&lt;/button&gt;
  &lt;/div&gt;
{/if}
</code></pre>
<p>Keep <code>pasteErrors</code> as a <code>$state([])</code> variable, set it after every paste, and clear it on dismiss or on the next paste. Users can see what was skipped, fix the source data in Excel, and repaste just the bad rows.</p>
<h2 id="a-note-on-column-ordering">A note on column ordering</h2><p>The handler above assumes you know which columns are editable and in what order the user sees them. That assumption breaks when column reordering is enabled. In that case, get the visible column order from the API before mapping:</p>
<pre><code class="language-ts">// inside handlePaste, after api is confirmed not-undefined
const visibleCols = api.getState().columnOrder ?? columns.map((c) =&gt; c.id)
// then map matrix columns against visibleCols starting at the active column
</code></pre>
<p>If you pin non-editable columns on the left (ID, name) and all editable columns in the middle, users can copy a block from Excel that matches exactly what they see - which is the most intuitive paste target anyway.</p>
<p>Pasting from Excel is a small feature with outsized impact on power users. Once it works, you&#39;ll hear about it every time someone evaluates your app.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/fill-handle-drag-to-fill/">A Fill Handle (Drag to Fill) in SvGrid</a></li>
<li><a href="https://svgrid.com/blog/editable-select-dropdown-cell/">An Editable Select / Dropdown Cell in SvGrid</a></li>
<li><a href="https://svgrid.com/blog/date-picker-cell-editor/">A Date-Picker Cell Editor in SvGrid</a></li>
<li><a href="https://svgrid.com/blog/autocomplete-cell-editor/">An Autocomplete Cell Editor in SvGrid</a></li>
<li><a href="https://svgrid.com/blog/copy-cell-range-to-clipboard/">Copy a Cell Range to the Clipboard in SvGrid</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Building an Order Management Dashboard in Svelte</title>
      <link>https://svgrid.com/blog/order-management-dashboard/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/order-management-dashboard/</guid>
      <pubDate>Sun, 23 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Kamelia M</dc:creator>
      <category>orders</category>
      <category>ecommerce</category>
      <category>master detail</category>
      <category>use case</category>
      <category>svelte data grid</category>
      <description>How to wire up an order management grid with master-detail line items, status workflows, server-side data, and bulk fulfillment actions using SvGrid.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/order-management-dashboard.png" width="1200" height="630" alt="" /></p><p>Most e-commerce operations teams spend more time in their order grid than anywhere else. They need to read status at a glance, drill into line items, bulk-mark shipments, and occasionally pull a report. That is a lot to pack into one view - but it maps almost directly to SvGrid&#39;s feature set.</p>
<p>This post walks through the full order dashboard: server-side data with filters and pagination, a status workflow in editable cells, master-detail line items loaded on demand, and bulk fulfillment actions. Each section shows real code you can adapt.</p>
<p><img src="https://svgrid.com/blog-media/realtime-orders.png" alt="A live order-management grid in SvGrid">
<em>A live order-management grid in SvGrid.</em></p>
<h2 id="columns-that-communicate-order-state">Columns that communicate order state</h2><p>The column definition is where most of the UI work happens. A good order grid shows what operators actually need: order ID, customer name, total value, current status, and a created-at timestamp. Status and totals get special treatment.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import type { ColumnDef, SvGridApi, SvGridOptions, TableFeatures } from &#39;@svgrid/grid&#39;
  import {
    tableFeatures, rowSortingFeature, columnFilteringFeature,
    rowSelectionFeature, rowPaginationFeature, rowExpandingFeature,
    createServerDataSource
  } from &#39;@svgrid/grid&#39;

  type Order = {
    id: string
    customer: string
    total: number
    status: &#39;pending&#39; | &#39;paid&#39; | &#39;fulfilled&#39; | &#39;shipped&#39;
    createdAt: string
  }

  const features = tableFeatures({
    rowSortingFeature,
    columnFilteringFeature,
    rowSelectionFeature,
    rowPaginationFeature,
    rowExpandingFeature,
  })

  const columns: ColumnDef&lt;typeof features, Order&gt;[] = [
    { id: &#39;id&#39;, field: &#39;id&#39;, header: &#39;Order&#39;, width: 110, pinned: &#39;left&#39; },
    { id: &#39;customer&#39;, field: &#39;customer&#39;, header: &#39;Customer&#39;, width: 200 },
    {
      id: &#39;total&#39;,
      field: &#39;total&#39;,
      header: &#39;Total&#39;,
      type: &#39;number&#39;,
      width: 110,
      cell: totalCell,
    },
    {
      id: &#39;status&#39;,
      field: &#39;status&#39;,
      header: &#39;Status&#39;,
      width: 130,
      editable: true,
      cell: statusCell,
      conditionalFormat: [
        { condition: ({ value }) =&gt; value === &#39;pending&#39;, style: { color: &#39;#b45309&#39; } },
        { condition: ({ value }) =&gt; value === &#39;paid&#39;, style: { color: &#39;#1d4ed8&#39; } },
        { condition: ({ value }) =&gt; value === &#39;fulfilled&#39;, style: { color: &#39;#15803d&#39; } },
        { condition: ({ value }) =&gt; value === &#39;shipped&#39;, style: { color: &#39;#6b7280&#39; } },
      ],
    },
    { id: &#39;createdAt&#39;, field: &#39;createdAt&#39;, header: &#39;Created&#39;, width: 150, type: &#39;date&#39; },
    { id: &#39;actions&#39;, header: &#39;&#39;, width: 60, cell: actionsCell, pinned: &#39;right&#39; },
  ]

  let api: SvGridApi | undefined

  {#snippet totalCell({ value }: { value: number })}
    &lt;span class=&quot;font-mono&quot;&gt;${value.toFixed(2)}&lt;/span&gt;
  {/snippet}

  {#snippet statusCell({ value }: { value: Order[&#39;status&#39;] })}
    &lt;span class=&quot;status-badge status-{value}&quot;&gt;{value}&lt;/span&gt;
  {/snippet}

  {#snippet actionsCell({ row }: { row: Order })}
    &lt;button onclick={() =&gt; openDetail(row)}&gt;...&lt;/button&gt;
  {/snippet}
&lt;/script&gt;
</code></pre>
<p>The conditional formatting on status is worth calling out: it runs per-cell with zero extra render cost. Operators see red/amber/green immediately, without needing to parse labels.</p>
<h2 id="server-side-data-with-filters-operators-actually-use">Server-side data with filters operators actually use</h2><p>Order tables grow fast. Even a modest shop accumulates tens of thousands of orders within a year. Server-side filtering is not optional here - it is the only approach that stays responsive.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import { createServerDataSource } from &#39;@svgrid/grid&#39;

  const ds = createServerDataSource({
    fetch: async ({ page, pageSize, sort, filters }) =&gt; {
      const params = new URLSearchParams({
        page: String(page),
        size: String(pageSize),
      })

      if (sort.length &gt; 0) {
        params.set(&#39;sortBy&#39;, sort[0].id)
        params.set(&#39;sortDir&#39;, sort[0].desc ? &#39;desc&#39; : &#39;asc&#39;)
      }

      for (const f of filters) {
        if (f.id === &#39;status&#39; &amp;&amp; f.value) params.set(&#39;status&#39;, String(f.value))
        if (f.id === &#39;createdAt&#39; &amp;&amp; f.value) params.set(&#39;from&#39;, String(f.value))
        if (f.id === &#39;customer&#39; &amp;&amp; f.value) params.set(&#39;customer&#39;, String(f.value))
      }

      const res = await fetch(`/api/orders?${params}`)
      const json = await res.json()
      return { rows: json.data, total: json.total }
    },
  })
&lt;/script&gt;

&lt;SvGrid
  data={ds}
  {columns}
  {features}
  pageable
  filterable
  sortable
  showFilterRow={true}
  rowHeight={36}
  onApiReady={(a) =&gt; { api = a }}
/&gt;
</code></pre>
<p>The filter row gives operators direct column-level filtering. Status gets a dropdown, date fields get range inputs, and customer gets a text search. All of it goes back to the server on change - the grid handles the debounce.</p>
<h2 id="line-items-as-expandable-detail">Line items as expandable detail</h2><p>The master-detail pattern fits orders perfectly. The order is the master row; its line items are the detail. Load them lazily so a 5000-row grid does not pre-fetch 50,000 line item records.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  type LineItem = {
    sku: string
    name: string
    qty: number
    unitPrice: number
    subtotal: number
  }

  const lineItemColumns: ColumnDef&lt;typeof features, LineItem&gt;[] = [
    { id: &#39;sku&#39;, field: &#39;sku&#39;, header: &#39;SKU&#39;, width: 100 },
    { id: &#39;name&#39;, field: &#39;name&#39;, header: &#39;Product&#39;, width: 240 },
    { id: &#39;qty&#39;, field: &#39;qty&#39;, header: &#39;Qty&#39;, type: &#39;number&#39;, width: 70 },
    { id: &#39;unitPrice&#39;, field: &#39;unitPrice&#39;, header: &#39;Unit Price&#39;, type: &#39;number&#39;, width: 110,
      cell: priceCell },
    { id: &#39;subtotal&#39;, field: &#39;subtotal&#39;, header: &#39;Subtotal&#39;, type: &#39;number&#39;, width: 110,
      cell: priceCell },
  ]

  {#snippet priceCell({ value }: { value: number })}
    &lt;span class=&quot;font-mono&quot;&gt;${value.toFixed(2)}&lt;/span&gt;
  {/snippet}

  {#snippet orderDetail({ row }: { row: Order })}
    {#await fetch(`/api/orders/${row.id}/items`).then(r =&gt; r.json()) then items}
      &lt;div class=&quot;detail-panel&quot;&gt;
        &lt;SvGrid
          data={items}
          columns={lineItemColumns}
          {features}
          rowHeight={30}
        /&gt;
      &lt;/div&gt;
    {:catch}
      &lt;p class=&quot;error&quot;&gt;Failed to load line items.&lt;/p&gt;
    {/await}
  {/snippet}
&lt;/script&gt;

&lt;SvGrid
  data={ds}
  {columns}
  {features}
  detail={orderDetail}
  pageable
  filterable
  sortable
  onApiReady={(a) =&gt; { api = a }}
/&gt;
</code></pre>
<p>One thing to get right: the <code>{:catch}</code> branch. Network requests fail, especially in operations tools that run all day. A blank or stuck expand state looks like a bug; an error message looks like the system is working correctly.</p>
<h2 id="bulk-fulfillment-via-row-selection">Bulk fulfillment via row selection</h2><p>Selection plus a toolbar is where the grid stops being a read-only table and becomes a tool. The selection API is straightforward, but there is one important detail: with server-side data, &quot;select all&quot; means select all matching records on the server, not just the current page.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  async function fulfillSelected() {
    if (!api) return
    const selected = api.getSelectedRows()
    const ids = selected.map((r) =&gt; r.id)

    await fetch(&#39;/api/orders/fulfill&#39;, {
      method: &#39;POST&#39;,
      headers: { &#39;Content-Type&#39;: &#39;application/json&#39; },
      body: JSON.stringify({ orderIds: ids }),
    })

    // Reflect the change locally without a full refetch
    api.applyTransaction({
      update: selected.map((r) =&gt; ({ ...r, status: &#39;fulfilled&#39; })),
    })

    api.clearRowSelection()
  }

  function exportSelected() {
    if (!api) return
    const rows = api.getSelectedRows()
    const csv = [
      [&#39;Order&#39;, &#39;Customer&#39;, &#39;Total&#39;, &#39;Status&#39;],
      ...rows.map((r) =&gt; [r.id, r.customer, r.total, r.status]),
    ]
      .map((row) =&gt; row.join(&#39;,&#39;))
      .join(&#39;\n&#39;)

    const blob = new Blob([csv], { type: &#39;text/csv&#39; })
    const url = URL.createObjectURL(blob)
    const a = document.createElement(&#39;a&#39;)
    a.href = url
    a.download = &#39;orders.csv&#39;
    a.click()
    URL.revokeObjectURL(url)
  }
&lt;/script&gt;

{#if api &amp;&amp; api.getSelectedRows().length &gt; 0}
  &lt;div class=&quot;bulk-toolbar&quot;&gt;
    &lt;span&gt;{api.getSelectedRows().length} orders selected&lt;/span&gt;
    &lt;button onclick={fulfillSelected}&gt;Mark Fulfilled&lt;/button&gt;
    &lt;button onclick={exportSelected}&gt;Export CSV&lt;/button&gt;
    &lt;button onclick={() =&gt; api?.clearRowSelection()}&gt;Clear&lt;/button&gt;
  &lt;/div&gt;
{/if}
</code></pre>
<p>The <code>applyTransaction</code> call is the right move after a bulk action. It updates the local state optimistically without triggering a full server round-trip. If the server call fails, you can reverse it - but in most fulfillment workflows, success is the default.</p>
<h2 id="status-advancement-and-edge-cases">Status advancement and edge cases</h2><p>Editable status cells need a bit of guard logic. An operator should not be able to move an order from &quot;shipped&quot; back to &quot;pending&quot;. The cell editor can enforce this:</p>
<pre><code class="language-svelte">{#snippet editableStatus({ value, row, stopEditing })}
  {@const allowed = nextStatuses(value)}
  &lt;select
    value={value}
    onchange={(e) =&gt; {
      const next = e.currentTarget.value as Order[&#39;status&#39;]
      if (allowed.includes(next)) {
        updateOrderStatus(row.id, next)
      }
      stopEditing()
    }}
  &gt;
    {#each allowed as s}
      &lt;option value={s}&gt;{s}&lt;/option&gt;
    {/each}
  &lt;/select&gt;
{/snippet}

function nextStatuses(current: Order[&#39;status&#39;]): Order[&#39;status&#39;][] {
  const transitions: Record&lt;Order[&#39;status&#39;], Order[&#39;status&#39;][]&gt; = {
    pending: [&#39;paid&#39;],
    paid: [&#39;fulfilled&#39;],
    fulfilled: [&#39;shipped&#39;],
    shipped: [],
  }
  return [current, ...transitions[current]]
}
</code></pre>
<p>Showing only valid transitions in the dropdown prevents data integrity errors without needing server-side validation to bubble back a rejection. The operator never sees an option that would fail.</p>
<h2 id="what-this-approach-handles-well">What this approach handles well</h2><p>The combination of server-side data, master-detail rows, and bulk selection covers the core of operations work. The grid itself does the heavy lifting: virtualization keeps large lists smooth, the filter row reduces the need for a separate filter panel, and selection state persists across page changes.</p>
<p>Where this needs extension: if you need real-time updates (new orders coming in, status changed by a different operator), wire a WebSocket or SSE feed into <code>api.applyTransaction</code>. The grid handles incremental updates cleanly - you push changes in, it re-renders only the affected rows.</p>
<p>Audit trails are the other common addition. Most order systems need a log of who changed a status and when. That is backend work, but hooking into the grid&#39;s cell edit callback gives you the right event to emit.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/ecommerce-product-catalog/">Building an E-commerce Product Catalog Grid in Svelte</a></li>
<li><a href="https://svgrid.com/blog/real-time-trading-grid/">Building a Real-Time Trading Grid in Svelte</a></li>
<li><a href="https://svgrid.com/blog/project-task-board-grid/">Building a Project / Task Board with a Svelte Data Grid</a></li>
<li><a href="https://svgrid.com/blog/logistics-fleet-tracking-grid/">Building a Logistics / Fleet Tracking Grid in Svelte</a></li>
<li><a href="https://svgrid.com/blog/log-viewer-large-logs/">Building a Log Viewer for Large Logs in Svelte</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Optimistic UI Explained</title>
      <link>https://svgrid.com/blog/optimistic-ui-explained/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/optimistic-ui-explained/</guid>
      <pubDate>Sat, 22 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Victor Vidolov</dc:creator>
      <category>optimistic ui</category>
      <category>concepts</category>
      <category>ux</category>
      <category>data grid</category>
      <description>What optimistic UI means, why it makes apps feel instant, and how to implement it safely with rollback - using a data grid as the real-world example.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/optimistic-ui-explained.png" width="1200" height="630" alt="" /></p><p>Most save operations succeed. That one fact is the entire foundation of optimistic UI, and if you internalize it, the pattern stops feeling like a trick and starts feeling obvious.</p>
<p><img src="https://svgrid.com/blog-media/optimistic-updates.png" alt="Optimistic edits in SvGrid">
<em>Optimistic UI in a SvGrid grid: apply now, confirm in the background.</em></p>
<h2 id="the-pessimistic-default-and-why-it-costs-you">The pessimistic default and why it costs you</h2><p>The conventional request-then-update loop looks safe because nothing on screen is wrong. Click save, spinner appears, response arrives, screen updates. But that 200-400ms gap is not neutral - it makes the app feel sluggish, and users feel the wait even when they cannot put a number on it.</p>
<p>The deeper problem is that you are making users wait for a confirmation they will get 99% of the time anyway. You are optimizing for the rare failure case at the expense of every single success.</p>
<p>Optimistic UI flips the assumption. Update the screen first, send the request in parallel, and only intervene if the request fails. The failure path becomes the exception branch, not the main branch.</p>
<h2 id="three-steps-one-trycatch">Three steps, one try/catch</h2><p>The core pattern is almost embarrassingly short. A grid cell edit is the canonical example because it has a clear old value, a new value, and a specific row to revert:</p>
<pre><code class="language-svelte">&lt;script&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import { tableFeatures, rowSortingFeature } from &#39;@svgrid/grid&#39;

  let rows = $state([
    { id: 1, name: &#39;Acme Corp&#39;, revenue: 142000, status: &#39;active&#39; },
    { id: 2, name: &#39;Globex&#39;,    revenue: 98000,  status: &#39;active&#39; },
    { id: 3, name: &#39;Initech&#39;,   revenue: 61000,  status: &#39;inactive&#39; },
  ])

  let gridApi = $state(null)
  let errorRowId = $state(null)

  const features = tableFeatures({ rowSortingFeature })

  const columns = [
    { id: &#39;name&#39;,    field: &#39;name&#39;,    header: &#39;Company&#39;,  width: 200, editable: true },
    { id: &#39;revenue&#39;, field: &#39;revenue&#39;, header: &#39;Revenue&#39;,  width: 130, type: &#39;number&#39;, editable: true },
    { id: &#39;status&#39;,  field: &#39;status&#39;,  header: &#39;Status&#39;,   width: 120, editable: true },
  ]

  async function handleCellEdit(event) {
    const { rowIndex, row, columnId, newValue, oldValue } = event

    // Step 1: apply immediately - the user sees the change right now
    rows[rowIndex] = { ...row, [columnId]: newValue }
    errorRowId = null

    try {
      // Step 2: confirm with the server in the background
      await fetch(`/api/companies/${row.id}`, {
        method: &#39;PATCH&#39;,
        headers: { &#39;Content-Type&#39;: &#39;application/json&#39; },
        body: JSON.stringify({ [columnId]: newValue }),
      })
    } catch {
      // Step 3: roll back only on failure
      rows[rowIndex] = { ...row, [columnId]: oldValue }
      errorRowId = row.id
    }
  }
&lt;/script&gt;

&lt;SvGrid
  data={rows}
  {columns}
  {features}
  editable
  onCellEditCommit={handleCellEdit}
  onApiReady={(api) =&gt; { gridApi = api }}
/&gt;
</code></pre>
<p>The user sees the change immediately. The network round trip is invisible. A failure is the only thing that interrupts the experience - and even then, you have the old value to restore.</p>
<h2 id="making-failure-visible-without-alarming-users">Making failure visible without alarming users</h2><p>Silent rollbacks are a trust problem. A user edits a cell, nothing seems to go wrong, and ten minutes later they notice their change is gone. That is worse than a visible error.</p>
<p>The <code>errorRowId</code> in the snippet above gives you a hook for visual feedback. You can use it to apply conditional formatting at the row level or trigger a notification:</p>
<pre><code class="language-svelte">&lt;script&gt;
  import SvGrid, { resolveCellFormat } from &#39;@svgrid/grid&#39;

  // Columns with row-level error highlighting
  const columns = [
    {
      id: &#39;name&#39;,
      field: &#39;name&#39;,
      header: &#39;Company&#39;,
      width: 200,
      editable: true,
      conditionalFormat: [
        {
          condition: ({ row }) =&gt; row.id === errorRowId,
          style: { backgroundColor: &#39;#fff0f0&#39;, color: &#39;#c0392b&#39; },
        },
      ],
    },
    // ... other columns
  ]

  let toastMessage = $state(&#39;&#39;)

  async function handleCellEdit(event) {
    const { rowIndex, row, columnId, newValue, oldValue } = event

    rows[rowIndex] = { ...row, [columnId]: newValue }
    errorRowId = null

    try {
      await fetch(`/api/companies/${row.id}`, {
        method: &#39;PATCH&#39;,
        headers: { &#39;Content-Type&#39;: &#39;application/json&#39; },
        body: JSON.stringify({ [columnId]: newValue }),
      })
    } catch (err) {
      rows[rowIndex] = { ...row, [columnId]: oldValue }
      errorRowId = row.id
      toastMessage = `Could not save change to &quot;${row.name}&quot;. Reverted.`

      // Clear toast after 4 seconds
      setTimeout(() =&gt; { toastMessage = &#39;&#39; }, 4000)
    }
  }
&lt;/script&gt;

{#if toastMessage}
  &lt;div class=&quot;toast error&quot;&gt;{toastMessage}&lt;/div&gt;
{/if}

&lt;SvGrid
  data={rows}
  {columns}
  editable
  onCellEditCommit={handleCellEdit}
/&gt;
</code></pre>
<p>The red background and the toast together do two things: they tell the user something went wrong, and they confirm the original value was restored. Without both signals, users guess.</p>
<h2 id="the-reconciliation-problem">The reconciliation problem</h2><p>There is one edge case that bites teams who do not think about it: what happens when a background data refresh arrives while an optimistic change is pending?</p>
<p>If your grid polls the server every 30 seconds and a response arrives 150ms after the user made an edit, a naive <code>rows = serverData</code> will silently overwrite their pending change - and they will have no idea until the next refresh either confirms or denies it.</p>
<p>The fix is to track which rows have in-flight edits and merge around them:</p>
<pre><code class="language-ts">// Track pending edits by row id
const pendingEdits = new Map&lt;number, Record&lt;string, unknown&gt;&gt;()

async function handleCellEdit(event) {
  const { rowIndex, row, columnId, newValue, oldValue } = event

  // Mark this row as having a pending edit
  pendingEdits.set(row.id, { ...(pendingEdits.get(row.id) ?? {}), [columnId]: newValue })

  rows[rowIndex] = { ...row, [columnId]: newValue }

  try {
    await fetch(`/api/companies/${row.id}`, {
      method: &#39;PATCH&#39;,
      headers: { &#39;Content-Type&#39;: &#39;application/json&#39; },
      body: JSON.stringify({ [columnId]: newValue }),
    })
    // Only clear the specific field once confirmed
    const pending = pendingEdits.get(row.id)
    if (pending) {
      delete pending[columnId]
      if (Object.keys(pending).length === 0) pendingEdits.delete(row.id)
    }
  } catch {
    rows[rowIndex] = { ...row, [columnId]: oldValue }
    pendingEdits.delete(row.id)
  }
}

// When server data arrives (polling, SSE, etc.)
function applyServerData(serverRows: typeof rows) {
  rows = serverRows.map((serverRow) =&gt; {
    const pending = pendingEdits.get(serverRow.id)
    // Merge pending edits on top of fresh server data
    return pending ? { ...serverRow, ...pending } : serverRow
  })
}
</code></pre>
<p>This is the part most tutorials skip, and it is where optimistic UI breaks down in production if you are not careful.</p>
<h2 id="when-optimism-is-the-wrong-call">When optimism is the wrong call</h2><p>Optimistic UI suits high-success, low-stakes interactions: cell edits, status toggles, tag assignments, row reordering. The success rate is near 100% and the worst case is a visible revert.</p>
<p>Three situations where you should not be optimistic:</p>
<p><strong>Payment and billing operations.</strong> A user who sees a &quot;Payment successful&quot; flash before the charge clears will be justifiably angry if the charge fails. Show a real pending state and wait.</p>
<p><strong>Irreversible deletes.</strong> If there is no undo on the server side, do not pretend the delete happened. A rollback gives back data that is already gone.</p>
<p><strong>High-concurrency records.</strong> If ten users can edit the same row and your API does not support optimistic locking (ETags, version fields), you will silently drop updates. Either add conflict detection or use a pessimistic lock.</p>
<p>The tell is usually the failure rate combined with the cost of a wrong impression. At 0.5% failure and easy rollback, go optimistic. At 5% failure on a payment, do not.</p>
<h2 id="the-performance-argument-is-secondary">The performance argument is secondary</h2><p>Teams often frame optimistic UI as a performance technique, and it does make apps feel faster. But the real value is that it shifts the mental model: the network is a background concern, not a blocker. Users stay in flow. The UI responds to them, not to the server.</p>
<p>Once you start building that way, pessimistic flows start feeling unnecessary every time you add them. Most of the time, they are.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/debounce-vs-throttle/">Debounce vs Throttle (for Grids and Beyond)</a></li>
<li><a href="https://svgrid.com/blog/controlled-vs-uncontrolled-grid-state/">Controlled vs Uncontrolled Grid State</a></li>
<li><a href="https://svgrid.com/blog/column-virtualization-explained/">Column Virtualization Explained</a></li>
<li><a href="https://svgrid.com/blog/aggregation-functions-explained/">Aggregation Functions Explained (Sum, Avg, Min, Max, Count)</a></li>
<li><a href="https://svgrid.com/blog/data-grid-vs-data-table/">Data Grid vs Data Table - What&#39;s the Difference?</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Open-Source vs Commercial Svelte Data Grids</title>
      <link>https://svgrid.com/blog/open-source-vs-commercial-svelte-grids/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/open-source-vs-commercial-svelte-grids/</guid>
      <pubDate>Fri, 21 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Boyko Markov</dc:creator>
      <category>comparison</category>
      <category>licensing</category>
      <category>open source</category>
      <category>commercial</category>
      <category>svelte data grid</category>
      <description>A practical breakdown of licensing trade-offs for Svelte data grids - what open-source actually costs you, what commercial actually buys you, and how to think about total cost of ownership before you commit.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/open-source-vs-commercial-svelte-grids.png" width="1200" height="630" alt="" /></p><p>Most teams pick a grid based on a GitHub star count or a quick feature table, then spend the next six months fighting the decision. The licensing model matters less than people think at first, and more than they realize once they&#39;re shipping.</p>
<p><img src="https://svgrid.com/blog-media/quick-start.png" alt="Open-Source vs Commercial Svelte Data Grids"></p>
<p>Here&#39;s the honest version.</p>
<h2 id="what-free-actually-costs-in-practice">What &quot;free&quot; actually costs in practice</h2><p>MIT-licensed means no license fee. It does not mean no cost.</p>
<p>The typical open-source Svelte grid in 2025-2026 gives you rendering, sorting, and basic filtering. Beyond that, you&#39;re on your own. Virtualization that handles 100k rows? Maybe. Cell editing with undo/redo? Probably not. Server-side data with pagination, filtering, and sorting wired together? Build it yourself.</p>
<p>A mid-complexity data grid feature - say, server-side sorting plus a custom filter row plus sticky headers - takes one to three days to build and wire correctly. Do that four times across a project and you&#39;ve spent two weeks on grid plumbing that a paid library would have solved on day one. At $150/hr, that&#39;s $12,000 before you&#39;ve shipped anything.</p>
<p>Open-source is cheapest when the feature set fits your need exactly. It stops being cheap the moment you start building on top of it.</p>
<h2 id="what-youre-actually-buying-with-commercial-licensing">What you&#39;re actually buying with commercial licensing</h2><p>A commercial grid is not a pile of features. It&#39;s risk transfer.</p>
<p>When something breaks in production on a Friday and you have a $1M demo on Monday, you want a support ticket going to a team who maintains that codebase for a living. You want a fix or a workaround, not a GitHub issue that might get picked up in two weeks.</p>
<p>You&#39;re also buying roadmap continuity. An open-source grid maintained by one person is one job change away from becoming unmaintained. Commercial grids have business incentives to stay maintained. That&#39;s not a guarantee, but it&#39;s a meaningful signal.</p>
<p>The real risk with commercial, beyond price, is lock-in. When the license restricts deployment environments, or seats are counted per-developer, or you need a new contract for each client project, the overhead becomes its own cost.</p>
<h2 id="the-feature-gap-in-svelte-specifically">The feature gap in Svelte specifically</h2><p>Svelte&#39;s ecosystem is younger than React&#39;s. The result is that even decent Svelte grids tend to be missing things you&#39;d take for granted in an ag-Grid or TanStack Table setup.</p>
<p>Things that look standard but frequently aren&#39;t in free Svelte grids:</p>
<ul>
<li>Row virtualization that handles pinned columns correctly</li>
<li>Column grouping with nested headers</li>
<li>Cell-level selection (not just row selection)</li>
<li>Editable cells with type-aware inputs</li>
<li>Export to Excel with formatting</li>
<li>Pivot tables</li>
</ul>
<p>Here&#39;s what a typical server-side + virtualized setup looks like in SvGrid&#39;s free tier, which covers most of these:</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import { createServerDataSource, type ColumnDef } from &#39;@svgrid/grid&#39;

  const ds = createServerDataSource({
    fetch: async ({ page, pageSize, sort, filters }) =&gt; {
      const params = new URLSearchParams({
        page: String(page),
        size: String(pageSize),
        sort: JSON.stringify(sort),
        filters: JSON.stringify(filters),
      })
      const res = await fetch(`/api/inventory?${params}`)
      const json = await res.json()
      return { rows: json.data, total: json.total }
    }
  })

  const columns: ColumnDef[] = [
    { id: &#39;sku&#39;,      field: &#39;sku&#39;,      header: &#39;SKU&#39;,      width: 120, pinned: &#39;left&#39; },
    { id: &#39;product&#39;,  field: &#39;product&#39;,  header: &#39;Product&#39;,  width: 240 },
    { id: &#39;stock&#39;,    field: &#39;stock&#39;,    header: &#39;Stock&#39;,    width: 100, type: &#39;number&#39; },
    { id: &#39;price&#39;,    field: &#39;price&#39;,    header: &#39;Price&#39;,    width: 100, type: &#39;number&#39;, editable: true },
    { id: &#39;category&#39;, field: &#39;category&#39;, header: &#39;Category&#39;, width: 160 },
  ]
&lt;/script&gt;

&lt;SvGrid
  data={ds}
  {columns}
  sortable
  filterable
  pageable
  virtualization={true}
  showFilterRow={true}
  enableCellSelection={true}
/&gt;
</code></pre>
<p>That runs entirely on the free <code>@svgrid/grid</code> package. The server handles sorting and filtering; the grid handles pagination, virtual scrolling, and the filter row UI.</p>
<h2 id="where-the-paid-tier-changes-the-picture">Where the paid tier changes the picture</h2><p>The enterprise package (<code>@svgrid/enterprise</code>) adds the things you&#39;d otherwise build yourself: pivot tables, Excel export with multi-sheet support, import, AI assistant, and priority support.</p>
<p>Pivot is the clearest example of why commercial tiers exist. Building a pivot engine from scratch is weeks of work. Getting it right under virtualization, with nested row groups and column totals, is a distinct and hard problem. Paying for it is almost always cheaper than building it.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import { createPivotModel } from &#39;@svgrid/enterprise&#39;
  import { type ColumnDef } from &#39;@svgrid/grid&#39;

  // Raw sales data - pivot handles aggregation
  const data = $state(salesRows)

  const pivot = createPivotModel({
    rows: [&#39;region&#39;, &#39;salesRep&#39;],
    columns: [&#39;quarter&#39;],
    values: [
      { field: &#39;revenue&#39;, aggFunc: &#39;sum&#39;, header: &#39;Revenue&#39; },
      { field: &#39;units&#39;,   aggFunc: &#39;sum&#39;, header: &#39;Units&#39; },
    ],
  })

  const columns: ColumnDef[] = [
    { id: &#39;region&#39;,   field: &#39;region&#39;,   header: &#39;Region&#39; },
    { id: &#39;salesRep&#39;, field: &#39;salesRep&#39;, header: &#39;Rep&#39; },
  ]

  let api: any
&lt;/script&gt;

&lt;SvGrid
  {data}
  {columns}
  pivotModel={pivot}
  groupable
  onApiReady={(a) =&gt; { api = a }}
/&gt;
</code></pre>
<p>That code would be a multi-week project to replicate from scratch. The license cost is a rounding error by comparison.</p>
<h2 id="the-open-core-middle-ground">The open-core middle ground</h2><p>The cleanest answer to &quot;open-source or commercial&quot; is often neither, in the pure form.</p>
<p>Open-core grids ship a real, capable free tier under a permissive license and charge for the tier above it. You evaluate and prototype for free. You pay only when you need the advanced features. You don&#39;t pay per developer for the core package.</p>
<p>SvGrid is built this way. <code>@svgrid/grid</code> is MIT-licensed and covers a wide range of production use cases - row and column virtualization, grouping, server-side data, editing, undo/redo, named views, conditional formatting, cell selection, and more. Enterprise adds the commercial-grade extras.</p>
<p>Here&#39;s a realistic view of how the imperative API works for managing grid state across both tiers:</p>
<pre><code class="language-ts">import SvGrid from &#39;@svgrid/grid&#39;
import { createNamedViews, localStorageViews } from &#39;@svgrid/grid&#39;

// Named views: save/restore column layout, filters, sort state
const views = createNamedViews(localStorageViews(&#39;my-grid&#39;))

// After onApiReady:
function saveCurrentView(name: string) {
  const state = api.getState()
  views.save(name, state)
}

function restoreView(name: string) {
  const state = views.load(name)
  if (state) api.setState(state)
}

// Programmatic column management
api.setColumnVisible(&#39;internalId&#39;, false)
api.setColumnPinning({ left: [&#39;name&#39;, &#39;sku&#39;], right: [&#39;actions&#39;] })
api.autosizeAllColumns()

// Grouping and pagination together
api.setGroupBy([&#39;category&#39;, &#39;region&#39;])
api.setPageSize(50)
api.setPage(0)

// Selection
api.selectAllRows()
const selected = api.getSelectedRows()

// Edit flow
api.startEditing(rowIndex, &#39;price&#39;)
// ... user edits ...
api.stopEditing()
api.undo() // revert if needed
</code></pre>
<p>All of that is free, MIT-licensed, ships in production today.</p>
<h2 id="how-to-make-the-call">How to make the call</h2><p>Three questions that cut through the noise:</p>
<p><strong>What features do you actually need at launch?</strong> List them. Match them against the free tier. If virtualization, sorting, filtering, editing, and server-side data are enough, the free tier is the right call. If you need pivot or Excel export, price in the commercial tier from the start.</p>
<p><strong>How expensive is your time relative to license cost?</strong> A per-developer annual license for an enterprise grid is typically $300-700/year. If your hourly rate is $100+, one day of building a feature that the grid already has breaks even on a full year of license cost. Do the math honestly.</p>
<p><strong>What&#39;s the abandonment risk?</strong> An MIT grid with one maintainer and 200 stars carries real risk. A commercial grid with paying customers has financial incentive to keep the lights on. For a grid you&#39;ll depend on for three or more years, that durability matters.</p>
<p>For most Svelte teams building internal apps or B2B products: start with a capable open-core free tier, evaluate whether you hit the ceiling, and upgrade if you do. Don&#39;t pay for features you won&#39;t use. Do pay for features that would cost more to build than to license.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/porting-mui-x-datagrid-to-svelte/">Porting a React MUI X DataGrid Screen to Svelte</a></li>
<li><a href="https://svgrid.com/blog/most-accessible-svelte-data-grid/">Choosing the Most Accessible Svelte Data Grid</a></li>
<li><a href="https://svgrid.com/blog/migrating-from-datatables-to-svelte/">Migrating from DataTables.net to a Svelte Data Grid</a></li>
<li><a href="https://svgrid.com/blog/migrating-from-ag-grid-react-to-svelte/">Migrating from ag-grid-react to a Svelte Stack</a></li>
<li><a href="https://svgrid.com/blog/fastest-svelte-data-grid/">What Makes a Svelte Data Grid Fast (and How to Measure It)</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Multi-Level (Grouped) Column Headers in SvGrid</title>
      <link>https://svgrid.com/blog/multi-level-column-headers/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/multi-level-column-headers/</guid>
      <pubDate>Thu, 20 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Victor Vidolov</dc:creator>
      <category>column groups</category>
      <category>headers</category>
      <category>columns</category>
      <category>recipe</category>
      <category>svelte data grid</category>
      <description>Band related columns under a shared parent header using SvGrid's nested column definition - how to nest, pin, combine with sorting and filtering, and when NOT to use grouping.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/multi-level-column-headers.png" width="1200" height="630" alt="" /></p><p>Twenty columns in a flat header is a navigation problem disguised as a data problem. Users scan left to right looking for meaning, and a wall of equal-weight labels gives them nothing to anchor on. Grouped headers - a parent band spanning several child columns - solve this by creating visual hierarchy before the user has to think.</p>
<p>SvGrid handles grouped headers through nested column definitions. There is no separate API call, no post-setup configuration. You nest a <code>columns</code> array inside a parent column object and the grid renders a spanning header above the children. That simplicity has real implications for how you can compose things.</p>
<p><img src="https://svgrid.com/blog-media/columns-hierarchy.png" alt="Multi-level grouped column headers in SvGrid">
<em>Grouped, multi-level column headers in SvGrid.</em></p>
<h2 id="the-nesting-model">The nesting model</h2><p>The core idea is that any column object can have a <code>columns</code> property. When it does, SvGrid treats it as a group header - it spans the full width of its children and cannot itself hold data. The children are ordinary leaf columns with all the usual properties: <code>field</code>, <code>width</code>, <code>type</code>, <code>editable</code>, <code>pinned</code>, and so on.</p>
<pre><code class="language-ts">import SvGrid from &#39;@svgrid/grid&#39;
import type { ColumnDef } from &#39;@svgrid/grid&#39;

type Row = {
  product: string
  q1_revenue: number
  q2_revenue: number
  q3_revenue: number
  q4_revenue: number
  q1_units: number
  q2_units: number
}

const columns: ColumnDef&lt;{}, Row&gt;[] = [
  {
    id: &#39;product&#39;,
    field: &#39;product&#39;,
    header: &#39;Product&#39;,
    width: 200,
    pinned: &#39;left&#39;,
  },
  {
    header: &#39;2026 Revenue&#39;,
    columns: [
      { id: &#39;q1_rev&#39;, field: &#39;q1_revenue&#39;, header: &#39;Q1&#39;, width: 110, type: &#39;number&#39;, format: { type: &#39;currency&#39;, currency: &#39;USD&#39; } },
      { id: &#39;q2_rev&#39;, field: &#39;q2_revenue&#39;, header: &#39;Q2&#39;, width: 110, type: &#39;number&#39;, format: { type: &#39;currency&#39;, currency: &#39;USD&#39; } },
      { id: &#39;q3_rev&#39;, field: &#39;q3_revenue&#39;, header: &#39;Q3&#39;, width: 110, type: &#39;number&#39;, format: { type: &#39;currency&#39;, currency: &#39;USD&#39; } },
      { id: &#39;q4_rev&#39;, field: &#39;q4_revenue&#39;, header: &#39;Q4&#39;, width: 110, type: &#39;number&#39;, format: { type: &#39;currency&#39;, currency: &#39;USD&#39; } },
    ],
  },
  {
    header: &#39;2026 Units&#39;,
    columns: [
      { id: &#39;q1_units&#39;, field: &#39;q1_units&#39;, header: &#39;Q1&#39;, width: 90, type: &#39;number&#39; },
      { id: &#39;q2_units&#39;, field: &#39;q2_units&#39;, header: &#39;Q2&#39;, width: 90, type: &#39;number&#39; },
    ],
  },
]
</code></pre>
<p>The &quot;2026 Revenue&quot; band spans four columns. &quot;2026 Units&quot; spans two. The <code>product</code> column is pinned left and stands alone. The grid takes care of the rowspan and colspan math, so you do not write any HTML by hand.</p>
<h2 id="sorting-and-filtering-still-work-on-leaf-columns">Sorting and filtering still work on leaf columns</h2><p>A common concern with multi-level headers is whether interactive features break down. They do not. Sorting, filtering, and resizing all operate on the leaf columns exactly as they would in a flat header. The group bands are display-only.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import type { SvGridApi } from &#39;@svgrid/grid&#39;

  let api: SvGridApi | undefined

  const data = $state(rows)

  // Sort by Q1 revenue programmatically after mount
  function sortByBestQuarter() {
    api?.setSort(&#39;q1_rev&#39;, &#39;desc&#39;)
  }
&lt;/script&gt;

&lt;SvGrid
  {data}
  {columns}
  sortable
  filterable
  showFilterRow={true}
  rowHeight={32}
  onApiReady={(a) =&gt; { api = a }}
/&gt;

&lt;button onclick={sortByBestQuarter}&gt;Sort by Q1&lt;/button&gt;
</code></pre>
<p>Filter rows appear under the leaf headers, not under the group bands, which is exactly where users expect to type. If you have <code>showFilterRow={true}</code>, the group bands get an empty cell spanning the group width - the filter inputs sit in the next row aligned to their columns.</p>
<h2 id="three-levels-and-why-you-probably-want-two">Three levels and why you probably want two</h2><p>Nesting can go deeper. Year &gt; Half &gt; Quarter is a legitimate structure and SvGrid will render all three rows:</p>
<pre><code class="language-ts">const columns: ColumnDef&lt;{}, Row&gt;[] = [
  { id: &#39;region&#39;, field: &#39;region&#39;, header: &#39;Region&#39;, width: 160, pinned: &#39;left&#39; },
  {
    header: &#39;2025&#39;,
    columns: [
      {
        header: &#39;H1&#39;,
        columns: [
          { id: &#39;q1_25&#39;, field: &#39;q1_2025&#39;, header: &#39;Q1&#39;, width: 100, type: &#39;number&#39; },
          { id: &#39;q2_25&#39;, field: &#39;q2_2025&#39;, header: &#39;Q2&#39;, width: 100, type: &#39;number&#39; },
        ],
      },
      {
        header: &#39;H2&#39;,
        columns: [
          { id: &#39;q3_25&#39;, field: &#39;q3_2025&#39;, header: &#39;Q3&#39;, width: 100, type: &#39;number&#39; },
          { id: &#39;q4_25&#39;, field: &#39;q4_2025&#39;, header: &#39;Q4&#39;, width: 100, type: &#39;number&#39; },
        ],
      },
    ],
  },
  {
    header: &#39;2026&#39;,
    columns: [
      {
        header: &#39;H1&#39;,
        columns: [
          { id: &#39;q1_26&#39;, field: &#39;q1_2026&#39;, header: &#39;Q1&#39;, width: 100, type: &#39;number&#39; },
          { id: &#39;q2_26&#39;, field: &#39;q2_2026&#39;, header: &#39;Q2&#39;, width: 100, type: &#39;number&#39; },
        ],
      },
    ],
  },
]
</code></pre>
<p>Three levels is technically fine. Four is where it starts costing users more in visual effort than it saves in organization. My rule of thumb: if the group label is more than five words, or if you are approaching four rows of headers, consider whether a column chooser or a tab-based view would serve better.</p>
<h2 id="combining-grouped-headers-with-column-grouping-and-aggregation">Combining grouped headers with column grouping and aggregation</h2><p>These are two different things that the same word (&quot;grouping&quot;) can confuse. Column header groups organize the header display. Row grouping (via <code>groupable</code> and <code>setGroupBy</code>) aggregates rows by a field value. They compose cleanly:</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import type { SvGridApi } from &#39;@svgrid/grid&#39;

  let api: SvGridApi | undefined
  const data = $state(salesRows)
&lt;/script&gt;

&lt;SvGrid
  {data}
  {columns}
  sortable
  filterable
  groupable
  showFilterRow={true}
  rowHeight={32}
  onApiReady={(a) =&gt; {
    api = a
    // Group rows by region; column header bands are separate
    api.setGroupBy([&#39;region&#39;])
  }}
/&gt;
</code></pre>
<p>The header bands (&quot;2026 Revenue&quot;, &quot;2026 Units&quot;) remain visible and unchanged while rows are grouped by <code>region</code>. Aggregated totals appear per group and in the footer, tied to whichever leaf columns have <code>aggregate</code> configured. This is the combination that makes financial dashboards work - you want both the organizational structure in the header and the roll-up structure in the rows.</p>
<h2 id="when-flat-headers-are-actually-better">When flat headers are actually better</h2><p>Grouped headers add cognitive load even when they add clarity. A few situations where I reach for flat columns instead:</p>
<ul>
<li>Fewer than six columns. Grouping three columns under one band is visual overhead with no payoff.</li>
<li>Heterogeneous data where columns do not share a natural parent dimension. Forcing them into a group gives users a false taxonomy.</li>
<li>Heavy filtering workflows. When users are constantly showing and hiding columns via a column chooser, a deep header hierarchy gets confusing as columns come and go and group bands collapse.</li>
</ul>
<p>When the data genuinely has a parent-child relationship between header concepts, though, grouped headers communicate that relationship immediately. A year with quarters underneath reads as &quot;these four things belong to 2026&quot; in a way that four separate &quot;2026 Q1&quot;, &quot;2026 Q2&quot; flat headers do not.</p>
<h2 id="column-visibility-with-grouped-headers">Column visibility with grouped headers</h2><p>If all children of a group are hidden, the group band itself collapses and disappears. If some children are hidden, the band narrows to span only the visible ones. This means you can safely wire a column chooser to leaf columns without writing any special logic for group visibility:</p>
<pre><code class="language-ts">// Hide all revenue columns - the &quot;2026 Revenue&quot; band disappears automatically
api.setColumnVisible(&#39;q1_rev&#39;, false)
api.setColumnVisible(&#39;q2_rev&#39;, false)
api.setColumnVisible(&#39;q3_rev&#39;, false)
api.setColumnVisible(&#39;q4_rev&#39;, false)

// Show one back - the band reappears, spanning only q4_rev
api.setColumnVisible(&#39;q4_rev&#39;, true)
</code></pre>
<p>This behavior is one of the places where having the group structure encoded in the column definition (rather than set up through a separate API) pays off. The grid always knows which leaves belong to which band and can recompute spans reactively.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/custom-column-header-menu/">A Custom Column Header Menu in SvGrid</a></li>
<li><a href="https://svgrid.com/blog/column-visibility-toggle/">A Column Show/Hide Toggle in SvGrid</a></li>
<li><a href="https://svgrid.com/blog/column-definitions-deep-dive/">Column Definitions in SvGrid - Fields, Accessors, and Formatting</a></li>
<li><a href="https://svgrid.com/blog/progress-bar-cells/">Progress and Percentage Bar Cells in SvGrid</a></li>
<li><a href="https://svgrid.com/blog/lazy-loading-master-detail-content/">Lazy-Loading Master-Detail Content in SvGrid</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Choosing the Most Accessible Svelte Data Grid</title>
      <link>https://svgrid.com/blog/most-accessible-svelte-data-grid/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/most-accessible-svelte-data-grid/</guid>
      <pubDate>Wed, 19 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Boyko Markov</dc:creator>
      <category>accessibility</category>
      <category>comparison</category>
      <category>wcag</category>
      <category>svelte data grid</category>
      <description>A practical guide to testing data grid accessibility - ARIA roles, keyboard navigation, focus management under virtualization, and screen-reader behavior - so you can verify claims yourself.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/most-accessible-svelte-data-grid.png" width="1200" height="630" alt="" /></p><p>Most data grid accessibility stories go like this: a vendor&#39;s feature page has a green checkbox next to &quot;WCAG 2.1 AA&quot;. A procurement team checks the box. Six months later, a screen-reader user files a support ticket because they cannot navigate past column 3.</p>
<p>Accessibility claims are cheap. The real question is how to verify them before you commit to a component.</p>
<p><img src="https://svgrid.com/blog-media/high-contrast.png" alt="A high-contrast, accessible SvGrid theme.">
<em>A high-contrast, accessible SvGrid theme.</em></p>
<h2 id="what-the-wai-aria-grid-pattern-actually-requires">What the WAI-ARIA grid pattern actually requires</h2><p>The W3C defines a <a href="https://www.w3.org/WAI/ARIA/apg/patterns/grid/">composite widget</a> called <code>grid</code> that is meant for grids of interactive cells - not for static data tables. The DOM requirements are specific:</p>
<ul>
<li>The container gets <code>role=&quot;grid&quot;</code></li>
<li>Each row gets <code>role=&quot;row&quot;</code></li>
<li>Header cells get <code>role=&quot;columnheader&quot;</code></li>
<li>Data cells get <code>role=&quot;gridcell&quot;</code></li>
</ul>
<p>Inspect any Svelte grid candidate with DevTools before trusting the docs. A library that renders <code>&lt;div class=&quot;cell&quot;&gt;</code> with no ARIA role fails this immediately - assistive technology has nothing to announce.</p>
<p>Beyond roles, the pattern requires that the grid function as a <strong>single tab stop</strong>. You Tab into it, arrow keys move focus between cells, and Tab again takes you out. If Tab walks through every cell in a 1000-row table, that is a keyboard trap by a different name, and it fails WCAG 2.1 criterion 2.1.2.</p>
<h2 id="the-four-things-you-must-test-yourself">The four things you must test yourself</h2><p>Feature pages lie by omission. These four tests take under ten minutes and tell you almost everything:</p>
<p><strong>1. Keyboard navigation without a mouse.</strong> Open the grid, tab into it, and try to reach the last cell using only arrow keys, Home, End, Page Up, Page Down, Ctrl+Home, and Ctrl+End. Try editing with F2 or Enter and cancelling with Escape. A grid that supports only arrow movement but drops the rest fails in practice.</p>
<p><strong>2. Focus survival under virtualization.</strong> This is where most grids quietly break. Virtualization recycles DOM nodes as you scroll - when a focused row leaves the viewport, the grid may destroy the node that held focus. Navigate to a row near the bottom of the viewport, scroll it off screen, then scroll back. Is the focused cell still focused, or did focus silently move to <code>&lt;body&gt;</code>?</p>
<p><strong>3. Screen-reader announcements.</strong> With VoiceOver or NVDA running, navigate with arrow keys. You should hear the cell content, the column header, and the row/column position (e.g. &quot;row 5, column 3&quot;). Sort state changes should be announced via <code>aria-sort</code>. Row selection should be announced via <code>aria-selected</code>.</p>
<p><strong>4. Custom cell accessibility.</strong> This one is your responsibility, but the grid shapes how easy or hard it is. Render an action column with a button in each cell, then keyboard-navigate to it. Does focus land inside the cell correctly? Does pressing Space or Enter activate the button? A grid that puts interactive elements inside <code>gridcell</code> correctly will make this straightforward.</p>
<h2 id="how-to-build-accessible-custom-cells-in-svgrid">How to build accessible custom cells in SvGrid</h2><p>Even a grid with perfect built-in accessibility can be broken by the cells you write. Here is the pattern that keeps things correct:</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import type { ColumnDef } from &#39;@svgrid/grid&#39;

  const columns: ColumnDef[] = [
    { id: &#39;name&#39;, field: &#39;name&#39;, header: &#39;Name&#39;, width: 200 },
    { id: &#39;status&#39;, field: &#39;status&#39;, header: &#39;Status&#39;, width: 120, cell: statusCell },
    { id: &#39;actions&#39;, header: &#39;Actions&#39;, width: 100, cell: actionsCell },
  ]
&lt;/script&gt;

{#snippet statusCell({ value })}
  &lt;!-- Use semantic text, not color alone - WCAG 1.4.1 --&gt;
  &lt;span
    class=&quot;badge&quot;
    class:active={value === &#39;active&#39;}
    aria-label={value === &#39;active&#39; ? &#39;Status: active&#39; : &#39;Status: inactive&#39;}
  &gt;
    {value}
  &lt;/span&gt;
{/snippet}

{#snippet actionsCell({ row })}
  &lt;!-- Real &lt;button&gt; elements, not divs with click handlers --&gt;
  &lt;button
    type=&quot;button&quot;
    aria-label={`Edit ${row.name}`}
    onclick={() =&gt; openEditor(row)}
  &gt;
    Edit
  &lt;/button&gt;
{/snippet}

&lt;SvGrid
  {data}
  {columns}
  enableCellSelection={true}
/&gt;
</code></pre>
<p>The two rules that matter: use real <code>&lt;button&gt;</code> and <code>&lt;a&gt;</code> elements for interactive content inside cells (not <code>&lt;div onclick&gt;</code>), and label icon-only controls so a screen reader has something to announce. The grid handles focus movement between cells; you handle what lives inside them.</p>
<h2 id="conditional-formatting-without-accessibility-regression">Conditional formatting without accessibility regression</h2><p>Visual formatting is common in grids - red for negative numbers, yellow for warnings. The accessibility risk is relying on color alone to convey meaning (WCAG 1.4.1 fails). Here is the correct approach:</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import type { ColumnDef } from &#39;@svgrid/grid&#39;

  const columns: ColumnDef[] = [
    { id: &#39;name&#39;, field: &#39;name&#39;, header: &#39;Name&#39;, width: 200 },
    {
      id: &#39;score&#39;,
      field: &#39;score&#39;,
      header: &#39;Score&#39;,
      width: 100,
      type: &#39;number&#39;,
      conditionalFormat: [
        {
          condition: ({ value }) =&gt; value &lt; 50,
          style: { color: &#39;var(--color-danger)&#39;, fontWeight: &#39;bold&#39; },
        },
        {
          condition: ({ value }) =&gt; value &gt;= 90,
          style: { color: &#39;var(--color-success)&#39; },
        },
      ],
      // Provide a text suffix so color is not the only signal
      cell: scoreCell,
    },
  ]
&lt;/script&gt;

{#snippet scoreCell({ value })}
  &lt;span aria-label={`${value} ${value &lt; 50 ? &#39;(below threshold)&#39; : value &gt;= 90 ? &#39;(excellent)&#39; : &#39;&#39;}`}&gt;
    {value}
    {#if value &lt; 50}
      &lt;span aria-hidden=&quot;true&quot;&gt; ↓&lt;/span&gt;
    {:else if value &gt;= 90}
      &lt;span aria-hidden=&quot;true&quot;&gt; ↑&lt;/span&gt;
    {/if}
  &lt;/span&gt;
{/snippet}

&lt;SvGrid {data} {columns} /&gt;
</code></pre>
<p>The pattern: use <code>aria-hidden=&quot;true&quot;</code> on decorative icons, and provide an <code>aria-label</code> that includes the meaning in text, not just the value. The visual arrow is supplementary, not the only signal.</p>
<h2 id="the-focus-under-virtualization-problem-in-detail">The focus-under-virtualization problem in detail</h2><p>Virtualization is where grids most commonly break accessibility silently. When you have 100,000 rows, the grid only renders the visible slice - maybe 30 rows at a time. As you scroll, rows leaving the viewport are unmounted and their DOM nodes are reused.</p>
<p>If a focused cell&#39;s row gets unmounted, the browser moves focus to <code>&lt;body&gt;</code>. The user pressing an arrow key next gets no response, or focus jumps somewhere unexpected. They might not even notice immediately - it just feels broken.</p>
<p>The correct implementation maintains a virtual focus position separate from the DOM focus. When a previously-focused row re-enters the viewport, the grid restores focus to the right DOM node automatically. Testing this is simple: Tab into the grid, arrow down to a row near the bottom of the visible area, then hold the down arrow until that row scrolls off screen. If focus survives, the implementation is correct.</p>
<h2 id="a-keyboard-navigation-test-script">A keyboard navigation test script</h2><p>Run this directly on any grid you are evaluating:</p>
<pre><code>1. Tab into the grid - focus should land on the first cell, not a container div.
2. Arrow right/left/up/down - focus should move one cell at a time.
3. Home - focus should move to column 1 of the current row.
4. End - focus should move to the last column.
5. Ctrl+Home - focus should move to row 1, column 1.
6. Ctrl+End - focus should move to the last row, last column.
7. F2 or Enter on an editable cell - cell should enter edit mode.
8. Escape - cell should exit edit mode without saving.
9. Tab out of the grid - focus should leave the grid to the next element in page order.
10. Scroll a focused row off screen and back - focus should be preserved.
</code></pre>
<p>A grid that passes all ten is doing accessibility correctly at the structural level. Most fail on 6 (Ctrl+End in large datasets requires virtualized focus tracking) and 10 (focus survival).</p>
<h2 id="svgrids-approach">SvGrid&#39;s approach</h2><p>SvGrid renders the WAI-ARIA grid pattern from the first render - <code>role=&quot;grid&quot;</code>, <code>role=&quot;row&quot;</code>, <code>role=&quot;columnheader&quot;</code>, <code>role=&quot;gridcell&quot;</code> - with roving focus managed via <code>tabindex</code> shifting. Keyboard navigation covers the full shortcut set including Ctrl+Home/End, F2/Escape for editing, and Page Up/Down. Focus is tracked as a virtual position independent of the DOM, so virtualized scrolling does not drop it.</p>
<p>That said: run the ten-step test above against SvGrid and against any alternative you are considering. Accessibility is too important to take on a vendor&#39;s word. The test takes ten minutes and the results are definitive.</p>
<p>For government, healthcare, or finance procurement where WCAG 2.1 AA is a hard requirement, test with an actual screen reader too - NVDA on Windows and VoiceOver on macOS are both free. What you hear is what your users experience.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/porting-mui-x-datagrid-to-svelte/">Porting a React MUI X DataGrid Screen to Svelte</a></li>
<li><a href="https://svgrid.com/blog/open-source-vs-commercial-svelte-grids/">Open-Source vs Commercial Svelte Data Grids</a></li>
<li><a href="https://svgrid.com/blog/migrating-from-datatables-to-svelte/">Migrating from DataTables.net to a Svelte Data Grid</a></li>
<li><a href="https://svgrid.com/blog/migrating-from-ag-grid-react-to-svelte/">Migrating from ag-grid-react to a Svelte Stack</a></li>
<li><a href="https://svgrid.com/blog/fastest-svelte-data-grid/">What Makes a Svelte Data Grid Fast (and How to Measure It)</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Migrating a Svelte 4 Table Component to Svelte 5</title>
      <link>https://svgrid.com/blog/migrating-svelte-4-table-to-svelte-5/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/migrating-svelte-4-table-to-svelte-5/</guid>
      <pubDate>Tue, 18 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Victor Vidolov</dc:creator>
      <category>svelte 5</category>
      <category>migration</category>
      <category>runes</category>
      <category>table</category>
      <category>engineering</category>
      <description>A concept-by-concept guide to porting a hand-rolled Svelte 4 data table to Svelte 5 runes - props, reactivity, stores, slots, events, and when to stop hand-rolling entirely.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/migrating-svelte-4-table-to-svelte-5.png" width="1200" height="630" alt="" /></p><p>Most Svelte 4 tables hit the same wall around the same time: someone adds sorting, then filtering, then someone else asks why 10,000 rows lock up the browser. By that point, you&#39;re maintaining a small grid library inside your app. Svelte 5 is a natural pause to decide whether to carry that forward or hand it off.</p>
<p><img src="https://svgrid.com/blog-media/quick-start.png" alt="Migrating a Svelte 4 Table Component to Svelte 5"></p>
<p>Either way, you need to know the translation. Here it is, concept by concept, with a real recommendation at the end.</p>
<h2 id="the-mental-model-shift">The mental model shift</h2><p>Svelte 4&#39;s reactivity was implicit. <code>$:</code> statements re-ran whenever referenced values changed, and the compiler tracked what those were. Svelte 5 makes that explicit: <code>$derived</code> and <code>$effect</code> replace <code>$:</code>, <code>$state</code> replaces <code>let</code> for reactive variables, and <code>$props()</code> replaces <code>export let</code>.</p>
<p>The upside is that the new model composes much better. Once you&#39;re used to it, you stop wondering which <code>$:</code> blocks will re-run and in what order.</p>
<h2 id="props-the-easy-part">Props - the easy part</h2><pre><code class="language-svelte">&lt;!-- Svelte 4 --&gt;
&lt;script&gt;
  export let rows = []
  export let columns = []
  export let caption = &#39;&#39;
&lt;/script&gt;
</code></pre>
<pre><code class="language-svelte">&lt;!-- Svelte 5 --&gt;
&lt;script lang=&quot;ts&quot;&gt;
  type Row = Record&lt;string, unknown&gt;
  type Column = { key: string; label: string; width?: number }

  let {
    rows = [],
    columns = [],
    caption = &#39;&#39;,
  }: {
    rows: Row[]
    columns: Column[]
    caption?: string
  } = $props()
&lt;/script&gt;
</code></pre>
<p>The destructuring syntax is cleaner for typed code. In Svelte 4, TypeScript annotations on <code>export let</code> required some gymnastics; with <code>$props()</code> you annotate the destructured type directly.</p>
<h2 id="reactive-state-and-derived-values">Reactive state and derived values</h2><p>This is where most of the migration effort lives.</p>
<pre><code class="language-svelte">&lt;!-- Svelte 4 --&gt;
&lt;script&gt;
  export let rows = []

  let sortKey = &#39;name&#39;
  let sortDir = 1  // 1 = asc, -1 = desc
  let filterText = &#39;&#39;

  $: filtered = rows.filter(r =&gt;
    String(r[sortKey] ?? &#39;&#39;).toLowerCase().includes(filterText.toLowerCase())
  )
  $: sorted = [...filtered].sort((a, b) =&gt;
    a[sortKey] &gt; b[sortKey] ? sortDir : a[sortKey] &lt; b[sortKey] ? -sortDir : 0
  )
&lt;/script&gt;
</code></pre>
<pre><code class="language-svelte">&lt;!-- Svelte 5 --&gt;
&lt;script lang=&quot;ts&quot;&gt;
  type Row = Record&lt;string, unknown&gt;
  let { rows = [] }: { rows: Row[] } = $props()

  let sortKey = $state(&#39;name&#39;)
  let sortDir = $state&lt;1 | -1&gt;(1)
  let filterText = $state(&#39;&#39;)

  let filtered = $derived(
    rows.filter(r =&gt;
      String(r[sortKey] ?? &#39;&#39;).toLowerCase().includes(filterText.toLowerCase())
    )
  )

  let sorted = $derived(
    [...filtered].sort((a, b) =&gt;
      a[sortKey] &gt; b[sortKey] ? sortDir : a[sortKey] &lt; b[sortKey] ? -sortDir : 0
    )
  )
&lt;/script&gt;
</code></pre>
<p>A few things to notice. First, <code>$derived</code> chains: <code>sorted</code> depends on <code>filtered</code>, and the runtime tracks that automatically. Second, local state variables (<code>sortKey</code>, <code>sortDir</code>, <code>filterText</code>) become <code>$state</code> calls. You no longer need a store just because a value is reactive.</p>
<h2 id="stores-to-state">Stores to $state</h2><p>Svelte 4 projects often used <code>writable</code> stores to share state between a table component and its parent - page index, selected rows, column visibility. With runes, you can pass <code>$state</code> values directly as props, or use a plain object with <code>$state</code> fields.</p>
<pre><code class="language-svelte">&lt;!-- Svelte 4 - shared table state via stores --&gt;
&lt;script&gt;
  import { writable, derived } from &#39;svelte/store&#39;

  export const selectedRows = writable(new Set())
  export const pageIndex = writable(0)
  export const pageSize = writable(25)

  export const pageCount = derived(
    [rowCount, pageSize],
    ([$rowCount, $pageSize]) =&gt; Math.ceil($rowCount / $pageSize)
  )
&lt;/script&gt;
</code></pre>
<pre><code class="language-svelte">&lt;!-- Svelte 5 - shared table state via $state --&gt;
&lt;script lang=&quot;ts&quot;&gt;
  // tableState.svelte.ts - a module file, not a component
  export function createTableState(totalRows: number) {
    let selectedRows = $state(new Set&lt;string&gt;())
    let pageIndex = $state(0)
    let pageSize = $state(25)

    let pageCount = $derived(Math.ceil(totalRows / pageSize))

    return {
      get selectedRows() { return selectedRows },
      get pageIndex() { return pageIndex },
      get pageSize() { return pageSize },
      get pageCount() { return pageCount },
      setPage: (n: number) =&gt; { pageIndex = n },
      toggleRow: (id: string) =&gt; {
        if (selectedRows.has(id)) selectedRows.delete(id)
        else selectedRows.add(id)
      },
    }
  }
&lt;/script&gt;
</code></pre>
<p>The <code>.svelte.ts</code> extension matters - it tells the Svelte compiler to process rune syntax in a non-component file. This pattern replaces the store module pattern cleanly.</p>
<h2 id="slots-to-snippets">Slots to snippets</h2><p>Custom cell rendering is usually the messiest part of any table migration. Svelte 4 used named slots, which meant the consuming side had to reach into the table with <code>slot=&quot;cell&quot;</code> and hope the binding worked. Svelte 5 snippets are explicit and type-safe.</p>
<pre><code class="language-svelte">&lt;!-- Svelte 4 --&gt;
&lt;!-- In DataTable.svelte --&gt;
{#each sorted as row}
  &lt;tr&gt;
    {#each columns as col}
      &lt;td&gt;
        &lt;slot name=&quot;cell&quot; {row} {col} value={row[col.key]}&gt;
          {row[col.key]}
        &lt;/slot&gt;
      &lt;/td&gt;
    {/each}
  &lt;/tr&gt;
{/each}

&lt;!-- In parent --&gt;
&lt;DataTable {rows} {columns}&gt;
  &lt;svelte:fragment slot=&quot;cell&quot; let:row let:col let:value&gt;
    {#if col.key === &#39;status&#39;}
      &lt;span class=&quot;badge badge-{value}&quot;&gt;{value}&lt;/span&gt;
    {:else}
      {value}
    {/if}
  &lt;/svelte:fragment&gt;
&lt;/DataTable&gt;
</code></pre>
<pre><code class="language-svelte">&lt;!-- Svelte 5 --&gt;
&lt;!-- In DataTable.svelte --&gt;
&lt;script lang=&quot;ts&quot;&gt;
  type CellContext = { row: Row; col: Column; value: unknown }
  let { rows, columns, cell }: {
    rows: Row[]
    columns: Column[]
    cell?: import(&#39;svelte&#39;).Snippet&lt;[CellContext]&gt;
  } = $props()
&lt;/script&gt;

{#each sorted as row}
  &lt;tr&gt;
    {#each columns as col}
      &lt;td&gt;
        {#if cell}
          {@render cell({ row, col, value: row[col.key] })}
        {:else}
          {row[col.key]}
        {/if}
      &lt;/td&gt;
    {/each}
  &lt;/tr&gt;
{/each}

&lt;!-- In parent --&gt;
{#snippet statusCell({ row, col, value })}
  {#if col.key === &#39;status&#39;}
    &lt;span class=&quot;badge badge-{value}&quot;&gt;{value}&lt;/span&gt;
  {:else}
    {value}
  {/if}
{/snippet}

&lt;DataTable {rows} {columns} cell={statusCell} /&gt;
</code></pre>
<h2 id="events-become-callback-props">Events become callback props</h2><pre><code class="language-svelte">&lt;!-- Svelte 4 --&gt;
&lt;script&gt;
  import { createEventDispatcher } from &#39;svelte&#39;
  const dispatch = createEventDispatcher()

  function handleRowClick(row) {
    dispatch(&#39;rowclick&#39;, { row })
  }
&lt;/script&gt;

&lt;tr onclick={() =&gt; handleRowClick(row)}&gt;...&lt;/tr&gt;
</code></pre>
<pre><code class="language-svelte">&lt;!-- Svelte 5 --&gt;
&lt;script lang=&quot;ts&quot;&gt;
  let { onRowClick }: { onRowClick?: (row: Row) =&gt; void } = $props()
&lt;/script&gt;

&lt;tr onclick={() =&gt; onRowClick?.(row)}&gt;...&lt;/tr&gt;
</code></pre>
<p>The event dispatcher is gone. Callback props are just functions. This is actually easier to type, easier to test, and removes the implicit string-based event name entirely.</p>
<h2 id="when-porting-stops-making-sense">When porting stops making sense</h2><p>The translation above handles a table that does sorting, filtering, and basic selection. That&#39;s a few hundred lines and maybe a day of work for most codebases. But there&#39;s a set of features where the cost curve of hand-rolling goes vertical: virtualized scrolling for large datasets, server-side pagination with loading states, column resizing and pinning, accessibility (full keyboard navigation, ARIA grid role, screen reader announcements), and cell-level editing with validation.</p>
<p>If your Svelte 4 table already has these, you&#39;ve built a grid library and you&#39;re maintaining it in parallel with your app. If it doesn&#39;t have them and someone on your team keeps asking for them, migration time is the right moment to stop.</p>
<p><a href="https://svgrid.com">SvGrid</a> is built Svelte 5 native - runes throughout, no adapter layer. Dropping it in looks like this:</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import type { ColumnDef } from &#39;@svgrid/grid&#39;
  import type { SvGridApi } from &#39;@svgrid/grid&#39;

  const columns: ColumnDef[] = [
    { id: &#39;name&#39;, field: &#39;name&#39;, header: &#39;Name&#39;, width: 200 },
    { id: &#39;status&#39;, field: &#39;status&#39;, header: &#39;Status&#39;, width: 120, cell: statusCell },
    { id: &#39;amount&#39;, field: &#39;amount&#39;, header: &#39;Amount&#39;, type: &#39;number&#39;, width: 100 },
  ]

  let api: SvGridApi

  {#snippet statusCell({ value })}
    &lt;span class=&quot;badge badge-{value}&quot;&gt;{value}&lt;/span&gt;
  {/snippet}
&lt;/script&gt;

&lt;SvGrid
  data={rows}
  {columns}
  sortable
  filterable
  pageable
  virtualization={true}
  rowHeight={36}
  onApiReady={(a) =&gt; { api = a }}
/&gt;
</code></pre>
<p>You get virtualization, keyboard navigation, server-side data, and column pinning without writing them. The snippet pattern for custom cells is identical to what you&#39;d write in the hand-rolled version above, so the knowledge transfers.</p>
<p>For small static tables, port it - the Svelte 5 version will be cleaner than what you had. For anything with real data volume or feature expectations, use the migration as the opportunity to stop maintaining infrastructure.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/reactivity-large-arrays-objects/">Reactivity with Large Arrays and Objects in Svelte 5</a></li>
<li><a href="https://svgrid.com/blog/derived-vs-derived-by/">$derived vs $derived.by in Svelte 5</a></li>
<li><a href="https://svgrid.com/blog/bindable-props-grid-controls/">$bindable Props for Grid Controls in Svelte 5</a></li>
<li><a href="https://svgrid.com/blog/why-we-bet-on-svelte-5-runes/">Why We Bet on Svelte 5 Runes for a High-Performance Data Grid</a></li>
<li><a href="https://svgrid.com/blog/svelte-headless-table-svelte-5-options/">svelte-headless-table and the Svelte 5 upgrade: your three options</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Migrating from DataTables.net to a Svelte Data Grid</title>
      <link>https://svgrid.com/blog/migrating-from-datatables-to-svelte/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/migrating-from-datatables-to-svelte/</guid>
      <pubDate>Wed, 12 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Victor Vidolov</dc:creator>
      <category>migration</category>
      <category>datatables</category>
      <category>jquery</category>
      <category>comparison</category>
      <category>svelte data grid</category>
      <description>A practical migration guide from jQuery DataTables to SvGrid - how column definitions, server-side data, custom rendering, and selection map across, with working code for each step.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/migrating-from-datatables-to-svelte.png" width="1200" height="630" alt="" /></p><p>jQuery DataTables is a decade-old plugin that still runs on a surprising share of internal tools and admin panels. If you are moving one of those to SvelteKit, you will find that most of the concepts translate - column definitions, server-side pagination, custom cell rendering - but the execution shifts from imperative jQuery to reactive Svelte state. The delta is smaller than it looks, and the result is significantly less code.</p>
<p><img src="https://svgrid.com/blog-media/quick-start.png" alt="Migrating from DataTables.net to a Svelte Data Grid"></p>
<h2 id="what-the-two-libraries-are-trying-to-do">What the two libraries are trying to do</h2><p>DataTables attaches behavior to an existing <code>&lt;table&gt;</code> element. You hand it options at init time, and it wires up sorting, filtering, and pagination on top of whatever HTML you already have. Every subsequent interaction goes through method calls (<code>table.page(2).draw()</code>) or event listeners.</p>
<p>SvGrid is a Svelte component. There is no DOM element to attach to; you describe your grid declaratively and it renders itself. State changes happen through reactive props and an imperative API you get access to via <code>onApiReady</code>. The mental model is closer to a controlled React component than to a jQuery plugin.</p>
<p>This distinction matters when planning the migration. You are not swapping implementations of the same idea. You are moving from an outside-in jQuery pattern to an inside-out Svelte component pattern.</p>
<h2 id="column-definitions">Column definitions</h2><p>DataTables column definitions look like this:</p>
<pre><code class="language-js">$(&#39;#myTable&#39;).DataTable({
  columns: [
    { data: &#39;name&#39;,   title: &#39;Name&#39;   },
    { data: &#39;email&#39;,  title: &#39;Email&#39;  },
    { data: &#39;salary&#39;, title: &#39;Salary&#39;, render: (d) =&gt; `$${d.toLocaleString()}` },
    { data: &#39;status&#39;, title: &#39;Status&#39;, orderable: false },
  ],
  order: [[2, &#39;desc&#39;]],
  pageLength: 25,
})
</code></pre>
<p>The same columns in SvGrid, as a <code>ColumnDef</code> array:</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import {
    tableFeatures,
    rowSortingFeature,
    rowPaginationFeature,
    type ColumnDef,
    type SvGridApi,
  } from &#39;@svgrid/grid&#39;

  type Employee = { name: string; email: string; salary: number; status: string }

  const features = tableFeatures({ rowSortingFeature, rowPaginationFeature })

  const columns: ColumnDef&lt;typeof features, Employee&gt;[] = [
    { id: &#39;name&#39;,   field: &#39;name&#39;,   header: &#39;Name&#39;,   width: 180 },
    { id: &#39;email&#39;,  field: &#39;email&#39;,  header: &#39;Email&#39;,  width: 220 },
    {
      id: &#39;salary&#39;,
      field: &#39;salary&#39;,
      header: &#39;Salary&#39;,
      width: 120,
      type: &#39;number&#39;,
      format: { type: &#39;currency&#39;, currency: &#39;USD&#39; },
    },
    { id: &#39;status&#39;, field: &#39;status&#39;, header: &#39;Status&#39;, width: 100, sortable: false },
  ]

  let data = $state&lt;Employee[]&gt;([])
  let api: SvGridApi
&lt;/script&gt;

&lt;SvGrid
  {data}
  {columns}
  features={features}
  sortable
  pageable
  pageSize={25}
  onApiReady={(a) =&gt; { api = a }}
/&gt;
</code></pre>
<p>A few things are different here. <code>render</code> callbacks become the <code>format</code> option for built-in formatters, or a <code>cell</code> snippet when you need full control over the rendered output. The initial <code>order</code> config maps to setting sort state after <code>onApiReady</code> fires: <code>api.setSort('salary', 'desc')</code>. The <code>pageLength</code> option is just <code>pageSize</code>.</p>
<h2 id="server-side-data">Server-side data</h2><p>DataTables&#39; <code>serverSide: true</code> mode sends a POST with <code>start</code>, <code>length</code>, <code>order[]</code>, and <code>search[value]</code> parameters baked into its own wire format. You return <code>recordsTotal</code> and <code>recordsFiltered</code> in a specific envelope. It works, but the format is opaque and the parameters come in DataTables&#39; own naming convention.</p>
<p>SvGrid&#39;s server-side mode gives you a typed fetch callback with clean parameters. You own the request shape:</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import {
    createServerDataSource,
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    rowPaginationFeature,
  } from &#39;@svgrid/grid&#39;

  const features = tableFeatures({
    rowSortingFeature,
    columnFilteringFeature,
    rowPaginationFeature,
  })

  const ds = createServerDataSource({
    fetch: async ({ page, pageSize, sort, filters }) =&gt; {
      const params = new URLSearchParams({
        page: String(page),
        size: String(pageSize),
      })

      if (sort.length &gt; 0) {
        params.set(&#39;sortField&#39;, sort[0].id)
        params.set(&#39;sortDir&#39;, sort[0].desc ? &#39;desc&#39; : &#39;asc&#39;)
      }

      filters.forEach((f) =&gt; {
        params.set(`filter[${f.id}]`, String(f.value))
      })

      const res = await fetch(`/api/employees?${params}`)
      const json = await res.json()
      return { rows: json.items, total: json.total }
    },
  })
&lt;/script&gt;

&lt;SvGrid
  data={ds}
  columns={columns}
  features={features}
  sortable
  filterable
  pageable
/&gt;
</code></pre>
<p>The <code>page</code> parameter is zero-indexed. Your API endpoint gets clean named parameters instead of DataTables&#39; positional array syntax. The total count comes back in your return value alongside the rows - no envelope keys to remember.</p>
<h2 id="custom-cell-rendering">Custom cell rendering</h2><p>DataTables <code>render</code> callbacks return an HTML string, which gets injected via innerHTML. That works until you need interactivity inside the cell - then you are wiring up event listeners manually against DOM nodes DataTables manages.</p>
<p>SvGrid uses Svelte snippets. A snippet is a typed, reactive block that renders as part of the component tree, so Svelte event handling and reactivity work normally inside it:</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import { tableFeatures, rowSelectionFeature, type ColumnDef } from &#39;@svgrid/grid&#39;

  type Employee = { id: number; name: string; status: &#39;active&#39; | &#39;inactive&#39; }

  const features = tableFeatures({ rowSelectionFeature })

  const columns: ColumnDef&lt;typeof features, Employee&gt;[] = [
    { id: &#39;name&#39;,   field: &#39;name&#39;,   header: &#39;Name&#39;,   width: 180 },
    { id: &#39;status&#39;, field: &#39;status&#39;, header: &#39;Status&#39;, width: 120, cell: statusCell },
    { id: &#39;actions&#39;, header: &#39;&#39;,     width: 80,         cell: actionsCell, sortable: false },
  ]

  let data = $state&lt;Employee[]&gt;([
    { id: 1, name: &#39;Alice&#39;, status: &#39;active&#39; },
    { id: 2, name: &#39;Bob&#39;,   status: &#39;inactive&#39; },
  ])

  function deactivate(id: number) {
    const idx = data.findIndex((r) =&gt; r.id === id)
    if (idx !== -1) data[idx].status = &#39;inactive&#39;
  }
&lt;/script&gt;

{#snippet statusCell({ value }: { value: string })}
  &lt;span class=&quot;badge&quot; class:active={value === &#39;active&#39;} class:inactive={value === &#39;inactive&#39;}&gt;
    {value}
  &lt;/span&gt;
{/snippet}

{#snippet actionsCell({ row }: { row: Employee })}
  &lt;button onclick={() =&gt; deactivate(row.id)} disabled={row.status === &#39;inactive&#39;}&gt;
    Deactivate
  &lt;/button&gt;
{/snippet}

&lt;SvGrid {data} {columns} features={features} /&gt;
</code></pre>
<p>The snippet gets typed <code>value</code> and <code>row</code> parameters. No HTML string construction, no <code>document.querySelector</code> after render. Svelte handles the DOM.</p>
<h2 id="the-honest-tradeoffs">The honest tradeoffs</h2><p>DataTables has 15 years of plugins - editor, responsive, buttons, select. If your project depends on several of those plugins working together, the migration cost is real. You are rebuilding that functionality, not just swapping components.</p>
<p>Where SvGrid clearly wins: virtualization for large client-side datasets (DataTables would page rather than virtualize), native Svelte reactivity so the grid responds to state changes without <code>.draw()</code> calls, and TypeScript types on columns, rows, and the API surface.</p>
<p>The DataTables method call pattern (<code>table.column(0).search('value').draw()</code>) can feel familiar if you have written a lot of jQuery. SvGrid&#39;s imperative API is similar in spirit - <code>api.setFilter('name', { operator: 'contains', value: 'Alice' })</code> - but it goes through Svelte&#39;s reactivity system rather than triggering a DOM re-render manually.</p>
<p>One thing that trips people up: DataTables initializes once and then you mutate it. SvGrid is reactive, so updating <code>data</code> to a new array re-renders the grid automatically. If you are used to calling <code>.ajax.reload()</code> to refresh data, the equivalent is just updating your <code>$state</code> variable.</p>
<p>The migration is mostly mechanical once you have the column definition shape down. The server-side adapter is the part that requires thought, because you are replacing DataTables&#39; baked-in wire format with your own - which gives you more control but also means writing the parameter mapping yourself.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/porting-mui-x-datagrid-to-svelte/">Porting a React MUI X DataGrid Screen to Svelte</a></li>
<li><a href="https://svgrid.com/blog/migrating-from-ag-grid-react-to-svelte/">Migrating from ag-grid-react to a Svelte Stack</a></li>
<li><a href="https://svgrid.com/blog/svelte-data-grid-comparisons/">Svelte Data Grid Comparisons and Alternatives (2026)</a></li>
<li><a href="https://svgrid.com/blog/svelte-headless-table-svelte-5-options/">svelte-headless-table and the Svelte 5 upgrade: your three options</a></li>
<li><a href="https://svgrid.com/blog/open-source-vs-commercial-svelte-grids/">Open-Source vs Commercial Svelte Data Grids</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Migrating from ag-grid-react to a Svelte Stack</title>
      <link>https://svgrid.com/blog/migrating-from-ag-grid-react-to-svelte/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/migrating-from-ag-grid-react-to-svelte/</guid>
      <pubDate>Tue, 11 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Kamelia M</dc:creator>
      <category>migration</category>
      <category>ag-grid-react</category>
      <category>react</category>
      <category>comparison</category>
      <category>svelte data grid</category>
      <description>A practical field guide for porting ag-grid-react screens to SvGrid - column defs, cell renderers, server-side data, and the React-to-Svelte reactivity shift.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/migrating-from-ag-grid-react-to-svelte.png" width="1200" height="630" alt="" /></p><p>The React-to-Svelte migration conversation always stalls at the same point: &quot;but we have a lot of AG Grid.&quot; That anxiety is usually disproportionate. Column definitions translate almost one-to-one, cell renderers become snippets, and the server-side row model maps to a single adapter function. The real migration work is the surrounding React patterns, not the grid.</p>
<p><img src="https://svgrid.com/blog-media/admin-template.png" alt="An admin template built with SvGrid.">
<em>An admin template built with SvGrid.</em></p>
<h2 id="column-definitions-mostly-a-rename-job">Column definitions: mostly a rename job</h2><p>AG Grid&#39;s <code>columnDefs</code> array and SvGrid&#39;s <code>columns</code> array share the same shape. The fields you use constantly - <code>field</code>, <code>headerName</code>, <code>width</code>, <code>pinned</code>, <code>editable</code>, <code>valueFormatter</code> - have direct equivalents. The API names are slightly different, but nothing requires rethinking.</p>
<table>
<thead>
<tr>
<th>ag-grid-react</th>
<th>SvGrid (@svgrid/grid)</th>
</tr>
</thead>
<tbody><tr>
<td><code>rowData</code></td>
<td><code>data</code></td>
</tr>
<tr>
<td><code>columnDefs</code></td>
<td><code>columns</code></td>
</tr>
<tr>
<td><code>headerName</code></td>
<td><code>header</code></td>
</tr>
<tr>
<td><code>field</code></td>
<td><code>field</code></td>
</tr>
<tr>
<td><code>valueFormatter</code></td>
<td><code>format</code> or <code>formatter</code></td>
</tr>
<tr>
<td><code>valueGetter</code></td>
<td><code>fieldFn</code></td>
</tr>
<tr>
<td><code>cellRenderer</code></td>
<td><code>cell</code> (snippet or component)</td>
</tr>
<tr>
<td><code>onCellValueChanged</code></td>
<td><code>onCellValueChange</code></td>
</tr>
<tr>
<td><code>suppressMovable</code></td>
<td><code>movable: false</code></td>
</tr>
<tr>
<td>Server-Side Row Model</td>
<td><code>createServerDataSource</code></td>
</tr>
<tr>
<td>AG Grid Enterprise</td>
<td><code>@svgrid/enterprise</code></td>
</tr>
</tbody></table>
<p>A typical AG Grid column definition like this:</p>
<pre><code class="language-ts">// ag-grid-react column def
const columnDefs = [
  { field: &#39;name&#39;, headerName: &#39;Name&#39;, width: 180, pinned: &#39;left&#39; },
  {
    field: &#39;price&#39;,
    headerName: &#39;Price&#39;,
    width: 100,
    type: &#39;numericColumn&#39;,
    editable: true,
    valueFormatter: (p) =&gt; `$${p.value.toFixed(2)}`,
  },
  { field: &#39;status&#39;, headerName: &#39;Status&#39;, width: 120, cellRenderer: StatusRenderer },
  { headerName: &#39;&#39;, width: 80, cellRenderer: ActionsRenderer, pinned: &#39;right&#39; },
]
</code></pre>
<p>becomes this in SvGrid:</p>
<pre><code class="language-ts">import type { ColumnDef } from &#39;@svgrid/grid&#39;

const columns: ColumnDef&lt;typeof features, Row&gt;[] = [
  { id: &#39;name&#39;, field: &#39;name&#39;, header: &#39;Name&#39;, width: 180, pinned: &#39;left&#39; },
  {
    id: &#39;price&#39;,
    field: &#39;price&#39;,
    header: &#39;Price&#39;,
    width: 100,
    type: &#39;number&#39;,
    editable: true,
    format: (value) =&gt; `$${Number(value).toFixed(2)}`,
  },
  { id: &#39;status&#39;, field: &#39;status&#39;, header: &#39;Status&#39;, width: 120, cell: statusCell },
  { id: &#39;actions&#39;, header: &#39;&#39;, width: 80, cell: actionsCell, pinned: &#39;right&#39; },
]
</code></pre>
<p>The pattern is consistent. If you have a script that generates column defs programmatically, a few targeted string replacements will get you most of the way there.</p>
<h2 id="cell-renderers-to-snippets">Cell renderers to snippets</h2><p>This is the area where React and Svelte diverge most visibly - and where Svelte wins on brevity. A React cell renderer is a component with props threading and a <code>forwardRef</code> if you need the grid API. A Svelte 5 snippet is a few lines of markup declared inline or in the same file.</p>
<pre><code class="language-tsx">// ag-grid-react: a status badge renderer
const StatusRenderer = ({ value }: { value: string }) =&gt; (
  &lt;span className={`badge badge--${value}`}&gt;{value}&lt;/span&gt;
)

// ag-grid-react: an actions renderer that calls the grid API
const ActionsRenderer = ({ data, api }: ICellRendererParams) =&gt; (
  &lt;button onClick={() =&gt; api.applyTransaction({ remove: [data] })}&gt;
    Remove
  &lt;/button&gt;
)
</code></pre>
<p>In SvGrid, both become snippets, and the grid instance comes from <code>onApiReady</code> rather than being injected per-cell:</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import type { SvGridApi, ColumnDef } from &#39;@svgrid/grid&#39;
  import { tableFeatures, rowSelectionFeature } from &#39;@svgrid/grid&#39;

  let api = $state&lt;SvGridApi | null&gt;(null)
  const features = tableFeatures({ rowSelectionFeature })

  const data = $state&lt;Row[]&gt;([
    { id: 1, name: &#39;Widget A&#39;, status: &#39;active&#39; },
    { id: 2, name: &#39;Widget B&#39;, status: &#39;inactive&#39; },
  ])

  const columns: ColumnDef&lt;typeof features, Row&gt;[] = [
    { id: &#39;name&#39;, field: &#39;name&#39;, header: &#39;Name&#39;, width: 200 },
    { id: &#39;status&#39;, field: &#39;status&#39;, header: &#39;Status&#39;, width: 120, cell: statusCell },
    { id: &#39;actions&#39;, header: &#39;&#39;, width: 80, cell: actionsCell },
  ]
&lt;/script&gt;

{#snippet statusCell({ value }: { value: string })}
  &lt;span class=&quot;badge badge--{value}&quot;&gt;{value}&lt;/span&gt;
{/snippet}

{#snippet actionsCell({ row }: { row: Row })}
  &lt;button onclick={() =&gt; api?.applyTransaction({ remove: [row] })}&gt;
    Remove
  &lt;/button&gt;
{/snippet}

&lt;SvGrid {data} {columns} {features} onApiReady={(a) =&gt; { api = a }} /&gt;
</code></pre>
<p>No prop threading. No forwardRef. The snippet has access to everything in the component&#39;s scope.</p>
<h2 id="reactivity-the-hook-mental-model-doesnt-port">Reactivity: the hook mental model doesn&#39;t port</h2><p>This is the actual migration challenge. React&#39;s mental model is &quot;re-render the component when state changes, and memoize the expensive parts.&quot; Svelte 5&#39;s mental model is &quot;track which reactive values each expression reads, and re-run only that expression.&quot;</p>
<p>In practice, you stop writing <code>useMemo</code> and <code>useCallback</code> entirely. A derived value is just a <code>$derived</code>. A side effect is a <code>$effect</code>. The dependency array is inferred automatically.</p>
<p>A common pattern in AG Grid React apps is filtering data in a <code>useMemo</code> before passing it to <code>rowData</code>:</p>
<pre><code class="language-tsx">// React pattern - explicit memoization and dependency arrays
const [rows, setRows] = useState&lt;Row[]&gt;(rawRows)
const [query, setQuery] = useState(&#39;&#39;)

const filteredRows = useMemo(
  () =&gt; rows.filter((r) =&gt; r.name.toLowerCase().includes(query.toLowerCase())),
  [rows, query]
)

// &lt;AgGridReact rowData={filteredRows} ... /&gt;
</code></pre>
<p>In Svelte 5, the same logic without the overhead:</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  let rows = $state&lt;Row[]&gt;(rawRows)
  let query = $state(&#39;&#39;)

  // $derived re-runs automatically when rows or query change
  let filteredRows = $derived(
    rows.filter((r) =&gt; r.name.toLowerCase().includes(query.toLowerCase()))
  )
&lt;/script&gt;

&lt;input bind:value={query} placeholder=&quot;Search...&quot; /&gt;
&lt;SvGrid data={filteredRows} {columns} /&gt;
</code></pre>
<p>If you had <code>useEffect</code> hooks reacting to grid events (like selection changes), those become <code>$effect</code> blocks or event callbacks on the <code>SvGrid</code> component directly.</p>
<h2 id="server-side-data">Server-side data</h2><p>AG Grid&#39;s Server-Side Row Model is one of its most distinctive features - and the one most teams worry about losing. SvGrid handles this with <code>createServerDataSource</code>, which takes a single <code>fetch</code> function and returns a data source you pass directly to <code>data</code>.</p>
<pre><code class="language-ts">import SvGrid, { createServerDataSource } from &#39;@svgrid/grid&#39;

const ds = createServerDataSource({
  fetch: async ({ page, pageSize, sort, filters }) =&gt; {
    const params = new URLSearchParams({
      page: String(page),
      size: String(pageSize),
    })

    if (sort.length &gt; 0) {
      params.set(&#39;sortField&#39;, sort[0].id)
      params.set(&#39;sortDir&#39;, sort[0].desc ? &#39;desc&#39; : &#39;asc&#39;)
    }

    for (const f of filters) {
      params.set(`filter_${f.id}`, JSON.stringify(f.value))
    }

    const res = await fetch(`/api/rows?${params}`)
    const json = await res.json()
    return { rows: json.data, total: json.total }
  },
})
</code></pre>
<p>Then use it like any other data source:</p>
<pre><code class="language-svelte">&lt;SvGrid data={ds} {columns} pageable sortable filterable /&gt;
</code></pre>
<p>Sorting, filtering, and pagination all trigger the <code>fetch</code> function automatically. You do not need to wire up event handlers or manually call <code>api.refreshServerSide()</code> the way you would in AG Grid.</p>
<h2 id="what-you-actually-lose">What you actually lose</h2><p>Honest assessment: there are some things AG Grid does that SvGrid does not replicate exactly.</p>
<p>AG Grid Enterprise&#39;s range selection (spreadsheet-style multi-cell drag select) and the Excel-like fill handle are not in SvGrid yet. If your app uses those heavily, that is a real gap.</p>
<p>AG Grid&#39;s charting integration is a standalone module that we do not try to match. SvGrid has <code>SvGridChart</code> and <code>buildSparkline</code> for column-level data visualization, but not the full in-grid chart builder.</p>
<p>The flip side: SvGrid&#39;s Svelte-native rendering means your custom cell content is real Svelte, with access to stores, runes, and component composition. AG Grid&#39;s React renderer runs React inside a non-React context, which creates an invisible performance ceiling and awkward lifecycle interactions. That tradeoff disappears entirely in SvGrid.</p>
<h2 id="the-migration-order-that-works">The migration order that works</h2><p>Start with the simplest screens first - read-only tables with basic sorting. Get comfortable with the column def translation and the snippet pattern. Then move to editable screens. Server-side data adapters should be last because they require the most careful testing of edge cases (empty results, error states, filter combinations).</p>
<p>For conditional formatting - something many AG Grid users handle with <code>cellStyle</code> callbacks - SvGrid has a dedicated <code>conditionalFormat</code> field on the column definition that keeps the logic out of your renderers:</p>
<pre><code class="language-ts">{
  id: &#39;score&#39;,
  field: &#39;score&#39;,
  header: &#39;Score&#39;,
  conditionalFormat: [
    { condition: ({ value }) =&gt; Number(value) &lt; 50, style: { color: &#39;red&#39;, fontWeight: &#39;bold&#39; } },
    { condition: ({ value }) =&gt; Number(value) &gt;= 90, style: { color: &#39;green&#39; } },
  ],
}
</code></pre>
<p>The grid concepts are the same. The implementation is Svelte-native. Most teams are surprised by how little of their actual grid logic needs to change.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/porting-mui-x-datagrid-to-svelte/">Porting a React MUI X DataGrid Screen to Svelte</a></li>
<li><a href="https://svgrid.com/blog/migrating-from-datatables-to-svelte/">Migrating from DataTables.net to a Svelte Data Grid</a></li>
<li><a href="https://svgrid.com/blog/svelte-data-grid-comparisons/">Svelte Data Grid Comparisons and Alternatives (2026)</a></li>
<li><a href="https://svgrid.com/blog/svelte-headless-table-svelte-5-options/">svelte-headless-table and the Svelte 5 upgrade: your three options</a></li>
<li><a href="https://svgrid.com/blog/open-source-vs-commercial-svelte-grids/">Open-Source vs Commercial Svelte Data Grids</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Measuring Data Grid Performance in DevTools</title>
      <link>https://svgrid.com/blog/measuring-grid-performance-devtools/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/measuring-grid-performance-devtools/</guid>
      <pubDate>Mon, 10 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Kamelia M</dc:creator>
      <category>performance</category>
      <category>devtools</category>
      <category>profiling</category>
      <category>measurement</category>
      <category>recipe</category>
      <description>A practical profiling workflow for SvGrid - how to read the Performance panel, catch virtualization failures, diagnose layout thrash, and make changes that measurably help.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/measuring-grid-performance-devtools.png" width="1200" height="630" alt="" /></p><p>&quot;It feels slow&quot; is not a bug report, it is a hypothesis. Before you touch a single line of grid code, you need a number - a frame time, a node count, a millisecond budget. Without that number, you cannot know whether your fix helped, hurt, or just reshuffled the problem somewhere less visible.</p>
<p><img src="https://svgrid.com/blog-media/million-rows.png" alt="Measuring Data Grid Performance in DevTools"></p>
<p>Here is the workflow I actually use when a grid starts misbehaving under load.</p>
<h2 id="capture-a-baseline-before-anything-else">Capture a baseline before anything else</h2><p>Open Chrome DevTools, switch to the Performance tab, and set CPU throttle to 4x (found in the gear icon). This simulates a mid-range laptop and surfaces problems your developer machine hides. Then hit Record, do the exact interaction that feels slow - scroll from top to bottom, sort a column with 50 000 rows, type in a filter box - and stop after 5-8 seconds.</p>
<p>You are looking for three things in the recording:</p>
<ol>
<li><strong>Long tasks</strong> - any orange triangle on the main thread timeline marks a task over 50ms. These are the ones that block input response.</li>
<li><strong>Frame duration</strong> - hover over the green frame bars at the top. 60fps means 16.7ms per frame. If your scroll frames are hitting 80ms, you have dropped 4 out of 5.</li>
<li><strong>What fills the long frames</strong> - drill down into a bad frame. Yellow is scripting (JS), purple is rendering (style/layout), green is painting. The color that dominates tells you where to look first.</li>
</ol>
<p>Save the trace file (<code>Export profile</code> button). You will re-import it later to compare against your fix.</p>
<h2 id="dom-node-count-is-the-fastest-virtualization-check">DOM node count is the fastest virtualization check</h2><p>With 50 000 rows, SvGrid should only render the ~30-50 rows visible in the viewport at any moment. Pop open the Console and run:</p>
<pre><code class="language-js">document.querySelectorAll(&#39;[data-row-index]&#39;).length
</code></pre>
<p>Note the number, scroll halfway down, run it again. If the count is stable (say, 38 to 42 rows), virtualization is working. If it grows linearly with scroll position, the virtual scroller is not engaging.</p>
<p>The almost-universal cause: the grid container has no explicit height. The virtualizer measures the container to decide how many rows to render, and if it reads <code>0</code> or <code>auto</code>, it falls back to rendering everything.</p>
<pre><code class="language-svelte">&lt;!-- Wrong: the grid has no bounded height, so virtualization falls back --&gt;
&lt;div&gt;
  &lt;SvGrid {data} {columns} virtualization={true} /&gt;
&lt;/div&gt;

&lt;!-- Right: explicit height gives the virtualizer a real measurement --&gt;
&lt;div style=&quot;height: 600px; overflow: hidden;&quot;&gt;
  &lt;SvGrid {data} {columns} virtualization={true} /&gt;
&lt;/div&gt;
</code></pre>
<p>You can also pass <code>rowHeight</code> explicitly. When SvGrid knows the row height upfront, it skips a measurement pass per row during initial render:</p>
<pre><code class="language-svelte">&lt;SvGrid
  {data}
  {columns}
  rowHeight={34}
  virtualization={true}
  onApiReady={(api) =&gt; { gridApi = api }}
/&gt;
</code></pre>
<h2 id="reading-a-scripting-spike">Reading a scripting spike</h2><p>When yellow (scripting) dominates your bad frames, expand the flame chart to find the hot function. Two patterns show up constantly:</p>
<p><strong>Array allocation on every render.</strong> If you pass <code>data={rows.map(transform)}</code> as an inline expression, Svelte re-evaluates it every time any reactive state changes, creating a new array identity on every tick. Move the transform outside:</p>
<pre><code class="language-svelte">&lt;script&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import { tableFeatures, rowSortingFeature } from &#39;@svgrid/grid&#39;

  let rawRows = $state(fetchedData)

  // Bad: new array reference every render
  // &lt;SvGrid data={rawRows.map(enrichRow)} {columns} /&gt;

  // Good: derived updates only when rawRows changes
  const rows = $derived(rawRows.map(enrichRow))
&lt;/script&gt;

&lt;SvGrid data={rows} {columns} sortable /&gt;
</code></pre>
<p><strong>Cell components re-mounting.</strong> If your custom cell creates a Svelte component per row and those components mount/unmount on every scroll, you will see a wall of component lifecycle calls in the flame chart. Snippets are lighter than components for simple cells:</p>
<pre><code class="language-svelte">&lt;script&gt;
  import SvGrid, { type ColumnDef } from &#39;@svgrid/grid&#39;
  import { tableFeatures, rowSortingFeature, columnFilteringFeature } from &#39;@svgrid/grid&#39;

  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })

  const columns: ColumnDef&lt;typeof features, Product&gt;[] = [
    { id: &#39;name&#39;, field: &#39;name&#39;, header: &#39;Name&#39;, width: 220 },
    { id: &#39;stock&#39;, field: &#39;stock&#39;, header: &#39;Stock&#39;, width: 100, cell: stockCell },
    { id: &#39;price&#39;, field: &#39;price&#39;, header: &#39;Price&#39;, width: 110, type: &#39;number&#39; },
  ]
&lt;/script&gt;

{#snippet stockCell({ value })}
  &lt;span class:low={value &lt; 10} class:out={value === 0}&gt;{value}&lt;/span&gt;
{/snippet}

&lt;SvGrid data={products} {columns} {features} virtualization={true} rowHeight={34} /&gt;
</code></pre>
<p>A snippet is just a function call. A component is a full lifecycle - mount, update, destroy. For cells that render thousands of times per second during a scroll, that difference is not academic.</p>
<h2 id="forced-reflow-warnings">Forced reflow warnings</h2><p>If you see red &quot;Forced reflow&quot; annotations in the timeline, you have layout thrash: something reads a layout property (like <code>offsetHeight</code> or <code>getBoundingClientRect</code>) and then writes to the DOM, forcing the browser to flush and recalculate layout synchronously.</p>
<p>In grid code, this usually happens in a custom cell that reads the row element&#39;s dimensions and then applies a class. The fix is to separate reads and writes, or use a ResizeObserver instead of reading layout imperatively.</p>
<p>Check whether the reflow is inside your code or inside SvGrid itself. Expand the stack trace in the reflow warning. If it traces into your <code>cell</code> snippet, that is yours to fix. If it traces into grid internals, file an issue with the trace.</p>
<h2 id="compare-before-and-after">Compare before and after</h2><p>After making a change, re-record the same interaction - same scroll path, same duration, same throttle setting. Import the old trace in one DevTools window, open the new one alongside it, and compare:</p>
<ul>
<li>Average frame time during the scroll</li>
<li>Max long task duration</li>
<li>DOM node count stability</li>
</ul>
<p>If your change shortened average frame time from 80ms to 22ms, that is a real improvement. If the numbers are the same, you changed the wrong thing. This sounds obvious but most performance work skips this step and ends up with &quot;optimizations&quot; that do nothing measurable.</p>
<h2 id="a-quick-profiling-checklist">A quick profiling checklist</h2><p>Before filing a performance bug or spending a day optimizing:</p>
<ul>
<li>Confirmed CPU throttle was on during the profile (4x or 6x)</li>
<li>Checked DOM node count during scroll - stable or climbing?</li>
<li>Identified whether the bottleneck is yellow (scripting), purple (layout), or green (paint)</li>
<li>Verified the grid container has an explicit pixel height</li>
<li>Checked that <code>data</code> is not being reallocated on every tick</li>
<li>Compared a before/after trace with the same interaction</li>
</ul>
<p>Most grid performance problems fall into one of three buckets: missing container height breaking virtualization, rebuilding data arrays on every reactive update, or cell components that are heavier than they need to be. The flame chart tells you which bucket you are in, and then the fix is usually straightforward. Measurement is the part that takes discipline.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/lazy-loading-master-detail-content/">Lazy-Loading Master-Detail Content in SvGrid</a></li>
<li><a href="https://svgrid.com/blog/immutable-updates-without-killing-performance/">Immutable Grid Updates Without Killing Performance</a></li>
<li><a href="https://svgrid.com/blog/avoiding-layout-thrash-custom-cells/">Avoiding Layout Thrash in Custom Grid Cells</a></li>
<li><a href="https://svgrid.com/blog/virtualize-100k-rows/">Render 100,000 Rows Smoothly with Grid Virtualization</a></li>
<li><a href="https://svgrid.com/blog/performance-tips-with-runes/">Performance Tips for SvGrid with Svelte 5 Runes</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Building a Logistics / Fleet Tracking Grid in Svelte</title>
      <link>https://svgrid.com/blog/logistics-fleet-tracking-grid/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/logistics-fleet-tracking-grid/</guid>
      <pubDate>Sun, 09 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Kamelia M</dc:creator>
      <category>logistics</category>
      <category>fleet</category>
      <category>realtime</category>
      <category>use case</category>
      <category>svelte data grid</category>
      <description>How to build a live fleet operations grid with real-time telemetry updates, expandable trip history, and exception-first row styling.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/logistics-fleet-tracking-grid.png" width="1200" height="630" alt="" /></p><p>Fleet dispatchers don&#39;t browse data - they triage it. A vehicle goes offline at 3am and nobody notices until a delivery window is missed. The grid has one job: put the exception in front of the person who can fix it before the customer calls.</p>
<p>This post builds out a real fleet tracking grid: live telemetry updates, color-coded delay indicators, lazy-loaded trip history per vehicle, and a filter toggle that surfaces only the rows that need attention. Each piece is its own decision worth thinking through.</p>
<p><img src="https://svgrid.com/blog-media/industrial-dashboard.png" alt="An operations dashboard in SvGrid">
<em>A fleet operations board built with SvGrid.</em></p>
<h2 id="column-layout-and-left-pinned-identity">Column layout and left-pinned identity</h2><p>Start with what dispatchers need at a glance: vehicle ID, driver, current status, location, ETA, and how late they are. Vehicle and driver should be pinned left so they remain visible when the table scrolls horizontally on smaller screens.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import type { ColumnDef } from &#39;@svgrid/grid&#39;
  import type { Vehicle } from &#39;./types&#39;

  const columns: ColumnDef&lt;typeof features, Vehicle&gt;[] = [
    { id: &#39;vehicle&#39;, field: &#39;vehicleId&#39;, header: &#39;Vehicle&#39;, width: 120, pinned: &#39;left&#39; },
    { id: &#39;driver&#39;, field: &#39;driverName&#39;, header: &#39;Driver&#39;, width: 160, pinned: &#39;left&#39; },
    { id: &#39;status&#39;, field: &#39;status&#39;, header: &#39;Status&#39;, width: 110, cell: statusCell },
    { id: &#39;location&#39;, field: &#39;currentStop&#39;, header: &#39;Location&#39;, width: 200 },
    { id: &#39;eta&#39;, field: &#39;eta&#39;, header: &#39;ETA&#39;, width: 100, type: &#39;date&#39; },
    { id: &#39;delay&#39;, field: &#39;delayMinutes&#39;, header: &#39;Delay (min)&#39;, width: 110, type: &#39;number&#39;,
      conditionalFormat: [
        { condition: ({ value }) =&gt; value &gt; 60, style: { color: &#39;#dc2626&#39;, fontWeight: &#39;bold&#39; } },
        { condition: ({ value }) =&gt; value &gt; 15 &amp;&amp; value &lt;= 60, style: { color: &#39;#d97706&#39; } },
        { condition: ({ value }) =&gt; value &lt;= 0, style: { color: &#39;#16a34a&#39; } },
      ]
    },
    { id: &#39;lastSeen&#39;, field: &#39;lastUpdateAt&#39;, header: &#39;Last Update&#39;, width: 140, type: &#39;date&#39;,
      conditionalFormat: [
        { condition: ({ value }) =&gt; isStale(value), style: { color: &#39;#9ca3af&#39;, fontStyle: &#39;italic&#39; } },
      ]
    },
  ]

  function isStale(ts: string): boolean {
    return Date.now() - new Date(ts).getTime() &gt; 5 * 60 * 1000
  }
&lt;/script&gt;

{#snippet statusCell({ value }: { value: string })}
  &lt;span class=&quot;badge badge--{value.toLowerCase()}&quot;&gt;{value}&lt;/span&gt;
{/snippet}

&lt;SvGrid
  {data}
  {columns}
  sortable
  filterable
  rowHeight={36}
  virtualization={true}
  showFilterRow={true}
  onApiReady={(a) =&gt; { api = a }}
/&gt;
</code></pre>
<p>The <code>conditionalFormat</code> on the delay column does what a simple formatter can&#39;t: it changes the visual weight of the cell based on severity without any custom cell renderer. Red and bold for anything over an hour, amber for 15-60 minutes, green for on time or early.</p>
<h2 id="pushing-telemetry-updates-without-re-rendering-the-world">Pushing telemetry updates without re-rendering the world</h2><p>Telemetry arrives fast - typically one update per vehicle every 10-30 seconds for a mid-size fleet, but potentially hundreds per minute during route changes. The right pattern is to batch updates with <code>applyTransaction</code> rather than swapping the entire data array, which would force a full re-render and lose scroll position.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid, { type SvGridApi } from &#39;@svgrid/grid&#39;

  let api: SvGridApi

  // Connect to your telemetry source - WebSocket, SSE, or polling
  function connectTelemetry() {
    const ws = new WebSocket(&#39;/api/fleet/telemetry&#39;)

    let pending: Vehicle[] = []
    let rafId: number

    ws.onmessage = (event) =&gt; {
      const update: Vehicle = JSON.parse(event.data)
      pending.push(update)

      cancelAnimationFrame(rafId)
      rafId = requestAnimationFrame(() =&gt; {
        if (pending.length &gt; 0 &amp;&amp; api) {
          api.applyTransaction({ update: pending })
          pending = []
        }
      })
    }

    ws.onclose = () =&gt; setTimeout(connectTelemetry, 3000)
    return () =&gt; ws.close()
  }

  $effect(() =&gt; {
    return connectTelemetry()
  })
&lt;/script&gt;
</code></pre>
<p>Batching to the animation frame means no matter how many messages arrive between frames, the grid paints once. On a fleet of 2000 vehicles with updates coming in at 100/second, the difference between this and naive <code>.data = newData</code> is the difference between a usable app and a slideshow.</p>
<h2 id="trip-history-per-vehicle">Trip history per vehicle</h2><p>Expand a vehicle row to see its stops and completed legs. The detail panel should load lazily - pulling all trip history upfront would make the initial load slow and waste bandwidth on vehicles the dispatcher never expands.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid, { rowExpandingFeature, tableFeatures } from &#39;@svgrid/grid&#39;
  import type { ColumnDef } from &#39;@svgrid/grid&#39;

  const features = tableFeatures({ rowExpandingFeature })

  const tripColumns: ColumnDef&lt;typeof features, Trip&gt;[] = [
    { id: &#39;stop&#39;, field: &#39;stopName&#39;, header: &#39;Stop&#39;, width: 200 },
    { id: &#39;arrival&#39;, field: &#39;arrivedAt&#39;, header: &#39;Arrived&#39;, width: 140, type: &#39;date&#39; },
    { id: &#39;departure&#39;, field: &#39;departedAt&#39;, header: &#39;Departed&#39;, width: 140, type: &#39;date&#39; },
    { id: &#39;duration&#39;, field: &#39;durationMinutes&#39;, header: &#39;Duration (min)&#39;, width: 130, type: &#39;number&#39; },
    { id: &#39;status&#39;, field: &#39;stopStatus&#39;, header: &#39;Stop Status&#39;, width: 120, cell: stopStatusCell },
  ]

  async function loadTrips(vehicleId: string): Promise&lt;Trip[]&gt; {
    const res = await fetch(`/api/fleet/${vehicleId}/trips?limit=20`)
    if (!res.ok) throw new Error(&#39;Failed to load trips&#39;)
    return res.json()
  }
&lt;/script&gt;

{#snippet vehicleDetail({ row }: { row: Vehicle })}
  &lt;div class=&quot;trip-detail&quot;&gt;
    &lt;h4&gt;Recent trips - {row.vehicleId}&lt;/h4&gt;
    {#await loadTrips(row.vehicleId)}
      &lt;p class=&quot;loading&quot;&gt;Loading trip history...&lt;/p&gt;
    {:then trips}
      &lt;SvGrid data={trips} columns={tripColumns} rowHeight={32} /&gt;
    {:catch}
      &lt;p class=&quot;error&quot;&gt;Could not load trips. Try expanding again.&lt;/p&gt;
    {/await}
  &lt;/div&gt;
{/snippet}

&lt;SvGrid
  {data}
  {columns}
  features={features}
  detail={vehicleDetail}
  rowHeight={36}
  virtualization={true}
  onApiReady={(a) =&gt; { api = a }}
/&gt;
</code></pre>
<p>The <code>{:catch}</code> branch matters here. Trip history is a secondary request and network failures should degrade gracefully rather than crashing the detail panel.</p>
<h2 id="exception-first-filtering">Exception-first filtering</h2><p>The most requested feature in any ops grid is &quot;show me only what&#39;s broken.&quot; That means two things: a fast toggle to filter to exception rows only, and sensible default sort that puts the worst offenders at the top.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  let showExceptionsOnly = $state(false)

  function toggleExceptions() {
    showExceptionsOnly = !showExceptionsOnly
    if (showExceptionsOnly) {
      // Surface offline vehicles, late deliveries, and long idle times
      api.setFilter(&#39;status&#39;, { operator: &#39;in&#39;, value: [&#39;Offline&#39;, &#39;Idle&#39;] })
      api.setSort(&#39;delay&#39;, &#39;desc&#39;)
    } else {
      api.clearAllFilters()
    }
  }

  function filterByRegion(region: string | null) {
    if (region) {
      api.setFilter(&#39;region&#39;, { operator: &#39;equals&#39;, value: region })
    } else {
      api.clearFilter(&#39;region&#39;)
    }
  }
&lt;/script&gt;

&lt;div class=&quot;toolbar&quot;&gt;
  &lt;button
    class=&quot;exceptions-toggle&quot;
    class:active={showExceptionsOnly}
    onclick={toggleExceptions}
  &gt;
    {showExceptionsOnly ? &#39;Show all&#39; : &#39;Exceptions only&#39;}
  &lt;/button&gt;

  &lt;select onchange={(e) =&gt; filterByRegion(e.currentTarget.value || null)}&gt;
    &lt;option value=&quot;&quot;&gt;All regions&lt;/option&gt;
    {#each regions as region}
      &lt;option value={region.id}&gt;{region.name}&lt;/option&gt;
    {/each}
  &lt;/select&gt;
&lt;/div&gt;
</code></pre>
<p>The <code>in</code> filter operator on the status column is the right tool here - you want offline AND idle, not just one. Pairing it with a sort on delay descending means the most critical situation is always row one when the filter is active.</p>
<h2 id="running-server-side-for-large-fleets">Running server-side for large fleets</h2><p>A national fleet with 5000+ vehicles can&#39;t run client-side. Set up server-side data with <code>createServerDataSource</code> and filter by depot or region at the API level, pushing live updates only for the vehicles currently in the visible page.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid, { createServerDataSource } from &#39;@svgrid/grid&#39;

  let depotId = $state&lt;string | null&gt;(null)

  const ds = createServerDataSource({
    fetch: async ({ page, pageSize, sort, filters }) =&gt; {
      const params = new URLSearchParams({
        page: String(page),
        size: String(pageSize),
        ...(depotId ? { depot: depotId } : {}),
        ...(sort.length ? { sortField: sort[0].field, sortDir: sort[0].dir } : {}),
      })
      for (const f of filters) {
        params.set(`filter_${f.field}`, f.value)
      }
      const res = await fetch(`/api/fleet/vehicles?${params}`)
      const json = await res.json()
      return { rows: json.vehicles, total: json.total }
    }
  })
&lt;/script&gt;

&lt;SvGrid
  data={ds}
  {columns}
  pageable
  sortable
  filterable
  rowHeight={36}
  onApiReady={(a) =&gt; { api = a }}
/&gt;
</code></pre>
<p>With server-side paging you can push WebSocket updates scoped to the current depot or region - no point streaming telemetry for 4800 vehicles when the dispatcher is looking at a 200-vehicle regional view.</p>
<h2 id="stale-gps-as-a-data-quality-signal">Stale GPS as a data quality signal</h2><p>One thing that catches people off guard: a vehicle showing &quot;Moving&quot; with a 20-minute-old GPS timestamp isn&#39;t moving - the GPS unit is failing or the vehicle drove into a tunnel. The <code>isStale</code> check in the <code>lastSeen</code> column above flags this visually. You can go further and add a filter preset for &quot;stale GPS&quot; that combines a timestamp age filter with status != Offline. Dispatchers often don&#39;t know to look for this, but once they see it highlighted they start relying on it heavily.</p>
<p>The fleet tracking grid is one of those cases where the data model is straightforward but the operational requirements shape almost every display decision. Exception-first layout, stale signal detection, and lazy trip history aren&#39;t nice-to-haves - they&#39;re what makes the difference between a grid that dispatchers keep open all day and one that gets replaced by a phone call.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/real-time-trading-grid/">Building a Real-Time Trading Grid in Svelte</a></li>
<li><a href="https://svgrid.com/blog/iot-sensor-dashboard/">Building an IoT Sensor Dashboard in Svelte</a></li>
<li><a href="https://svgrid.com/blog/project-task-board-grid/">Building a Project / Task Board with a Svelte Data Grid</a></li>
<li><a href="https://svgrid.com/blog/order-management-dashboard/">Building an Order Management Dashboard in Svelte</a></li>
<li><a href="https://svgrid.com/blog/log-viewer-large-logs/">Building a Log Viewer for Large Logs in Svelte</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Building a Log Viewer for Large Logs in Svelte</title>
      <link>https://svgrid.com/blog/log-viewer-large-logs/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/log-viewer-large-logs/</guid>
      <pubDate>Sat, 08 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Victor Vidolov</dc:creator>
      <category>logs</category>
      <category>virtualization</category>
      <category>large data</category>
      <category>use case</category>
      <category>svelte data grid</category>
      <description>How to build a production-ready log viewer with SvGrid - virtualization for millions of lines, severity coloring, live tailing with scroll-lock, and server-side filtering.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/log-viewer-large-logs.png" width="1200" height="630" alt="" /></p><p>Most grid demos show 1,000 rows of fake employee data. A log viewer is the opposite: real production logs routinely run into the hundreds of thousands of lines, spike to millions during incidents, and keep arriving in real time while someone is actively scrolling through them. If your grid can&#39;t handle that without choking the main thread, it&#39;s not useful for this case.</p>
<p>SvGrid handles it well because virtualization is on by default and the live-update path is fast. Here&#39;s a complete blueprint.</p>
<p><img src="https://svgrid.com/blog-media/large-dataset.png" alt="A large dataset in SvGrid">
<em>Virtualization keeps the DOM bounded regardless of how many log lines are buffered.</em></p>
<h2 id="column-layout-that-actually-works-for-logs">Column layout that actually works for logs</h2><p>The column shape matters. Timestamp, level, source, and message are the four you always need, and each has a specific display concern:</p>
<pre><code class="language-svelte">&lt;script&gt;
  import SvGrid from &#39;@svgrid/grid&#39;

  const columns = [
    {
      id: &#39;ts&#39;,
      field: &#39;ts&#39;,
      header: &#39;Timestamp&#39;,
      width: 185,
      pinned: &#39;left&#39;,
      cell: tsSnippet,
    },
    {
      id: &#39;level&#39;,
      field: &#39;level&#39;,
      header: &#39;Level&#39;,
      width: 80,
      cell: levelSnippet,
      conditionalFormat: [
        { condition: ({ value }) =&gt; value === &#39;ERROR&#39;, style: { background: &#39;#3b0000&#39;, color: &#39;#ff6b6b&#39; } },
        { condition: ({ value }) =&gt; value === &#39;WARN&#39;,  style: { background: &#39;#2a1a00&#39;, color: &#39;#ffa94d&#39; } },
        { condition: ({ value }) =&gt; value === &#39;DEBUG&#39;, style: { color: &#39;#666&#39; } },
      ],
    },
    {
      id: &#39;source&#39;,
      field: &#39;source&#39;,
      header: &#39;Source&#39;,
      width: 160,
    },
    {
      id: &#39;message&#39;,
      field: &#39;message&#39;,
      header: &#39;Message&#39;,
      width: 600,
    },
  ]
&lt;/script&gt;

{#snippet tsSnippet({ value })}
  &lt;span style=&quot;font-family: monospace; font-size: 12px&quot;&gt;{value}&lt;/span&gt;
{/snippet}

{#snippet levelSnippet({ value })}
  &lt;span class=&quot;level-badge level-{value?.toLowerCase()}&quot;&gt;{value}&lt;/span&gt;
{/snippet}

&lt;SvGrid
  data={logBuffer}
  {columns}
  virtualization={true}
  rowHeight={28}
  filterable
  showFilterRow={true}
  showGlobalFilter={true}
  onApiReady={(a) =&gt; { api = a }}
/&gt;
</code></pre>
<p>Pin the timestamp left. The message column should be wide - people scan it with their eyes horizontally, and truncation at 200px makes the grid nearly useless during debugging. If you want full message text on demand, expanding the row beats a tooltip for logs because messages can be 2,000 characters of stack trace.</p>
<h2 id="why-uniform-row-height-is-non-negotiable-at-scale">Why uniform row height is non-negotiable at scale</h2><p>Virtualization works by calculating which rows are visible based on scroll position and row height. If every row has a different height, the grid has to measure every row above the viewport to know the scroll offset - which defeats the whole point at a million rows.</p>
<p>Set <code>rowHeight</code> to a fixed pixel value. Use monospace for timestamps so they don&#39;t reflow. If you need expandable rows for full stack traces, that&#39;s fine - just keep the collapsed state at a uniform height. The performance difference between variable-height and fixed-height virtualization at 500k rows is not subtle.</p>
<h2 id="severity-coloring-without-a-custom-renderer">Severity coloring without a custom renderer</h2><p>The <code>conditionalFormat</code> array on each column applies CSS styles inline based on cell value. This is fast because it runs during render, not in a separate pass. Coloring the level column handles the badge; if you also want to tint the whole row, apply the same conditions at the row level:</p>
<pre><code class="language-svelte">&lt;script&gt;
  import SvGrid from &#39;@svgrid/grid&#39;

  const rowClass = ({ row }) =&gt; {
    if (row.level === &#39;ERROR&#39;) return &#39;row-error&#39;
    if (row.level === &#39;WARN&#39;)  return &#39;row-warn&#39;
    return &#39;&#39;
  }
&lt;/script&gt;

&lt;SvGrid
  data={logBuffer}
  {columns}
  {rowClass}
  virtualization={true}
  rowHeight={28}
/&gt;

&lt;style&gt;
  :global(.row-error) { background-color: rgba(180, 0, 0, 0.08) !important; }
  :global(.row-warn)  { background-color: rgba(180, 100, 0, 0.06) !important; }
&lt;/style&gt;
</code></pre>
<p>The first thing anyone does when opening a log viewer is filter to errors. Make that painless - a dedicated level filter in the filter row plus a one-click &quot;errors only&quot; button wired to <code>api.setFilter('level', { operator: 'equals', value: 'ERROR' })</code> covers 80% of the use cases before they even type anything.</p>
<h2 id="server-side-filtering-for-very-large-datasets">Server-side filtering for very large datasets</h2><p>For a live system, you don&#39;t want to buffer millions of rows in the browser. You want to query the log backend and page through results. <code>createServerDataSource</code> handles this:</p>
<pre><code class="language-svelte">&lt;script&gt;
  import SvGrid, { createServerDataSource } from &#39;@svgrid/grid&#39;

  const ds = createServerDataSource({
    fetch: async ({ page, pageSize, sort, filters }) =&gt; {
      const params = new URLSearchParams({
        page: String(page),
        size: String(pageSize),
      })

      for (const [field, filter] of Object.entries(filters ?? {})) {
        if (filter?.value) params.set(field, filter.value)
      }

      if (sort?.length) {
        params.set(&#39;sort_field&#39;, sort[0].id)
        params.set(&#39;sort_dir&#39;, sort[0].desc ? &#39;desc&#39; : &#39;asc&#39;)
      }

      const res = await fetch(`/api/logs?${params}`)
      const json = await res.json()
      return { rows: json.entries, total: json.total }
    }
  })
&lt;/script&gt;

&lt;SvGrid
  data={ds}
  {columns}
  filterable
  pageable
  showFilterRow={true}
/&gt;
</code></pre>
<p>The filter state flows directly from the grid UI into your fetch parameters. Add debounce on the text inputs if your backend can&#39;t handle a query per keystroke.</p>
<h2 id="live-tailing-without-yanking-the-user">Live tailing without yanking the user</h2><p>Tailing - new lines appending at the bottom while someone is scrolled up reading old lines - is the hardest part to get right. The failure mode is aggressive: the grid auto-scrolls to the bottom on every new batch, interrupting whoever is investigating an issue. The correct behavior is:</p>
<ul>
<li>If the user is at (or very near) the bottom, auto-scroll to follow new lines.</li>
<li>If they have scrolled up at all, stop auto-scrolling. Let them read.</li>
<li>Resume auto-scroll if they scroll back to the bottom themselves.</li>
</ul>
<pre><code class="language-svelte">&lt;script&gt;
  import SvGrid from &#39;@svgrid/grid&#39;

  let api = $state(null)
  let logBuffer = $state([])
  let userScrolledUp = false
  let raf = null

  function onScroll(event) {
    const el = event.target
    const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight &lt; 40
    userScrolledUp = !atBottom
  }

  function appendLogs(newLines) {
    if (raf) cancelAnimationFrame(raf)
    raf = requestAnimationFrame(() =&gt; {
      logBuffer = [...logBuffer.slice(-50000), ...newLines]
      if (!userScrolledUp &amp;&amp; api) {
        api.scrollToRow(logBuffer.length - 1)
      }
    })
  }

  // Wire to your WebSocket or EventSource
  const ws = new WebSocket(&#39;/ws/logs&#39;)
  ws.addEventListener(&#39;message&#39;, (e) =&gt; {
    const batch = JSON.parse(e.data)
    appendLogs(batch)
  })
&lt;/script&gt;

&lt;div class=&quot;log-container&quot; on:scroll={onScroll}&gt;
  &lt;SvGrid
    data={logBuffer}
    {columns}
    virtualization={true}
    rowHeight={28}
    filterable
    onApiReady={(a) =&gt; { api = a }}
  /&gt;
&lt;/div&gt;

&lt;style&gt;
  .log-container {
    height: 100vh;
    overflow: auto;
  }
&lt;/style&gt;
</code></pre>
<p>The <code>slice(-50000)</code> caps the in-memory buffer. Without a cap, a process that logs at 10k lines per minute will consume gigabytes of RAM over hours. 50,000 lines is usually enough history for incident investigation; adjust based on what your backend already retains and queryable.</p>
<p>Batching to <code>requestAnimationFrame</code> is critical when log velocity is high. A firehose pushing 200 messages per second would trigger 200 Svelte state updates per second without batching, which kills performance. Coalescing those into one update per frame keeps the grid smooth.</p>
<h2 id="the-filter-youll-add-last-but-users-ask-for-first">The filter you&#39;ll add last but users ask for first</h2><p>Time range filtering. Most log backends support <code>from</code> and <code>to</code> timestamp parameters, and users almost always want &quot;show me the 10 minutes around when the alert fired.&quot; Add two datetime inputs bound to filter state and wire them to your server-side fetch or to a client-side filter on the timestamp field:</p>
<pre><code class="language-svelte">&lt;script&gt;
  let fromTs = $state(&#39;&#39;)
  let toTs = $state(&#39;&#39;)

  $effect(() =&gt; {
    if (!api) return
    if (fromTs) {
      api.setFilter(&#39;ts&#39;, { operator: &#39;gte&#39;, value: fromTs })
    } else {
      api.clearFilter?.(&#39;ts&#39;)
    }
  })
&lt;/script&gt;

&lt;div class=&quot;toolbar&quot;&gt;
  &lt;input type=&quot;datetime-local&quot; bind:value={fromTs} /&gt;
  &lt;input type=&quot;datetime-local&quot; bind:value={toTs} /&gt;
  &lt;button onclick={() =&gt; api.setFilter(&#39;level&#39;, { operator: &#39;equals&#39;, value: &#39;ERROR&#39; })}&gt;
    Errors only
  &lt;/button&gt;
  &lt;button onclick={() =&gt; api.clearAllFilters()}&gt;Clear filters&lt;/button&gt;
&lt;/div&gt;
</code></pre>
<p>The &quot;errors only&quot; button and clear filters are worth adding as dedicated UI elements rather than relying on the filter row alone. During a production incident, people are moving fast and don&#39;t want to type.</p>
<h2 id="what-actually-breaks-at-scale">What actually breaks at scale</h2><p>A few things that will bite you in production that aren&#39;t obvious from a demo:</p>
<p>Timestamps with milliseconds tend to be stored as strings in logs, and string comparison sorts them lexicographically correctly only if the format is ISO 8601 with zero-padded fields. If your backend sends <code>1/5/2026 9:04:03 AM</code>, sorting breaks. Parse to Date objects or to epoch milliseconds before putting them in the buffer.</p>
<p>Message columns with ANSI escape codes from terminal output look like noise: <code>\x1b[32mINFO\x1b[0m connected</code>. Strip escape codes server-side or in a column <code>valueFormatter</code> before render.</p>
<p>If you&#39;re rendering 10k rows with conditionalFormat on every column, measure. Conditional formatting runs a comparison per cell per render. For a log viewer where most rows are INFO, short-circuiting on the common case (checking for ERROR first) keeps the hot path fast.</p>
<p>The grid itself isn&#39;t the bottleneck at typical log volumes. The bottleneck is usually JSON.parse on the WebSocket messages, or Svelte reactivity triggered on every individual append. Batching at the data layer - not at the grid layer - is where you get the performance back.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/virtualize-100k-rows/">Render 100,000 Rows Smoothly with Grid Virtualization</a></li>
<li><a href="https://svgrid.com/blog/real-time-trading-grid/">Building a Real-Time Trading Grid in Svelte</a></li>
<li><a href="https://svgrid.com/blog/project-task-board-grid/">Building a Project / Task Board with a Svelte Data Grid</a></li>
<li><a href="https://svgrid.com/blog/order-management-dashboard/">Building an Order Management Dashboard in Svelte</a></li>
<li><a href="https://svgrid.com/blog/logistics-fleet-tracking-grid/">Building a Logistics / Fleet Tracking Grid in Svelte</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Lazy-Loading Master-Detail Content in SvGrid</title>
      <link>https://svgrid.com/blog/lazy-loading-master-detail-content/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/lazy-loading-master-detail-content/</guid>
      <pubDate>Fri, 07 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Kamelia M</dc:creator>
      <category>performance</category>
      <category>master detail</category>
      <category>lazy loading</category>
      <category>recipe</category>
      <category>svelte data grid</category>
      <description>Fetch detail-panel data only when a row is expanded, cache the results, and cancel abandoned requests - keeping a large grid fast without paying for panels no one views.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/lazy-loading-master-detail-content.png" width="1200" height="630" alt="" /></p><p>A grid with 2,000 orders should not fire 2,000 API calls. Yet that is exactly what happens when detail panels load up front. The fix is straightforward: fetch only when someone actually expands a row, cache results so collapsing and re-expanding costs nothing, and cancel any in-flight request if the user changes their mind before data arrives.</p>
<p>This pattern is not complex, but there are a few places to get it wrong. Here is a solid recipe that handles all three concerns.</p>
<p><img src="https://svgrid.com/blog-media/lazy-tree.png" alt="Lazy-loaded tree branches in SvGrid">
<em>Detail data fetched only on expand, cached on subsequent opens.</em></p>
<h2 id="why-up-front-loading-fails-at-scale">Why up-front loading fails at scale</h2><p>The intuitive approach is to prepare detail data for every row when the grid initializes. It works fine on a demo dataset of 20 rows. At 500 rows it bogs down the page load. At 5,000 it makes the grid unusable.</p>
<p>The root problem is that a user typically opens a handful of rows - rarely more than a dozen in a session. Loading detail for every row to support those few is wasteful by definition. The fix is demand-driven loading: nothing fetches until the row actually opens.</p>
<h2 id="fetching-on-expand-with-sveltes-await-block">Fetching on expand with Svelte&#39;s await block</h2><p>SvGrid passes a <code>row</code> object into your detail snippet. That is the trigger point. Wrap the fetch in <code>{#await}</code> and Svelte handles the three states - loading, success, and error - without any extra state variables:</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import type { ColumnDef } from &#39;@svgrid/grid&#39;
  import type { Order, LineItem } from &#39;$lib/types&#39;

  const orderColumns: ColumnDef[] = [
    { id: &#39;id&#39;,       field: &#39;id&#39;,       header: &#39;Order ID&#39;, width: 120 },
    { id: &#39;customer&#39;, field: &#39;customer&#39;, header: &#39;Customer&#39;,  width: 200 },
    { id: &#39;total&#39;,    field: &#39;total&#39;,    header: &#39;Total&#39;,     width: 100, type: &#39;number&#39; },
    { id: &#39;status&#39;,   field: &#39;status&#39;,   header: &#39;Status&#39;,    width: 120 },
  ]

  const lineItemColumns: ColumnDef[] = [
    { id: &#39;sku&#39;,      field: &#39;sku&#39;,      header: &#39;SKU&#39;,       width: 140 },
    { id: &#39;name&#39;,     field: &#39;name&#39;,     header: &#39;Product&#39;,   width: 240 },
    { id: &#39;qty&#39;,      field: &#39;qty&#39;,      header: &#39;Qty&#39;,       width: 80,  type: &#39;number&#39; },
    { id: &#39;price&#39;,    field: &#39;price&#39;,    header: &#39;Unit Price&#39;, width: 110, type: &#39;number&#39; },
  ]

  const { data: orders } = $props&lt;{ data: Order[] }&gt;()
&lt;/script&gt;

{#snippet detailPanel({ row }: { row: Order })}
  {#await fetchLineItems(row.id)}
    &lt;div class=&quot;detail-placeholder&quot;&gt;
      &lt;span class=&quot;spinner&quot;&gt;&lt;/span&gt; Loading line items...
    &lt;/div&gt;
  {:then items}
    &lt;div class=&quot;detail-inner&quot;&gt;
      &lt;SvGrid data={items} columns={lineItemColumns} rowHeight={28} /&gt;
    &lt;/div&gt;
  {:catch err}
    &lt;div class=&quot;detail-error&quot;&gt;
      Failed to load. &lt;button onclick={() =&gt; retryFor(row.id)}&gt;Try again&lt;/button&gt;
    &lt;/div&gt;
  {/await}
{/snippet}

&lt;SvGrid
  data={orders}
  columns={orderColumns}
  rowHeight={36}
  detail={detailPanel}
/&gt;
</code></pre>
<p>The grid renders immediately. <code>fetchLineItems</code> does not run until the user expands a row. Svelte&#39;s reactive <code>{#await}</code> block takes care of the rest.</p>
<h2 id="caching-so-the-second-open-is-instant">Caching so the second open is instant</h2><p>The snippet above re-fetches every time a row is expanded. Collapsing and re-opening the same order should not round-trip to the server again. The cleanest way to prevent that is to cache the promise, not the result:</p>
<pre><code class="language-ts">// lib/line-item-cache.ts
const cache = new Map&lt;string, Promise&lt;LineItem[]&gt;&gt;()

export function fetchLineItems(orderId: string): Promise&lt;LineItem[]&gt; {
  if (!cache.has(orderId)) {
    cache.set(orderId, fetchFromApi(orderId))
  }
  return cache.get(orderId)!
}

async function fetchFromApi(orderId: string): Promise&lt;LineItem[]&gt; {
  const res = await fetch(`/api/orders/${orderId}/line-items`)
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  return res.json()
}

// Call this if the user edits an order and you need to bust the cache
export function invalidate(orderId: string) {
  cache.delete(orderId)
}
</code></pre>
<p>Caching the promise (not the resolved data) means two expansions that fire before the first fetch completes both get the same promise - no duplicate requests. It is a subtle point that matters in grids where someone can expand rows rapidly.</p>
<h2 id="cancelling-abandoned-requests">Cancelling abandoned requests</h2><p>Users scroll fast. Someone might expand row 47, start reading, then collapse it and jump to row 120. If the line-items request for row 47 is still in flight, it is waste. Cancel it.</p>
<pre><code class="language-ts">// lib/line-item-cache.ts (revised to support cancellation)
const cache = new Map&lt;string, Promise&lt;LineItem[]&gt;&gt;()
const controllers = new Map&lt;string, AbortController&gt;()

export function fetchLineItems(orderId: string): Promise&lt;LineItem[]&gt; {
  if (cache.has(orderId)) return cache.get(orderId)!

  const controller = new AbortController()
  controllers.set(orderId, controller)

  const promise = fetch(`/api/orders/${orderId}/line-items`, {
    signal: controller.signal,
  })
    .then(res =&gt; {
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      return res.json() as Promise&lt;LineItem[]&gt;
    })
    .finally(() =&gt; {
      controllers.delete(orderId)
    })

  cache.set(orderId, promise)
  return promise
}

export function cancelFetch(orderId: string) {
  controllers.get(orderId)?.abort()
  // Remove from cache so a future expand retries cleanly
  cache.delete(orderId)
}

export function invalidate(orderId: string) {
  cancelFetch(orderId)
}
</code></pre>
<p>Wire <code>cancelFetch</code> into a collapse handler if you want strict cancellation. In practice, aborting only matters for slow connections or very large payloads - for sub-200ms API responses, the extra plumbing may not be worth it. Know your p99 latency before adding complexity.</p>
<h2 id="how-this-interacts-with-row-virtualization">How this interacts with row virtualization</h2><p>SvGrid virtualizes rows by default. Only the rows currently visible in the viewport render - detail snippets included. That means an expanded row that scrolls out of view unmounts its detail panel, and when it scrolls back in, <code>{#await}</code> re-runs. With caching in place, re-running hits the cache immediately and the panel appears without a flash or refetch.</p>
<p>Without caching, every re-enter into the viewport triggers a fresh request. On a fast connection you might not notice. On a slow one, or when the detail panel contains a nested grid that itself needs settling time, the repeated fetch becomes visible jank. Cache the promise.</p>
<h2 id="nested-grids-inside-detail-panels">Nested grids inside detail panels</h2><p>If the detail content is itself a <code>&lt;SvGrid&gt;</code> (orders -&gt; line items, accounts -&gt; transactions), there is one sizing concern worth knowing: the outer grid does not know the inner grid&#39;s height until it renders. Set an explicit height on the detail panel container so the outer grid can allocate space cleanly:</p>
<pre><code class="language-svelte">{#snippet detailPanel({ row }: { row: Order })}
  {#await fetchLineItems(row.id)}
    &lt;div style=&quot;height: 160px;&quot; class=&quot;detail-placeholder&quot;&gt;Loading...&lt;/div&gt;
  {:then items}
    &lt;div style=&quot;height: 160px; overflow: hidden;&quot;&gt;
      &lt;SvGrid data={items} columns={lineItemColumns} rowHeight={28} /&gt;
    &lt;/div&gt;
  {:catch}
    &lt;div style=&quot;height: 40px;&quot; class=&quot;detail-error&quot;&gt;Load failed.&lt;/div&gt;
  {/await}
{/snippet}
</code></pre>
<p>A fixed height also avoids layout shifts when data arrives - the outer grid reserves the space during the loading state and nothing reflows when items populate the inner grid.</p>
<h2 id="when-to-skip-caching">When to skip caching</h2><p>Short sessions, frequently updated data, or detail panels where freshness matters more than speed. If your order line items can change in the seconds between a user opening and closing a row, stale cache is a real risk. In that case, either skip the cache entirely or pair it with a short TTL:</p>
<pre><code class="language-ts">const cache = new Map&lt;string, { promise: Promise&lt;LineItem[]&gt;, ts: number }&gt;()
const TTL = 30_000 // 30 seconds

export function fetchLineItems(orderId: string): Promise&lt;LineItem[]&gt; {
  const entry = cache.get(orderId)
  if (entry &amp;&amp; Date.now() - entry.ts &lt; TTL) return entry.promise

  const promise = fetch(`/api/orders/${orderId}/line-items`).then(r =&gt; r.json())
  cache.set(orderId, { promise, ts: Date.now() })
  return promise
}
</code></pre>
<p>Thirty seconds is usually enough to cover the &quot;collapse and immediately re-open&quot; case while keeping data reasonably fresh. Adjust based on how often your backend data actually changes.</p>
<p>The core principle stays constant: pay for only what the user looks at, serve it instantly if they look again, and clean up what they walked away from.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/immutable-updates-without-killing-performance/">Immutable Grid Updates Without Killing Performance</a></li>
<li><a href="https://svgrid.com/blog/measuring-grid-performance-devtools/">Measuring Data Grid Performance in DevTools</a></li>
<li><a href="https://svgrid.com/blog/avoiding-layout-thrash-custom-cells/">Avoiding Layout Thrash in Custom Grid Cells</a></li>
<li><a href="https://svgrid.com/blog/virtualize-100k-rows/">Render 100,000 Rows Smoothly with Grid Virtualization</a></li>
<li><a href="https://svgrid.com/blog/performance-tips-with-runes/">Performance Tips for SvGrid with Svelte 5 Runes</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Building an IoT Sensor Dashboard in Svelte</title>
      <link>https://svgrid.com/blog/iot-sensor-dashboard/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/iot-sensor-dashboard/</guid>
      <pubDate>Thu, 06 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Victor Vidolov</dc:creator>
      <category>iot</category>
      <category>sensors</category>
      <category>realtime</category>
      <category>use case</category>
      <category>svelte data grid</category>
      <description>How to build a live IoT sensor dashboard with SvGrid - high-frequency updates, sparkline trends, threshold alerts, and stale-device detection that all stay smooth at scale.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/iot-sensor-dashboard.png" width="1200" height="630" alt="" /></p><p>A factory floor with 800 sensors reporting every two seconds gives you 1,600 row updates per second. A naive grid implementation will drop frames, leak memory, or just lock the browser. The grid has to be smarter than a spreadsheet.</p>
<p><img src="https://svgrid.com/blog-media/live-dashboard.png" alt="A live dashboard grid in SvGrid">
<em>A live, updating dashboard grid in SvGrid.</em></p>
<p>This post walks through the actual architecture for an IoT dashboard: batched updates keyed by device ID, sparkline cells tracking recent history, conditional formatting for threshold breaches, and stale-sensor detection. These are the pieces that distinguish a real production dashboard from a demo.</p>
<h2 id="why-raw-websocket-events-will-wreck-your-frame-rate">Why raw websocket events will wreck your frame rate</h2><p>The instinct is to wire a WebSocket directly to the grid: message arrives, call <code>api.applyTransaction({ update: [row] })</code>, done. This works fine at a handful of updates per second. It falls apart when sensors report fast enough that you&#39;re calling <code>applyTransaction</code> 50 times between two animation frames.</p>
<p>The fix is a pending-updates map keyed by sensor ID. New readings overwrite old ones in the map, and a single <code>requestAnimationFrame</code> callback drains the whole map in one transaction per frame. You never render more than once per frame regardless of update volume.</p>
<pre><code class="language-ts">import { type SvGridApi } from &#39;@svgrid/grid&#39;

let api: SvGridApi

// called by your WebSocket message handler
const pending = new Map&lt;string, SensorRow&gt;()
let flushScheduled = false

function onReading(reading: SensorRow) {
  pending.set(reading.id, reading)
  if (!flushScheduled) {
    flushScheduled = true
    requestAnimationFrame(flush)
  }
}

function flush() {
  flushScheduled = false
  if (!api || pending.size === 0) return

  const updates = [...pending.values()]
  pending.clear()

  api.applyTransaction({ update: updates })
}
</code></pre>
<p>With 800 sensors all firing at 2 Hz, you still only call <code>applyTransaction</code> 60 times per second, each batch containing however many updates arrived since the last frame. The grid renders once, not 1,600 times.</p>
<h2 id="column-layout-for-sensor-data">Column layout for sensor data</h2><p>The column structure matters. Sensor dashboards are scanned visually, so information density and visual hierarchy both need attention.</p>
<pre><code class="language-ts">import SvGrid from &#39;@svgrid/grid&#39;
import {
  type ColumnDef,
  tableFeatures,
  rowSortingFeature,
  columnFilteringFeature,
  rowSelectionFeature,
  buildSparkline,
  resolveCellFormat,
} from &#39;@svgrid/grid&#39;

type SensorRow = {
  id: string
  name: string
  zone: string
  value: number
  unit: string
  history: number[]
  status: &#39;ok&#39; | &#39;warning&#39; | &#39;critical&#39;
  lastSeen: number // unix ms
}

const features = tableFeatures({
  rowSortingFeature,
  columnFilteringFeature,
  rowSelectionFeature,
})

const columns: ColumnDef&lt;typeof features, SensorRow&gt;[] = [
  {
    id: &#39;name&#39;,
    field: &#39;name&#39;,
    header: &#39;Sensor&#39;,
    width: 200,
    pinned: &#39;left&#39;,
  },
  {
    id: &#39;zone&#39;,
    field: &#39;zone&#39;,
    header: &#39;Zone&#39;,
    width: 120,
  },
  {
    id: &#39;value&#39;,
    field: &#39;value&#39;,
    header: &#39;Reading&#39;,
    width: 110,
    type: &#39;number&#39;,
    cell: valueCellSnippet,
    conditionalFormat: [
      {
        condition: ({ row }) =&gt; row.original.status === &#39;critical&#39;,
        style: { color: &#39;#c0392b&#39;, fontWeight: &#39;bold&#39; },
      },
      {
        condition: ({ row }) =&gt; row.original.status === &#39;warning&#39;,
        style: { color: &#39;#e67e22&#39; },
      },
    ],
  },
  {
    id: &#39;trend&#39;,
    field: &#39;history&#39;,
    header: &#39;Trend&#39;,
    width: 120,
    cell: ({ value }) =&gt; buildSparkline(value, { color: &#39;#3b82f6&#39;, height: 28 }),
  },
  {
    id: &#39;status&#39;,
    field: &#39;status&#39;,
    header: &#39;Status&#39;,
    width: 100,
    cell: statusCellSnippet,
  },
  {
    id: &#39;lastSeen&#39;,
    field: &#39;lastSeen&#39;,
    header: &#39;Last Seen&#39;,
    width: 130,
    cell: lastSeenCellSnippet,
  },
]
</code></pre>
<p>Pin the sensor name left so it stays anchored while users scroll through data columns. The trend sparkline renders inline from the rolling history array - every update appends the new value and trims the oldest, so the cell always shows the last N readings without extra state.</p>
<h2 id="threshold-alerts-with-conditional-row-styling">Threshold alerts with conditional row styling</h2><p>Color alone is not enough for alerts - about 8% of men have some form of color blindness, and dashboards are often viewed in bright ambient light. Pair color with a status badge.</p>
<pre><code class="language-svelte">{#snippet statusCellSnippet({ value })}
  &lt;span
    class=&quot;status-badge&quot;
    class:ok={value === &#39;ok&#39;}
    class:warning={value === &#39;warning&#39;}
    class:critical={value === &#39;critical&#39;}
  &gt;
    {#if value === &#39;critical&#39;}
      Critical
    {:else if value === &#39;warning&#39;}
      Warning
    {:else}
      OK
    {/if}
  &lt;/span&gt;
{/snippet}

{#snippet valueCellSnippet({ value, row })}
  &lt;span&gt;{value} {row.original.unit}&lt;/span&gt;
{/snippet}

{#snippet lastSeenCellSnippet({ value })}
  {@const age = Date.now() - value}
  {@const isStale = age &gt; 30_000}
  &lt;span class:stale={isStale}&gt;
    {isStale ? `${Math.round(age / 1000)}s ago` : &#39;live&#39;}
  &lt;/span&gt;
{/snippet}

&lt;style&gt;
  .status-badge { padding: 2px 8px; border-radius: 4px; font-size: 0.8em; }
  .ok    { background: #d1fae5; color: #065f46; }
  .warning  { background: #fef3c7; color: #92400e; }
  .critical { background: #fee2e2; color: #7f1d1d; }
  .stale { color: #9ca3af; font-style: italic; }
&lt;/style&gt;
</code></pre>
<p>For threshold logic, compute <code>status</code> when processing each incoming reading - that way the grid only stores the derived value and the conditional format check is a simple string comparison. Don&#39;t put the threshold comparison inside the cell renderer; that runs on every render.</p>
<h2 id="stale-sensor-detection">Stale-sensor detection</h2><p>A sensor that stops reporting is itself a failure mode - often more critical than a high reading, because a dead sensor means you have no visibility into that zone. The <code>lastSeen</code> timestamp handles this. Set a background interval to touch the grid with a <code>status</code> recalculation pass every 10-15 seconds, which flags any sensor that has not reported in your staleness threshold (typically 30s to 2 minutes depending on the sensor&#39;s expected report interval).</p>
<pre><code class="language-ts">// Recompute stale status periodically, independent of incoming readings
setInterval(() =&gt; {
  const now = Date.now()
  const staleThreshold = 30_000

  const updates = api.getData()
    .filter(row =&gt; now - row.lastSeen &gt; staleThreshold &amp;&amp; row.status !== &#39;stale&#39;)
    .map(row =&gt; ({ ...row, status: &#39;stale&#39; as const }))

  if (updates.length &gt; 0) {
    api.applyTransaction({ update: updates })
  }
}, 10_000)
</code></pre>
<p>This keeps the staleness state in the grid&#39;s data layer rather than in a parallel reactive store, which means sorting by status will correctly sort stale sensors alongside critical and warning ones.</p>
<h2 id="keeping-critical-sensors-visible">Keeping critical sensors visible</h2><p>Once you have status data, let users sort critical sensors to the top. Call this on initial load and expose it as a button for operators who want to see the worst-case view:</p>
<pre><code class="language-ts">function pinCriticalToTop() {
  api.setSort(&#39;status&#39;, &#39;asc&#39;) // &#39;critical&#39; &lt; &#39;ok&#39; &lt; &#39;warning&#39; alphabetically - adjust to your sort order
}

// Or use a custom sort comparator if your order is critical -&gt; warning -&gt; ok
function showWorstFirst() {
  const order = { critical: 0, warning: 1, stale: 2, ok: 3 }
  api.setSort(&#39;status&#39;, &#39;asc&#39;, {
    compareFn: (a, b) =&gt; (order[a] ?? 4) - (order[b] ?? 4),
  })
}
</code></pre>
<h2 id="scale-considerations">Scale considerations</h2><p>Virtualization handles the rendering side - even with 5,000 sensors, only the visible rows are in the DOM at any time. The batched-update pattern above handles the JavaScript side. The third constraint is memory: rolling history arrays that grow without bound will eventually exhaust heap. Cap them explicitly when you push new readings.</p>
<p>For very large sensor fleets grouped by zone or building, keep live updates scoped to the currently visible group. Expand the zone in the grid and subscribe to that zone&#39;s feed; collapse it and unsubscribe. Server-side grouping with <code>createServerDataSource</code> works well here - paginate by zone, push live updates only for the active page.</p>
<pre><code class="language-ts">import { createServerDataSource } from &#39;@svgrid/grid&#39;

const ds = createServerDataSource({
  fetch: async ({ page, pageSize, sort, filters }) =&gt; {
    const params = new URLSearchParams({
      page: String(page),
      size: String(pageSize),
      zone: activeZone,
    })
    const json = await fetch(`/api/sensors?${params}`).then(r =&gt; r.json())
    return { rows: json.data, total: json.total }
  },
})
</code></pre>
<p>The combination of batched RAF updates, sparkline history cells, conditional status formatting, and stale-device detection covers most of what makes an IoT dashboard genuinely useful to the operators watching it. Each piece is independent - you can start with just the batched updates and add sparklines and alerts incrementally.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/real-time-trading-grid/">Building a Real-Time Trading Grid in Svelte</a></li>
<li><a href="https://svgrid.com/blog/logistics-fleet-tracking-grid/">Building a Logistics / Fleet Tracking Grid in Svelte</a></li>
<li><a href="https://svgrid.com/blog/project-task-board-grid/">Building a Project / Task Board with a Svelte Data Grid</a></li>
<li><a href="https://svgrid.com/blog/order-management-dashboard/">Building an Order Management Dashboard in Svelte</a></li>
<li><a href="https://svgrid.com/blog/log-viewer-large-logs/">Building a Log Viewer for Large Logs in Svelte</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Building an Inventory Management Grid in Svelte</title>
      <link>https://svgrid.com/blog/inventory-management-grid/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/inventory-management-grid/</guid>
      <pubDate>Wed, 05 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Boyko Markov</dc:creator>
      <category>inventory</category>
      <category>stock</category>
      <category>use case</category>
      <category>svelte data grid</category>
      <description>Stock levels, low-stock highlighting, inline edits, bulk updates, and CSV import - a practical inventory grid built with SvGrid.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/inventory-management-grid.png" width="1200" height="630" alt="" /></p><p>Warehouse staff don&#39;t read dashboards. They glance at a grid for three seconds, find the row that&#39;s wrong, fix the number, and move on. If the grid makes that hard - slow saves, no visual hierarchy, no keyboard navigation - they work around it with sticky notes and spreadsheets. Building an inventory grid that people actually trust means getting those fundamentals right before adding anything else.</p>
<p><img src="https://svgrid.com/blog-media/anomaly.png" alt="Anomaly highlighting in a SvGrid grid">
<em>Threshold and anomaly highlighting applied to stock levels.</em></p>
<h2 id="column-layout-that-works-at-a-glance">Column layout that works at a glance</h2><p>The column order matters. SKU and product name go on the left and get pinned so they stay visible when scrolling right. Quantity columns - on hand, reserved, available - come next, right-aligned and formatted as integers. Then reorder level, status badge, and last-updated date on the right.</p>
<pre><code class="language-ts">import SvGrid from &#39;@svgrid/grid&#39;
import type { ColumnDef } from &#39;@svgrid/grid&#39;

interface InventoryRow {
  sku: string
  product: string
  onHand: number
  reserved: number
  available: number
  reorderLevel: number
  status: &#39;in-stock&#39; | &#39;low&#39; | &#39;out&#39;
  updatedAt: string
}

const columns: ColumnDef&lt;InventoryRow&gt;[] = [
  { id: &#39;sku&#39;, field: &#39;sku&#39;, header: &#39;SKU&#39;, width: 110, pinned: &#39;left&#39; },
  { id: &#39;product&#39;, field: &#39;product&#39;, header: &#39;Product&#39;, width: 240, pinned: &#39;left&#39; },
  { id: &#39;onHand&#39;, field: &#39;onHand&#39;, header: &#39;On Hand&#39;, width: 100, type: &#39;number&#39;, editable: true },
  { id: &#39;reserved&#39;, field: &#39;reserved&#39;, header: &#39;Reserved&#39;, width: 100, type: &#39;number&#39; },
  { id: &#39;available&#39;, field: &#39;available&#39;, header: &#39;Available&#39;, width: 110, type: &#39;number&#39;,
    conditionalFormat: [
      { condition: ({ value }) =&gt; value &lt;= 0, style: { color: &#39;#dc2626&#39;, fontWeight: &#39;700&#39; } },
      { condition: ({ value, row }) =&gt; value &gt; 0 &amp;&amp; value &lt;= (row as InventoryRow).reorderLevel,
        style: { color: &#39;#d97706&#39;, fontWeight: &#39;600&#39; } },
    ]
  },
  { id: &#39;reorderLevel&#39;, field: &#39;reorderLevel&#39;, header: &#39;Reorder At&#39;, width: 110, type: &#39;number&#39; },
  { id: &#39;status&#39;, field: &#39;status&#39;, header: &#39;Status&#39;, width: 100, cell: statusBadge },
  { id: &#39;updatedAt&#39;, field: &#39;updatedAt&#39;, header: &#39;Updated&#39;, width: 140, type: &#39;date&#39; },
]
</code></pre>
<p>Pinning SKU and product means a user can scroll to the reserved/reorder columns without losing context. That&#39;s a small thing that warehouse staff will notice on day one.</p>
<h2 id="making-low-stock-impossible-to-miss">Making low stock impossible to miss</h2><p>Color alone is not enough - someone will always be working in a washed-out environment or have reduced color vision. The right approach combines a tinted row, a colored cell value, and a text badge that says &quot;Low&quot; or &quot;Out&quot; explicitly.</p>
<p>Row-level tinting comes from a <code>rowClass</code> function. Cell-level color comes from <code>conditionalFormat</code> (shown in the column def above). The status badge is a Svelte snippet that renders both a color dot and a text label.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import type { InventoryRow } from &#39;./types&#39;

  let { data, columns } = $props()

  function rowClass(row: InventoryRow): string {
    if (row.available &lt;= 0) return &#39;row-out-of-stock&#39;
    if (row.available &lt;= row.reorderLevel) return &#39;row-low-stock&#39;
    return &#39;&#39;
  }
&lt;/script&gt;

{#snippet statusBadge({ value }: { value: string })}
  &lt;span class=&quot;badge badge-{value}&quot;&gt;
    {#if value === &#39;out&#39;}&lt;span class=&quot;dot dot-red&quot; aria-hidden=&quot;true&quot;&gt;&lt;/span&gt;Out{/if}
    {#if value === &#39;low&#39;}&lt;span class=&quot;dot dot-amber&quot; aria-hidden=&quot;true&quot;&gt;&lt;/span&gt;Low{/if}
    {#if value === &#39;in-stock&#39;}&lt;span class=&quot;dot dot-green&quot; aria-hidden=&quot;true&quot;&gt;&lt;/span&gt;In Stock{/if}
  &lt;/span&gt;
{/snippet}

&lt;SvGrid
  {data}
  {columns}
  {rowClass}
  editable
  sortable
  filterable
  showFilterRow={true}
  enableCellSelection={true}
  rowHeight={34}
  onApiReady={(api) =&gt; { gridApi = api }}
/&gt;

&lt;style&gt;
  :global(.row-out-of-stock) { background: #fef2f2; }
  :global(.row-low-stock) { background: #fffbeb; }
  .badge { display: inline-flex; align-items: center; gap: 5px; font-size: 0.8rem; }
  .dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
  .dot-red { background: #dc2626; }
  .dot-amber { background: #d97706; }
  .dot-green { background: #16a34a; }
&lt;/style&gt;
</code></pre>
<p>The <code>rowClass</code> function is evaluated per row during render. Returning an empty string for in-stock rows means no extra DOM attribute to strip - it just skips the class entirely.</p>
<h2 id="inline-editing-that-doesnt-interrupt-the-workflow">Inline editing that doesn&#39;t interrupt the workflow</h2><p>The on-hand column is where edits happen. A warehouse worker receives a shipment, tabs to a row, hits a number, presses Enter, and expects the cursor to drop to the next row - exactly like a spreadsheet. SvGrid&#39;s default editing behavior handles that: Enter commits the edit and moves down, Escape cancels it.</p>
<p>For saves, I recommend optimistic updates rather than waiting on a network round-trip. Update the local row immediately, fire the API call in the background, and only revert if it fails. The grid&#39;s <code>applyTransaction</code> API is the right tool here.</p>
<pre><code class="language-ts">import { createServerDataSource } from &#39;@svgrid/grid&#39;

async function handleCellEdit(event: { rowIndex: number; field: string; newValue: unknown; row: InventoryRow }) {
  const { rowIndex, field, newValue, row } = event

  // Optimistic: update local state immediately
  gridApi.applyTransaction({
    update: [{ ...row, [field]: newValue }]
  })

  // Derive status from the new available count
  const updated = { ...row, [field]: newValue }
  const newStatus = updated.available &lt;= 0
    ? &#39;out&#39;
    : updated.available &lt;= updated.reorderLevel
    ? &#39;low&#39;
    : &#39;in-stock&#39;

  try {
    await fetch(`/api/inventory/${row.sku}`, {
      method: &#39;PATCH&#39;,
      headers: { &#39;Content-Type&#39;: &#39;application/json&#39; },
      body: JSON.stringify({ [field]: newValue, status: newStatus }),
    })
  } catch {
    // Revert on failure
    gridApi.applyTransaction({ update: [row] })
  }
}
</code></pre>
<p>One thing to keep in mind: when on-hand changes, available usually changes too (available = on-hand - reserved). If that derived column isn&#39;t recalculated before the grid re-renders, you&#39;ll show stale data. Either compute it server-side and return the full updated row, or derive it inline in the edit handler before calling <code>applyTransaction</code>.</p>
<p>Undo/redo (<code>api.undo()</code> / <code>api.redo()</code>) is worth enabling too. Reconciliation errors happen when someone enters 120 instead of 12. Ctrl+Z is the fastest recovery path.</p>
<h2 id="bulk-operations-and-filtering-for-action">Bulk operations and filtering for action</h2><p>The filter row is your friend in an inventory grid. Filtering by status = &quot;low&quot; or &quot;out&quot; lets a buyer see exactly what needs reordering - far more useful than scrolling through 4000 SKUs. Filtering by supplier or category narrows the list for a category manager doing a cycle count.</p>
<p>For bulk updates (marking a batch discontinued, applying a restock to a supplier&#39;s range), select the filtered rows and apply a transaction to all of them:</p>
<pre><code class="language-ts">function markSelectedDiscontinued() {
  const selected = gridApi.getSelectedRows()
  const updates = selected.map(row =&gt; ({ ...row, status: &#39;out&#39;, reorderLevel: 0 }))
  gridApi.applyTransaction({ update: updates })
  // then POST to /api/inventory/bulk
}

function restockSelected(quantity: number) {
  const selected = gridApi.getSelectedRows()
  const updates = selected.map(row =&gt; ({
    ...row,
    onHand: row.onHand + quantity,
    available: row.available + quantity,
    status: (row.available + quantity) &gt; row.reorderLevel ? &#39;in-stock&#39; : &#39;low&#39;,
  }))
  gridApi.applyTransaction({ update: updates })
}
</code></pre>
<p>This pattern - filter to a meaningful subset, select all, apply a named operation - covers most of the bulk editing scenarios I&#39;ve seen in practice.</p>
<h2 id="csv-import-for-cycle-counts">CSV import for cycle counts</h2><p>Physical inventory counts still come back as spreadsheets. The import flow I use: parse the CSV client-side with a small utility, match rows by SKU, compute the delta between counted and on-hand, then load into the grid for review before committing. The reviewer sees a diff - rows where the counted quantity diverges from the system quantity are tinted. Approving the diff calls <code>applyTransaction</code> with the updates.</p>
<p>For large catalogs (tens of thousands of SKUs), push filtering and sorting server-side with <code>createServerDataSource</code>. The client never needs to hold the full dataset:</p>
<pre><code class="language-ts">import { createServerDataSource } from &#39;@svgrid/grid&#39;

const ds = createServerDataSource({
  fetch: async ({ page, pageSize, sort, filters }) =&gt; {
    const params = new URLSearchParams({
      page: String(page),
      size: String(pageSize),
      ...(sort?.[0] ? { sortField: sort[0].id, sortDir: sort[0].desc ? &#39;desc&#39; : &#39;asc&#39; } : {}),
    })
    filters.forEach(f =&gt; {
      params.set(`filter_${f.id}`, String(f.value))
    })
    const res = await fetch(`/api/inventory?${params}`)
    const json = await res.json()
    return { rows: json.items, total: json.total }
  }
})
</code></pre>
<p>Server-side paging with a 50-row page size keeps the initial load fast even for a catalog of 100k products. The filter row still works - it just drives server-side query parameters instead of client-side predicate functions.</p>
<h2 id="what-id-prioritize-for-a-first-version">What I&#39;d prioritize for a first version</h2><p>The order matters: get the column layout and low-stock highlighting right first, because those affect every session. Inline editing with optimistic saves comes next, because that&#39;s the core interaction. Bulk operations and CSV import can come in a later sprint - they&#39;re high-value but not every user needs them on day one.</p>
<p>The grid being fast matters more than it being feature-complete. Warehouse staff develop muscle memory. If the grid responds in under 50ms to a keypress, they&#39;ll trust it. If it hesitates on every edit, they&#39;ll go back to spreadsheets.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/real-time-trading-grid/">Building a Real-Time Trading Grid in Svelte</a></li>
<li><a href="https://svgrid.com/blog/project-task-board-grid/">Building a Project / Task Board with a Svelte Data Grid</a></li>
<li><a href="https://svgrid.com/blog/order-management-dashboard/">Building an Order Management Dashboard in Svelte</a></li>
<li><a href="https://svgrid.com/blog/logistics-fleet-tracking-grid/">Building a Logistics / Fleet Tracking Grid in Svelte</a></li>
<li><a href="https://svgrid.com/blog/log-viewer-large-logs/">Building a Log Viewer for Large Logs in Svelte</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Importing CSV into a Svelte Data Grid</title>
      <link>https://svgrid.com/blog/importing-csv-into-the-grid/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/importing-csv-into-the-grid/</guid>
      <pubDate>Tue, 04 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Boyko Markov</dc:creator>
      <category>csv</category>
      <category>import</category>
      <category>data</category>
      <category>recipe</category>
      <category>svelte data grid</category>
      <description>Parse a user-uploaded CSV file into objects, map its headers to grid columns, validate rows before committing, and handle the edge cases that actually bite you in production.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/importing-csv-into-the-grid.png" width="1200" height="630" alt="" /></p><p>CSV is the file format that never dies. Every enterprise client has a spreadsheet export they want to &quot;just upload,&quot; and no two of them look the same. Quoted commas, BOM characters, inconsistent date formats, trailing newlines - the actual parsing is maybe five percent of the work. The rest is defense.</p>
<p>With SvGrid the rendering side is trivial: get an array of objects and set it as <code>data</code>. The challenge is building a pipeline that transforms an untrusted file into something the grid can display without silent data corruption.</p>
<p><img src="https://svgrid.com/blog-media/spreadsheet.png" alt="A spreadsheet-style SvGrid grid loaded with imported data.">
<em>A spreadsheet-style SvGrid grid loaded with imported data.</em></p>
<h2 id="getting-the-file-into-memory">Getting the file into memory</h2><p>Start with a plain file input scoped to CSV types. The browser&#39;s File API gives you a <code>text()</code> method that returns the raw content as a string - no library needed for this part.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import { type ColumnDef, tableFeatures, rowSortingFeature,
           columnFilteringFeature } from &#39;@svgrid/grid&#39;

  type Row = Record&lt;string, string | number&gt;

  let importedRows = $state&lt;Row[]&gt;([])
  let parseError = $state&lt;string | null&gt;(null)

  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })

  const columns: ColumnDef&lt;typeof features, Row&gt;[] = [
    { id: &#39;name&#39;, field: &#39;name&#39;, header: &#39;Name&#39;, width: 200 },
    { id: &#39;email&#39;, field: &#39;email&#39;, header: &#39;Email&#39;, width: 240 },
    { id: &#39;amount&#39;, field: &#39;amount&#39;, header: &#39;Amount&#39;, width: 120, type: &#39;number&#39; },
    { id: &#39;date&#39;, field: &#39;date&#39;, header: &#39;Date&#39;, width: 140 },
  ]

  async function onFile(e: Event) {
    parseError = null
    const file = (e.target as HTMLInputElement).files?.[0]
    if (!file) return

    try {
      const text = await file.text()
      const rows = parseCsv(text)
      importedRows = rows
    } catch (err) {
      parseError = err instanceof Error ? err.message : &#39;Failed to parse file&#39;
    }
  }
&lt;/script&gt;

&lt;input type=&quot;file&quot; accept=&quot;.csv,text/csv&quot; onchange={onFile} /&gt;

{#if parseError}
  &lt;p class=&quot;error&quot;&gt;{parseError}&lt;/p&gt;
{:else if importedRows.length &gt; 0}
  &lt;SvGrid
    data={importedRows}
    {columns}
    sortable
    filterable
    showGlobalFilter={true}
    virtualization={true}
  /&gt;
{/if}
</code></pre>
<h2 id="why-naive-splitting-fails">Why naive splitting fails</h2><p>The first instinct is <code>row.split(',')</code>. That works until someone uploads a CSV where a field value contains a comma - like <code>&quot;Smith, John&quot;</code> or <code>&quot;$1,200.00&quot;</code>. RFC 4180 allows commas inside quoted fields, and real exports from Excel, Google Sheets, and every accounting system use them constantly.</p>
<p>A correct parser needs to handle: quoted fields with embedded commas, escaped quotes (<code>&quot;&quot;</code>), newlines inside quoted fields, and optionally a UTF-8 BOM at the start of the file. Writing that from scratch is doable but tedious. There are small, well-tested libraries (PapaParse being the most popular) or you can write a state-machine parser once and reuse it.</p>
<p>Here is a minimal but correct parser that covers the cases that actually come up:</p>
<pre><code class="language-ts">function parseCsv(raw: string): Record&lt;string, string&gt;[] {
  // strip UTF-8 BOM if present
  const text = raw.startsWith(&#39;﻿&#39;) ? raw.slice(1) : raw

  const lines: string[][] = []
  let current: string[] = []
  let field = &#39;&#39;
  let inQuotes = false

  for (let i = 0; i &lt; text.length; i++) {
    const ch = text[i]

    if (inQuotes) {
      if (ch === &#39;&quot;&#39;) {
        if (text[i + 1] === &#39;&quot;&#39;) {
          field += &#39;&quot;&#39;
          i++
        } else {
          inQuotes = false
        }
      } else {
        field += ch
      }
    } else {
      if (ch === &#39;&quot;&#39;) {
        inQuotes = true
      } else if (ch === &#39;,&#39;) {
        current.push(field)
        field = &#39;&#39;
      } else if (ch === &#39;\n&#39; || (ch === &#39;\r&#39; &amp;&amp; text[i + 1] === &#39;\n&#39;)) {
        if (ch === &#39;\r&#39;) i++
        current.push(field)
        field = &#39;&#39;
        if (current.some(Boolean)) lines.push(current)
        current = []
      } else {
        field += ch
      }
    }
  }

  if (field || current.length) {
    current.push(field)
    if (current.some(Boolean)) lines.push(current)
  }

  if (lines.length &lt; 2) throw new Error(&#39;CSV has no data rows&#39;)

  const [headers, ...body] = lines
  return body.map((cells) =&gt;
    Object.fromEntries(headers.map((h, i) =&gt; [h.trim(), (cells[i] ?? &#39;&#39;).trim()]))
  )
}
</code></pre>
<p>The BOM strip is easy to forget and causes <code>undefined</code> values when the first column header is <code>﻿name</code> instead of <code>name</code>. It shows up in every Excel-exported CSV.</p>
<h2 id="validating-rows-before-they-hit-the-grid">Validating rows before they hit the grid</h2><p>Loading raw strings into the grid and calling it done is how you end up with <code>NaN</code> in number columns and broken sorts. Do a validation pass after parsing: coerce types, flag bad rows, and give the user a chance to see what will be skipped.</p>
<pre><code class="language-ts">type ValidationResult = {
  valid: Row[]
  invalid: Array&lt;{ row: Record&lt;string, string&gt;; reason: string; index: number }&gt;
}

function validateRows(
  raw: Record&lt;string, string&gt;[],
  fieldMap: Record&lt;string, string&gt;
): ValidationResult {
  const valid: Row[] = []
  const invalid: ValidationResult[&#39;invalid&#39;] = []

  raw.forEach((rawRow, index) =&gt; {
    const mapped: Row = {}
    let reason: string | null = null

    for (const [csvHeader, field] of Object.entries(fieldMap)) {
      const raw = rawRow[csvHeader] ?? &#39;&#39;

      if (field === &#39;amount&#39;) {
        const n = parseFloat(raw.replace(/[,$]/g, &#39;&#39;))
        if (isNaN(n)) {
          reason = `Row ${index + 2}: &quot;${raw}&quot; is not a valid amount`
          break
        }
        mapped[field] = n
      } else if (field === &#39;email&#39;) {
        if (raw &amp;&amp; !raw.includes(&#39;@&#39;)) {
          reason = `Row ${index + 2}: &quot;${raw}&quot; is not a valid email`
          break
        }
        mapped[field] = raw
      } else {
        mapped[field] = raw
      }
    }

    if (reason) {
      invalid.push({ row: rawRow, reason, index })
    } else {
      valid.push(mapped)
    }
  })

  return { valid, invalid }
}
</code></pre>
<p>The <code>fieldMap</code> parameter is a mapping from CSV header names to your grid&#39;s field names - which brings up the next real-world problem.</p>
<h2 id="header-mapping">Header mapping</h2><p>Users rarely export a file where the column names match your schema. Their file has <code>Full Name</code>, yours expects <code>name</code>. Their file has <code>Invoice Total (USD)</code>, yours expects <code>amount</code>.</p>
<p>The right approach is an explicit mapping step: show the CSV headers on the left, offer a dropdown of your grid columns on the right, and auto-select when the names are close enough. For exact matches you can auto-map; for everything else let the user decide.</p>
<p>A minimal mapping state in Svelte 5 looks like:</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  // csvHeaders: string[] extracted from the first row
  // gridFields: string[] from your column definitions

  let mapping = $state&lt;Record&lt;string, string&gt;&gt;({})

  function autoMap(csvHeaders: string[], gridFields: string[]) {
    const norm = (s: string) =&gt; s.toLowerCase().replace(/[^a-z0-9]/g, &#39;&#39;)
    const result: Record&lt;string, string&gt; = {}
    for (const h of csvHeaders) {
      const match = gridFields.find(f =&gt; norm(f) === norm(h))
      if (match) result[h] = match
    }
    return result
  }

  // initialize when headers are known
  $effect(() =&gt; {
    if (csvHeaders.length) mapping = autoMap(csvHeaders, gridFields)
  })
&lt;/script&gt;

{#each csvHeaders as header}
  &lt;label&gt;
    {header} -&gt;
    &lt;select bind:value={mapping[header]}&gt;
      &lt;option value=&quot;&quot;&gt;-- skip --&lt;/option&gt;
      {#each gridFields as field}
        &lt;option value={field}&gt;{field}&lt;/option&gt;
      {/each}
    &lt;/select&gt;
  &lt;/label&gt;
{/each}
</code></pre>
<p>The normalization function (<code>norm</code>) handles the common case where <code>Full Name</code> should match <code>fullName</code> or <code>full_name</code>. Beyond that, you need human judgment, and the UI should make that easy.</p>
<h2 id="large-files-and-the-ui-thread">Large files and the UI thread</h2><p>For files under a few thousand rows, synchronous parsing is fine. For anything larger - say 50,000 rows from a data export - parsing on the main thread will freeze the browser briefly. The right fix is a Web Worker: post the raw text, get back the parsed rows.</p>
<p>Once the data is in the grid, virtualization handles rendering. SvGrid only renders the visible viewport regardless of how many rows exist, so 100,000 imported rows scroll the same as 100. Set <code>virtualization={true}</code> (it defaults on for large datasets) and you do not need to page the data client-side.</p>
<h2 id="showing-a-preview-before-committing">Showing a preview before committing</h2><p>For any destructive or bulk operation, a preview step pays for itself in support tickets avoided. After parsing and validation, show the grid with a count of valid and invalid rows, let the user scan the data, and only then offer a &quot;Confirm import&quot; button. Calling <code>api.applyTransaction({ add: validRows })</code> on confirm means you can also support incremental imports into an existing dataset rather than replacing the whole thing.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  let api: import(&#39;@svgrid/grid&#39;).SvGridApi&lt;typeof features, Row&gt; | null = $state(null)

  function confirmImport() {
    if (!api) return
    api.applyTransaction({ add: validRows })
    showPreview = false
  }
&lt;/script&gt;

{#if showPreview}
  &lt;div class=&quot;import-preview&quot;&gt;
    &lt;p&gt;{validRows.length} rows will be imported, {invalidRows.length} skipped.&lt;/p&gt;
    &lt;SvGrid data={validRows} {columns} onApiReady={(a) =&gt; { api = a }} /&gt;
    &lt;button onclick={confirmImport}&gt;Confirm import&lt;/button&gt;
  &lt;/div&gt;
{/if}
</code></pre>
<p>The pattern works for CSV today and for XLSX or JSON tomorrow - the grid does not care how the data arrived, only that it is an array of objects that match the column field names.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/sync-grid-state-to-url/">Sync Grid State to the URL in Svelte</a></li>
<li><a href="https://svgrid.com/blog/progress-bar-cells/">Progress and Percentage Bar Cells in SvGrid</a></li>
<li><a href="https://svgrid.com/blog/multi-level-column-headers/">Multi-Level (Grouped) Column Headers in SvGrid</a></li>
<li><a href="https://svgrid.com/blog/lazy-loading-master-detail-content/">Lazy-Loading Master-Detail Content in SvGrid</a></li>
<li><a href="https://svgrid.com/blog/immutable-updates-without-killing-performance/">Immutable Grid Updates Without Killing Performance</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Immutable Grid Updates Without Killing Performance</title>
      <link>https://svgrid.com/blog/immutable-updates-without-killing-performance/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/immutable-updates-without-killing-performance/</guid>
      <pubDate>Mon, 03 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Kamelia M</dc:creator>
      <category>performance</category>
      <category>immutability</category>
      <category>reactivity</category>
      <category>recipe</category>
      <category>svelte data grid</category>
      <description>Surgical immutability - how to get predictable reactivity in SvGrid without the cost of cloning everything on every change.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/immutable-updates-without-killing-performance.png" width="1200" height="630" alt="" /></p><p>Cloning the entire dataset on every change is technically immutable and practically a disaster. If you have 50,000 rows and a live feed pushing updates at 60Hz, you are allocating 50,000 objects 60 times per second and handing the garbage collector a full-time job. Meanwhile, every row looks new to the grid&#39;s change detection, so everything repaints - even the rows that did not change.</p>
<p><img src="https://svgrid.com/blog-media/million-rows.png" alt="Immutable Grid Updates Without Killing Performance"></p>
<p>There is a better model. You do not need full immutability. You need <em>structural sharing</em>: new references for what changed, shared references for everything else.</p>
<h2 id="what-the-grid-actually-checks">What the grid actually checks</h2><p>SvGrid&#39;s reactivity (built on Svelte 5 signals) decides whether to repaint a row by comparing object identity. Same reference means nothing changed, skip it. New reference means something changed, update the DOM.</p>
<p>This is fast when it is precise. One row changed? One new reference, one repaint. But if your update strategy produces a new reference for every row regardless of what actually changed, you have traded the CPU cost of diffing for the memory cost of allocating - and you have not saved any renders.</p>
<pre><code class="language-ts">import SvGrid from &#39;@svgrid/grid&#39;

// WRONG: every row is a brand-new object on every update
// The grid sees 50,000 changed rows when one cell was edited
function updateBad(i: number, field: string, value: unknown) {
  data = data.map((r) =&gt; ({ ...r }))
}

// RIGHT: new reference only for the changed row, shared ref for everything else
function updateRow(i: number, field: string, value: unknown) {
  const next = data.slice()                       // new array, O(n) but cheap
  next[i] = { ...data[i], [field]: value }        // new object only for row i
  data = next                                     // rows[j !== i] keep their references
}
</code></pre>
<p>The <code>slice()</code> call is O(n) but it only copies an array of pointers, not the objects themselves. The objects at every index except <code>i</code> are the same reference they always were. The grid skips them.</p>
<h2 id="hooking-into-the-edit-lifecycle">Hooking into the edit lifecycle</h2><p>SvGrid&#39;s <code>onCellEdit</code> callback is where you apply these updates. The grid calls it after the user commits a change, passing you the row index, column id, and new value.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import type { ColumnDef } from &#39;@svgrid/grid&#39;

  type Row = { id: number; name: string; price: number; status: string }

  let data: Row[] = $state([
    { id: 1, name: &#39;Widget A&#39;, price: 29.99, status: &#39;active&#39; },
    { id: 2, name: &#39;Widget B&#39;, price: 49.99, status: &#39;inactive&#39; },
    // ... more rows
  ])

  const columns: ColumnDef&lt;typeof features, Row&gt;[] = [
    { id: &#39;name&#39;,   field: &#39;name&#39;,   header: &#39;Name&#39;,   width: 200, editable: true },
    { id: &#39;price&#39;,  field: &#39;price&#39;,  header: &#39;Price&#39;,  width: 100, type: &#39;number&#39;, editable: true },
    { id: &#39;status&#39;, field: &#39;status&#39;, header: &#39;Status&#39;, width: 120, editable: true },
  ]

  function onCellEdit({ rowIndex, columnId, newValue }: {
    rowIndex: number
    columnId: string
    newValue: unknown
  }) {
    const next = data.slice()
    next[rowIndex] = { ...data[rowIndex], [columnId]: newValue }
    data = next
  }
&lt;/script&gt;

&lt;SvGrid
  {data}
  {columns}
  editable
  {onCellEdit}
  rowHeight={32}
  virtualization={true}
/&gt;
</code></pre>
<p>The assignment <code>data = next</code> triggers Svelte&#39;s reactivity. Because <code>next</code> is a new array reference, the grid knows something changed. But row objects at every index except <code>rowIndex</code> are the same references as before, so the grid skips them.</p>
<h2 id="nested-objects-copy-the-path-not-the-tree">Nested objects: copy the path, not the tree</h2><p>If your rows contain nested objects - say an <code>address</code> field with <code>city</code>, <code>zip</code>, etc. - the same principle applies, one level deeper. Copy from the root of the mutation down to the changed field, sharing everything else.</p>
<pre><code class="language-ts">type Row = {
  id: number
  name: string
  address: { city: string; zip: string; country: string }
  metrics: { views: number; clicks: number; ctr: number }
}

function updateCity(i: number, city: string) {
  const next = data.slice()
  next[i] = {
    ...data[i],
    address: {
      ...data[i].address,  // share zip, country
      city,                // only city is new
    },
    // metrics keeps its reference - not touched
  }
  data = next
}
</code></pre>
<p><code>data[i].metrics</code> is the same object reference before and after. If something downstream is watching <code>metrics</code>, it will not fire. The update is scoped exactly to the changed subtree.</p>
<h2 id="batch-updates-and-applytransaction">Batch updates and <code>applyTransaction</code></h2><p>Surgical copies are great for single-cell edits. When you need to update, add, or remove many rows at once - say syncing a page of server results - use <code>applyTransaction</code> instead. It accepts add, update, and remove arrays and applies them in one pass.</p>
<pre><code class="language-ts">import SvGrid, { type SvGridApi } from &#39;@svgrid/grid&#39;

let api: SvGridApi

// Called from a polling loop, WebSocket handler, or server sync
function syncFromServer(patch: {
  add: Row[]
  update: { id: number; changes: Partial&lt;Row&gt; }[]
  remove: number[]
}) {
  api.applyTransaction({
    add: patch.add,
    update: patch.update.map(({ id, changes }) =&gt; {
      const existing = data.find((r) =&gt; r.id === id)!
      return { ...existing, ...changes }  // surgical merge per row
    }),
    remove: patch.remove,
  })
}
</code></pre>
<p><code>applyTransaction</code> handles the structural sharing internally. You do not need to manage array slicing yourself - just pass the minimal delta and the grid does the rest.</p>
<h2 id="when-to-reach-for-immer">When to reach for Immer</h2><p>If your row type is deeply nested and mutations are complex, Immer is a reasonable tool. It gives you a draft-based API that feels mutable but produces structurally-shared immutable results.</p>
<pre><code class="language-ts">import { produce } from &#39;immer&#39;

function deepUpdate(i: number, updater: (row: Row) =&gt; void) {
  data = produce(data, (draft) =&gt; {
    updater(draft[i])
  })
}

// Usage
deepUpdate(3, (row) =&gt; {
  row.address.city = &#39;Berlin&#39;
  row.metrics.clicks += 1
})
</code></pre>
<p>Immer&#39;s overhead is small enough that it rarely matters for interactive edits. For high-frequency updates - a live trading feed pushing hundreds of updates per second - hand-written surgical copies are measurably leaner. At that point you are updating specific known fields, not navigating arbitrary nested paths, so the verbosity is manageable.</p>
<p>The threshold I use: if your update rate is under ~100/sec and the nesting is more than two levels deep, Immer is probably the better tradeoff. Above that rate, profile first, then optimize.</p>
<h2 id="the-one-trap-accidental-mutation">The one trap: accidental mutation</h2><p>Structural sharing only works if you never mutate objects in place. If you write <code>data[i].price = newValue</code> directly, the object reference does not change, Svelte&#39;s reactivity does not fire, and the grid does not update. You get silent staleness.</p>
<p>The fix is straightforward: treat row objects as read-only once they are in the array. If you are working with TypeScript, <code>Readonly&lt;Row&gt;</code> at the type level will surface accidental mutations at compile time rather than at runtime. It is a small annotation that has saved me from debugging confusing stale-state bugs more than once.</p>
<pre><code class="language-ts">// TypeScript will catch mutations before they reach production
const columns: ColumnDef&lt;typeof features, Readonly&lt;Row&gt;&gt;[] = [
  { id: &#39;name&#39;, field: &#39;name&#39;, header: &#39;Name&#39;, width: 200, editable: true },
  // ...
]
</code></pre>
<p>The grid does not care whether your type is <code>Readonly</code> or not - it is a compile-time guard for your own code. Pair it with the surgical-copy pattern in your event handlers and you get both safety and performance.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/lazy-loading-master-detail-content/">Lazy-Loading Master-Detail Content in SvGrid</a></li>
<li><a href="https://svgrid.com/blog/measuring-grid-performance-devtools/">Measuring Data Grid Performance in DevTools</a></li>
<li><a href="https://svgrid.com/blog/avoiding-layout-thrash-custom-cells/">Avoiding Layout Thrash in Custom Grid Cells</a></li>
<li><a href="https://svgrid.com/blog/virtualize-100k-rows/">Render 100,000 Rows Smoothly with Grid Virtualization</a></li>
<li><a href="https://svgrid.com/blog/performance-tips-with-runes/">Performance Tips for SvGrid with Svelte 5 Runes</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Immutable Data Updates in Svelte 5</title>
      <link>https://svgrid.com/blog/immutable-data-updates-in-svelte/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/immutable-data-updates-in-svelte/</guid>
      <pubDate>Sun, 02 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Victor Vidolov</dc:creator>
      <category>svelte 5</category>
      <category>immutability</category>
      <category>reactivity</category>
      <category>concepts</category>
      <category>runes</category>
      <description>Svelte 5 runes track both mutation and reassignment, but for grids and lists, the way you update data determines whether re-renders are surgical or wasteful. Here are the patterns that actually hold up.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/immutable-data-updates-in-svelte.png" width="1200" height="630" alt="" /></p><p>Svelte 5&#39;s deep reactivity is genuinely good news. <code>$state</code> proxies your arrays and objects so both mutation and reassignment trigger updates. The bad news: that flexibility makes it easy to do the right thing by accident, and the wrong thing without noticing until your grid starts repainting everything on every keystroke.</p>
<p><img src="https://svgrid.com/blog-media/quick-start.png" alt="Immutable Data Updates in Svelte 5"></p>
<p>The core question when working with list data is not &quot;will Svelte detect this?&quot; - it will. The question is &quot;how expensive will the re-render be, and will components that key off identity behave correctly?&quot;</p>
<h2 id="what-svelte-5-actually-tracks">What Svelte 5 actually tracks</h2><p>Svelte 4 required assignment to trigger reactivity. Svelte 5 removes that constraint with proxy-based <code>$state</code>:</p>
<pre><code class="language-ts">let rows = $state&lt;Row[]&gt;([])

// All of these are tracked in Svelte 5:
rows.push(newRow)           // mutation - works
rows[0].name = &#39;Ada&#39;        // deep mutation - works
rows = [...rows, newRow]    // reassignment - also works
</code></pre>
<p>That last form - spread into a new array - is what most &quot;immutable updates&quot; guides recommend. It works, but it creates a new reference for every item in the array, which has implications for anything downstream that compares by reference.</p>
<h2 id="reference-identity-and-why-grids-care">Reference identity and why grids care</h2><p>A data grid maintains state keyed to rows: which row is selected, which cell is being edited, which group is expanded. It maps that state to your data using row identity - either an index or a stable ID field you provide.</p>
<p>When you replace an entire array with a spread, every row becomes a new object in memory. If the grid is checking <code>oldRow === newRow</code> to decide what changed, it sees everything as changed and repaints the full viewport. For small datasets this is invisible. At 10,000+ rows with virtualization, it shows up as a noticeable stutter.</p>
<p>The fix is surgical replacement: only create a new object for the row that actually changed, and leave the others untouched.</p>
<pre><code class="language-ts">import SvGrid, { type ColumnDef, tableFeatures, rowSelectionFeature } from &#39;@svgrid/grid&#39;

// Updating a single field in a single row - surgical, reference-stable
function updateStatus(rowIndex: number, newStatus: string) {
  rows[rowIndex] = { ...rows[rowIndex], status: newStatus }
  // rows[rowIndex] is a new reference
  // rows[0], rows[1], ... rows[rowIndex - 1], rows[rowIndex + 1] etc. are unchanged
}

// Adding a row at the end
function addRow(row: Row) {
  rows.push(row)
  // existing references are untouched
}

// Removing a row by index
function removeRow(rowIndex: number) {
  rows.splice(rowIndex, 1)
  // in-place mutation - existing references above the splice point are stable
}
</code></pre>
<p>Contrast with the pattern that looks similar but kills reference stability:</p>
<pre><code class="language-ts">// This replaces every row reference even though only one changed
rows = rows.map((r, i) =&gt; i === rowIndex ? { ...r, status: newStatus } : { ...r })
//                                                                          ^^^^^^^
//                                                                  pointless new objects
</code></pre>
<p>The version above is doubly wrong: it rebuilds untouched rows as new objects, and it does a full reassignment, so the grid sees every row as changed.</p>
<h2 id="updating-nested-fields">Updating nested fields</h2><p>Nested objects need the same care. Copy along the path you&#39;re changing, not the entire tree:</p>
<pre><code class="language-ts">type Order = {
  id: number
  customer: { name: string; email: string }
  items: { sku: string; qty: number }[]
  total: number
}

let orders = $state&lt;Order[]&gt;([])

// Change just the customer email
function updateEmail(orderIndex: number, newEmail: string) {
  orders[orderIndex] = {
    ...orders[orderIndex],
    customer: {
      ...orders[orderIndex].customer,
      email: newEmail,
    },
  }
  // orders[orderIndex].items references are preserved
  // other orders are untouched
}

// Add an item to a specific order
function addItem(orderIndex: number, item: { sku: string; qty: number }) {
  orders[orderIndex] = {
    ...orders[orderIndex],
    items: [...orders[orderIndex].items, item],
  }
}
</code></pre>
<p>The guiding principle: new reference only at the level you changed and every ancestor up to the root array. Siblings and unrelated branches stay the same.</p>
<h2 id="using-the-grids-transaction-api">Using the grid&#39;s transaction API</h2><p>For bulk mutations - adding, updating, and removing multiple rows at once - the <code>applyTransaction</code> method is the right tool. It handles all the reference bookkeeping internally:</p>
<pre><code class="language-ts">import SvGrid, {
  type ColumnDef,
  type SvGridApi,
  tableFeatures,
  rowSelectionFeature,
  columnFilteringFeature,
} from &#39;@svgrid/grid&#39;

let api: SvGridApi | null = null

const features = tableFeatures({ rowSelectionFeature, columnFilteringFeature })

const columns: ColumnDef&lt;typeof features, Order&gt;[] = [
  { id: &#39;id&#39;, field: &#39;id&#39;, header: &#39;ID&#39;, width: 80 },
  { id: &#39;customer&#39;, field: &#39;customer.name&#39;, header: &#39;Customer&#39;, width: 200 },
  { id: &#39;total&#39;, field: &#39;total&#39;, header: &#39;Total&#39;, width: 120, type: &#39;number&#39; },
  { id: &#39;status&#39;, field: &#39;status&#39;, header: &#39;Status&#39;, width: 120 },
]

// Sync result of a server action back into the grid
async function syncFromServer() {
  const diff = await fetchPendingChanges()

  api?.applyTransaction({
    add: diff.created,
    update: diff.updated,   // matched by row identity
    remove: diff.deleted,   // removes by id
  })
}
</code></pre>
<p><code>applyTransaction</code> is designed to be called frequently - after websocket messages, polling intervals, or optimistic UI rollbacks. It processes only what changed rather than replacing the whole dataset.</p>
<h2 id="pitfalls-worth-knowing">Pitfalls worth knowing</h2><p><strong>Shared object references across rows.</strong> If two rows point to the same nested object and you mutate it directly, both rows appear changed. Clone when you need independence:</p>
<pre><code class="language-ts">// If importing from an API that reuses objects across records, clone on intake
let rows = $state(apiResponse.map(r =&gt; ({ ...r, meta: { ...r.meta } })))
</code></pre>
<p><strong>External non-reactive data.</strong> Arrays from props or stores are not automatically proxied. Assign them into <code>$state</code> to make updates trackable:</p>
<pre><code class="language-ts">// Not reactive - changes to externalData don&#39;t propagate
let rows = externalData

// Reactive - subsequent mutations on rows are tracked
let rows = $state([...externalData])
</code></pre>
<p><strong><code>$state.raw</code> for large stable datasets.</strong> If you have a large array that you always replace wholesale (never mutate in place), <code>$state.raw</code> skips the proxy overhead and can be significantly faster. The tradeoff is that in-place mutations are not tracked at all:</p>
<pre><code class="language-ts">// Replace the array to trigger an update; in-place mutations are invisible
let rows = $state.raw&lt;Row[]&gt;([])

function refresh(newData: Row[]) {
  rows = newData  // new reference - triggers update
  rows.push(x)   // silent - won&#39;t update
}
</code></pre>
<p>Use <code>$state.raw</code> only when your data flow is clearly replace-only - server-side pagination is the typical case.</p>
<h2 id="the-mental-model-that-holds-up">The mental model that holds up</h2><p>Think of your data as a tree. When you change a node, you need a new reference for that node and every node from it back to the root. Everything else - siblings, cousins, the other subtrees - should keep its existing reference.</p>
<p>That rule keeps downstream components, selection state, and the grid&#39;s own diffing from treating unrelated data as changed. Svelte 5 gives you the flexibility to mutate in place, but for list-backed state that feeds into a grid, the discipline of surgical immutable updates pays for itself quickly once your datasets grow past a few hundred rows.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/why-we-bet-on-svelte-5-runes/">Why We Bet on Svelte 5 Runes for a High-Performance Data Grid</a></li>
<li><a href="https://svgrid.com/blog/reactivity-large-arrays-objects/">Reactivity with Large Arrays and Objects in Svelte 5</a></li>
<li><a href="https://svgrid.com/blog/migrating-svelte-4-table-to-svelte-5/">Migrating a Svelte 4 Table Component to Svelte 5</a></li>
<li><a href="https://svgrid.com/blog/immutable-updates-without-killing-performance/">Immutable Grid Updates Without Killing Performance</a></li>
<li><a href="https://svgrid.com/blog/svelte-5-tips-and-tricks/">Svelte 5 Tips and Tricks: Runes, Snippets, and More</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>How We Built Excel-Style Filters</title>
      <link>https://svgrid.com/blog/how-we-built-excel-style-filters/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/how-we-built-excel-style-filters/</guid>
      <pubDate>Sat, 01 Aug 2026 07:12:00 GMT</pubDate>
      <dc:creator>Boyko Markov</dc:creator>
      <category>filtering</category>
      <category>excel filters</category>
      <category>engineering</category>
      <category>story</category>
      <description>How SvGrid implements per-column filter menus, type-aware operators, and a shared filter model that works identically for in-memory and server-side data.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/how-we-built-excel-style-filters.png" width="1200" height="630" alt="" /></p><p>Most grid libraries treat filtering as an afterthought - a text box above the table that does a case-insensitive <code>String.includes</code>. Excel-style filtering is what users actually want: per-column menus, type-specific operators, the ability to stack multiple conditions, and results that feel instant. Building that well turned out to require more architectural discipline than the filtering logic itself.</p>
<p><img src="https://svgrid.com/blog-media/excel-filters.png" alt="SvGrid's Excel-style column filter menu.">
<em>SvGrid&#39;s Excel-style column filter menu.</em></p>
<h2 id="filtering-belongs-in-the-row-pipeline-not-the-renderer">Filtering belongs in the row pipeline, not the renderer</h2><p>The first decision was structural. Some grids bolt filtering onto rendering - they filter inside the loop that builds visible rows. That&#39;s the easy path and also the wrong one, because it makes every downstream feature (sorting, grouping, pagination, selection counts) work with stale totals.</p>
<p>We had already built the row model as an explicit pipeline:</p>
<pre><code>data -&gt; filter -&gt; sort -&gt; group -&gt; paginate -&gt; visible rows
</code></pre>
<p>Filtering as a discrete pipeline step means everything downstream sees only surviving rows. Pagination totals are correct. Group counts are correct. &quot;Select all&quot; selects what you see, not the full dataset. You don&#39;t get that for free if filtering is tangled with rendering.</p>
<p>The filter model is a map from column id to a condition object. When any entry changes, the pipeline re-derives filtered rows. Nothing else in the grid knows that filtering happened - it just sees a shorter list.</p>
<h2 id="the-type-problem">The type problem</h2><p>Text and numbers look similar in a grid cell. They&#39;re completely different to filter. &quot;Greater than 100&quot; on a text column would sort &quot;9&quot; above &quot;100&quot; alphabetically. Dates stored as ISO strings need range handling, not substring matching.</p>
<p>The fix is to filter on typed values, not on display strings. Each column definition knows its value type - <code>text</code>, <code>number</code>, <code>date</code>, <code>boolean</code> - and the filter engine picks operators appropriate to that type and compares raw values:</p>
<pre><code class="language-typescript">// column definition
const columns: ColumnDef&lt;typeof features, Product&gt;[] = [
  {
    id: &#39;name&#39;,
    field: &#39;name&#39;,
    header: &#39;Product&#39;,
    width: 220,
    type: &#39;text&#39;,
    // text operators: contains, notContains, equals, startsWith, endsWith, blank
  },
  {
    id: &#39;price&#39;,
    field: &#39;price&#39;,
    header: &#39;Price&#39;,
    width: 120,
    type: &#39;number&#39;,
    // number operators: equals, notEquals, greaterThan, lessThan, between
  },
  {
    id: &#39;releaseDate&#39;,
    field: &#39;releaseDate&#39;,
    header: &#39;Released&#39;,
    width: 140,
    type: &#39;date&#39;,
    // date operators: equals, before, after, between, blank
  },
  {
    id: &#39;inStock&#39;,
    field: &#39;inStock&#39;,
    header: &#39;In Stock&#39;,
    width: 100,
    type: &#39;boolean&#39;,
    // boolean operators: true, false
  },
]
</code></pre>
<p>When the user picks &quot;between 100 and 500&quot; on a number column, the engine compares <code>Number(row.price)</code> against <code>100</code> and <code>500</code>. The display string might be &quot;$100.00&quot; - the filter never touches it.</p>
<p>This also means you can apply formatting independently of filtering logic. Currency symbols, date locale strings, percentage signs - none of that leaks into filter comparisons.</p>
<h2 id="two-uis-one-model">Two UIs, one model</h2><p>Different workflows want different filter UIs. An analyst building a report wants filters always visible. An end user browsing a product list wants them tucked away until needed. We support both modes, driven by the same underlying filter model.</p>
<p><code>showFilterRow</code> adds an always-visible input row below the headers. <code>filterable</code> on a column (or the grid globally) adds a funnel icon to the header that opens a per-column filter menu. Both write to the same filter model. You can use one, the other, or both at once.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import {
    tableFeatures,
    columnFilteringFeature,
    rowSortingFeature,
    rowPaginationFeature,
    type ColumnDef
  } from &#39;@svgrid/grid&#39;

  const features = tableFeatures({
    columnFilteringFeature,
    rowSortingFeature,
    rowPaginationFeature,
  })

  const columns: ColumnDef&lt;typeof features, Product&gt;[] = [
    { id: &#39;name&#39;, field: &#39;name&#39;, header: &#39;Product&#39;, width: 220, type: &#39;text&#39; },
    { id: &#39;price&#39;, field: &#39;price&#39;, header: &#39;Price&#39;, width: 120, type: &#39;number&#39; },
    { id: &#39;category&#39;, field: &#39;category&#39;, header: &#39;Category&#39;, width: 160 },
    { id: &#39;stock&#39;, field: &#39;stock&#39;, header: &#39;Stock&#39;, width: 100, type: &#39;number&#39; },
  ]

  let data = $state(products)
&lt;/script&gt;

&lt;!-- Filter row: always visible inputs, good for power users --&gt;
&lt;SvGrid
  {data}
  {columns}
  {features}
  filterable
  showFilterRow={true}
  pageable
  rowHeight={36}
/&gt;
</code></pre>
<p>The filter menu variant is the same grid, different props:</p>
<pre><code class="language-svelte">&lt;!-- Menu mode: per-header dropdown, cleaner for end users --&gt;
&lt;SvGrid
  {data}
  {columns}
  {features}
  filterable
  showGlobalFilter={true}
  pageable
  rowHeight={36}
/&gt;
</code></pre>
<p>Both modes support compound conditions - AND/OR within a column. The menu exposes this as two input rows with a connector selector. The filter row exposes it through the filter menu that appears when you click the active filter indicator.</p>
<h2 id="setting-filters-from-code">Setting filters from code</h2><p>The filter model is also fully addressable through the API. This matters for building filter presets, persisting filter state to a URL, or driving the grid from external controls like a sidebar filter panel.</p>
<pre><code class="language-typescript">let api: SvGridApi

// Set a single-condition filter
api.setFilter(&#39;price&#39;, {
  operator: &#39;between&#39;,
  value: &#39;100&#39;,
  valueTo: &#39;500&#39;,
})

// Set a compound condition
api.setFilter(&#39;category&#39;, {
  operator: &#39;equals&#39;,
  value: &#39;Electronics&#39;,
  logicalOperator: &#39;OR&#39;,
  valueTo: &#39;Accessories&#39;,
  operatorTo: &#39;equals&#39;,
})

// Clear one column
api.clearFilter(&#39;category&#39;)

// Clear everything
api.clearAllFilters()

// Read back the current model (e.g., serialize to URL)
const state = api.getState()
// state.columnFilters is the full filter map

// Restore from URL params on mount
api.setState({ columnFilters: parsedFilters })
</code></pre>
<p>The state round-trip is what makes filter persistence practical. Serialize <code>api.getState()</code> to <code>localStorage</code> or URL params, restore it on mount with <code>api.setState()</code>, and users get their filter context back across page loads.</p>
<h2 id="server-side-filtering-same-model-different-destination">Server-side filtering: same model, different destination</h2><p>For datasets too large to load into the browser, the filter model travels to the server instead of running in-memory. The <code>createServerDataSource</code> adapter receives the current filter conditions alongside pagination and sort state in each fetch call.</p>
<pre><code class="language-typescript">import { createServerDataSource } from &#39;@svgrid/grid&#39;

const ds = createServerDataSource({
  fetch: async ({ page, pageSize, sort, filters }) =&gt; {
    const params = new URLSearchParams({
      page: String(page),
      size: String(pageSize),
    })

    // filters is the same filter model the in-memory engine uses
    if (filters.price) {
      params.set(&#39;priceMin&#39;, filters.price.value ?? &#39;&#39;)
      params.set(&#39;priceMax&#39;, filters.price.valueTo ?? &#39;&#39;)
    }
    if (filters.category?.value) {
      params.set(&#39;category&#39;, filters.category.value)
    }
    if (sort.length) {
      params.set(&#39;sortField&#39;, sort[0].id)
      params.set(&#39;sortDir&#39;, sort[0].desc ? &#39;desc&#39; : &#39;asc&#39;)
    }

    const res = await fetch(`/api/products?${params}`)
    const json = await res.json()
    return { rows: json.data, total: json.total }
  },
})
</code></pre>
<p>From the grid&#39;s perspective, nothing changes. The same components, the same <code>setFilter</code> API calls, the same filter UI - it just calls your fetch function instead of filtering in-memory. Swapping between in-memory and server-side filtering requires changing one prop, not restructuring your component.</p>
<h2 id="performance-in-practice">Performance in practice</h2><p>In-memory filtering on a 50,000-row dataset completes in under 10ms on a mid-range laptop. The filter function runs once per model change and the result is stored as a derived value. Virtualization handles the render side - only the rows in the visible window are ever in the DOM, regardless of how many rows survive the filter.</p>
<p>The one real cost is initial data ingestion. Loading 50,000 rows into <code>$state()</code> takes measurable time if you do it synchronously. The answer there is server-side pagination from the start if your data is that large, or lazy-loading chunks. The filter engine itself is not the bottleneck.</p>
<p>What the pipeline architecture gave us was a feature that plugged in without touching sorting, grouping, or pagination code. The filter step is about 300 lines. The rest of the grid doesn&#39;t know it exists - it just sees however many rows made it through.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/going-ai-native-the-mcp-server/">Going AI-Native: The SvGrid MCP Server</a></li>
<li><a href="https://svgrid.com/blog/building-the-theming-system/">Inside SvGrid: A Theming System on CSS Variables</a></li>
<li><a href="https://svgrid.com/blog/building-the-sort-pipeline/">Inside SvGrid: The Row Model and Sorting</a></li>
<li><a href="https://svgrid.com/blog/building-the-editing-engine/">Inside SvGrid: The Inline Editing Engine</a></li>
<li><a href="https://svgrid.com/blog/building-grouping-trees-master-detail/">Inside SvGrid: Grouping, Trees, and Master-Detail</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Going AI-Native: The SvGrid MCP Server</title>
      <link>https://svgrid.com/blog/going-ai-native-the-mcp-server/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/going-ai-native-the-mcp-server/</guid>
      <pubDate>Fri, 31 Jul 2026 07:12:00 GMT</pubDate>
      <dc:creator>Kamelia M</dc:creator>
      <category>ai</category>
      <category>mcp</category>
      <category>claude</category>
      <category>cursor</category>
      <category>engineering</category>
      <category>story</category>
      <description>Most AI assistants invent data grid APIs. We built an MCP server so they look up the real one instead.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/going-ai-native-the-mcp-server.png" width="1200" height="630" alt="" /></p><p>Ask any AI assistant to scaffold a Svelte data grid and watch what happens. The code looks plausible - column definitions, event handlers, props with sensible names. Then you try to run it. Half the props do not exist. The sort callback signature is wrong. The filtering API is borrowed from a different library. You spend the next twenty minutes debugging code that was never real.</p>
<p><img src="https://svgrid.com/blog-media/server-side.png" alt="Going AI-Native: The SvGrid MCP Server"></p>
<p>This is not an edge case. It is the baseline experience with any library that is not deeply baked into an assistant&#39;s training data. The assistant interpolates from pattern-matches across dozens of grids, produces something that reads as correct, and moves on.</p>
<p>We decided that was not acceptable for SvGrid. The AI integration came last in the build sequence, but it was never optional.</p>
<h2 id="why-ai-assistants-get-grids-wrong">Why AI assistants get grids wrong</h2><p>The problem has a specific cause. An assistant working from training data is averaging over whatever it saw at crawl time. If you are a mature library with years of Stack Overflow answers, the average is probably close to the real API. If you are newer, or if you have changed your API significantly, the average is noise.</p>
<p>Data grids make this worse because they have large, interconnected APIs. Prop names, feature flag objects, imperative methods, column definition shapes - they vary substantially between libraries, and an assistant reasoning from partial information will confidently combine pieces from different ones. The result compiles visually and fails at runtime.</p>
<p>The only way to fix this is to give the assistant a reliable lookup path.</p>
<h2 id="what-svgridmcp-does">What <code>@svgrid/mcp</code> does</h2><p>We built <code>@svgrid/mcp</code> as a Model Context Protocol server. MCP is a standard that lets editors like Cursor and Claude Code expose tools an assistant can call during generation. Instead of reasoning from training data alone, the assistant can call a tool, get the real current documentation or example source, and use that as the basis for its output.</p>
<p>For SvGrid specifically the server exposes three things: the live documentation (so feature explanations are accurate), the working example sources (so generated code copies patterns that actually run), and the typed API reference (so prop names and method signatures match what the package exports).</p>
<p>Wiring it in takes one config block:</p>
<pre><code class="language-json">{
  &quot;mcpServers&quot;: {
    &quot;sv-grid&quot;: { &quot;command&quot;: &quot;npx&quot;, &quot;args&quot;: [&quot;@svgrid/mcp&quot;] }
  }
}
</code></pre>
<p>After that, when you ask your assistant to add server-side pagination to an existing grid, it retrieves the <code>createServerDataSource</code> signature and the relevant example before it writes a single line. What it produces is grounded.</p>
<h2 id="what-grounded-output-actually-looks-like">What grounded output actually looks like</h2><p>The difference shows up immediately when you work with features that have non-obvious shapes. Server-side data is a good test case. An assistant working from training data will typically invent a callback prop or produce an <code>onPageChange</code> handler pattern borrowed from a UI library. With the MCP server it retrieves the real pattern:</p>
<pre><code class="language-typescript">import { createServerDataSource } from &#39;@svgrid/grid&#39;
import type { SvGridOptions, ColumnDef, TableFeatures } from &#39;@svgrid/grid&#39;

const ds = createServerDataSource({
  fetch: async ({ page, pageSize, sort, filters }) =&gt; {
    const params = new URLSearchParams({
      page: String(page),
      size: String(pageSize),
    })

    if (sort.length) {
      params.set(&#39;sort&#39;, sort[0].id)
      params.set(&#39;dir&#39;, sort[0].desc ? &#39;desc&#39; : &#39;asc&#39;)
    }

    for (const f of filters) {
      params.set(`filter[${f.id}]`, String(f.value))
    }

    const res = await fetch(`/api/data?${params}`)
    const json = await res.json()
    return { rows: json.data, total: json.total }
  },
})
</code></pre>
<p>That comes back correctly typed and ready to pass as <code>data</code> to <code>&lt;SvGrid&gt;</code>. No invented props, no mismatched callback signatures.</p>
<p>The same holds for the imperative API. An assistant guessing at method names will often get the shape wrong - calling <code>api.sort()</code> instead of <code>api.setSort()</code>, or passing the wrong argument order. With the reference available, it calls what exists:</p>
<pre><code class="language-typescript">import SvGrid from &#39;@svgrid/grid&#39;
import type { SvGridApi } from &#39;@svgrid/grid&#39;

let api: SvGridApi | undefined

function applyWorkingFilter() {
  if (!api) return
  api.setFilter(&#39;status&#39;, { operator: &#39;equals&#39;, value: &#39;active&#39; })
  api.setFilter(&#39;revenue&#39;, { operator: &#39;between&#39;, value: &#39;10000&#39;, valueTo: &#39;50000&#39; })
  api.setSort(&#39;revenue&#39;, &#39;desc&#39;)
  api.setPage(1) // reset to first page after filter change
}

function exportCurrentView() {
  if (!api) return
  const rows = api.getDisplayedRows()
  // rows reflects current sort + filter + pagination
  downloadCsv(rows)
}
</code></pre>
<h2 id="the-llmstxt-feed">The <code>llms.txt</code> feed</h2><p>Not every assistant speaks MCP. Retrieval-augmented tools, web-enabled models, and AI search engines all have their own mechanisms for indexing documentation. For those we published an <code>llms.txt</code> feed at the site root.</p>
<p><code>llms.txt</code> is a simple convention, roughly the <code>robots.txt</code> equivalent for AI crawlers. It points to a structured, machine-readable summary of the site that a retrieval system can index cleanly rather than parsing HTML. Assistants that find SvGrid through search get documentation-grounded answers instead of best guesses.</p>
<p>The combination covers the two main paths: interactive coding assistants via MCP, and retrieval-based tools via the text feed.</p>
<h2 id="the-principle-behind-the-decision">The principle behind the decision</h2><p>We did not build this as a marketing exercise. AI assistants change the actual experience of adopting a library. When generated code works on the first try, you spend your time on the actual problem. When it invents an API, you spend it debugging fiction.</p>
<p>The team has shipped tools that meet developers where they are since 2011 - jQuery, then Angular and React bindings, then web components, then native Svelte 5. AI-assisted development is where a large share of grid code is being written right now. Showing up there was not a stretch, it was the same move, one more time.</p>
<p>The technical how-to for connecting the MCP server to your editor is in <a href="build-grids-faster-with-ai-and-mcp">Build Svelte Grids Faster with AI and the SvGrid MCP Server</a>. That post covers editor-specific config, the tool set the server exposes, and what to do when an assistant still goes off-script.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/prompts-to-build-svelte-data-grid/">Prompts That Build a Svelte Data Grid (Claude &amp; Cursor)</a></li>
<li><a href="https://svgrid.com/blog/build-grids-faster-with-ai-and-mcp/">Build Svelte Grids Faster with AI and the SvGrid MCP Server</a></li>
<li><a href="https://svgrid.com/blog/how-we-built-excel-style-filters/">How We Built Excel-Style Filters</a></li>
<li><a href="https://svgrid.com/blog/building-the-theming-system/">Inside SvGrid: A Theming System on CSS Variables</a></li>
<li><a href="https://svgrid.com/blog/building-the-sort-pipeline/">Inside SvGrid: The Row Model and Sorting</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>First Light - Pointing SvGrid at 100,000 Rows</title>
      <link>https://svgrid.com/blog/first-light-100000-rows/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/first-light-100000-rows/</guid>
      <pubDate>Thu, 30 Jul 2026 07:12:00 GMT</pubDate>
      <dc:creator>Kamelia M</dc:creator>
      <category>company</category>
      <category>story</category>
      <category>performance</category>
      <category>virtualization</category>
      <description>When we first fed SvGrid a hundred thousand rows, we were testing more than performance - we were testing whether building native on Svelte 5 runes was the right call from the start.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/first-light-100000-rows.png" width="1200" height="630" alt="" /></p><p>The first version of SvGrid that could render data at all was pointing at an array of three rows. That proves the component tree compiles. It proves nothing about your architecture.</p>
<p>So we did what you have to do eventually: we generated a hundred thousand rows, dropped them in, and watched the browser profile.</p>
<p><img src="https://svgrid.com/blog-media/editor-types.png" alt="SvGrid inline editor types.">
<em>SvGrid rendering with real data volumes.</em></p>
<h2 id="the-question-we-were-actually-asking">The question we were actually asking</h2><p>Performance at scale is a proxy question. What we were really asking was: did we make the right foundational bets? Specifically two of them.</p>
<p>The first bet was virtualization from day one. Not as an optimization we would add later, but as a constraint the engine was built around from the beginning. No part of the rendering path should ever assume a full-data pass.</p>
<p>The second bet was Svelte 5 runes as the reactivity layer, before runes were even stable. The idea was that fine-grained reactivity at the cell level would mean updates stay surgical regardless of dataset size. One cell changes, one cell repaints - not a grid-wide reconciliation triggered by a single field mutation.</p>
<p>Both bets were theoretical until we had a hundred thousand rows to test them on.</p>
<h2 id="setting-up-the-test">Setting up the test</h2><p>The setup was deliberately plain. A flat array of 100,000 generated objects, realistic field types, no server side data source, no pagination. Just the component in a bounded container with a large dataset.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import { tableFeatures, rowSortingFeature, columnFilteringFeature } from &#39;@svgrid/grid&#39;
  import type { ColumnDef } from &#39;@svgrid/grid&#39;

  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })

  // 100,000 rows, generated
  const data = $state(
    Array.from({ length: 100_000 }, (_, i) =&gt; ({
      id: i,
      name: `Row ${i}`,
      category: [&#39;Alpha&#39;, &#39;Beta&#39;, &#39;Gamma&#39;][i % 3],
      value: Math.round(Math.random() * 10_000),
      active: i % 4 !== 0,
    }))
  )

  const columns: ColumnDef&lt;typeof features, (typeof data)[number]&gt;[] = [
    { id: &#39;id&#39;,       field: &#39;id&#39;,       header: &#39;ID&#39;,       width: 80 },
    { id: &#39;name&#39;,     field: &#39;name&#39;,     header: &#39;Name&#39;,     width: 180 },
    { id: &#39;category&#39;, field: &#39;category&#39;, header: &#39;Category&#39;, width: 120 },
    { id: &#39;value&#39;,    field: &#39;value&#39;,    header: &#39;Value&#39;,    width: 100, type: &#39;number&#39; },
    { id: &#39;active&#39;,   field: &#39;active&#39;,   header: &#39;Active&#39;,   width: 80  },
  ]
&lt;/script&gt;

&lt;div style=&quot;height: 600px;&quot;&gt;
  &lt;SvGrid {data} {columns} sortable virtualization={true} /&gt;
&lt;/div&gt;
</code></pre>
<p>The <code>height: 600px</code> on the wrapper is not incidental. It is load-bearing. Without a bounded height, the grid cannot calculate the viewport, and without a viewport, virtualization has nothing to work with. That single omission is the most common reason someone reports that a large dataset is slow - the grid is rendering all hundred thousand rows because nothing told it what &quot;visible&quot; means.</p>
<h2 id="what-we-saw">What we saw</h2><p>Scrolling was smooth. Frame time stayed low. The DOM node count in the Elements panel sat at roughly the same number whether we were at row 0 or row 99,999 - a few hundred nodes for the visible window plus overscan, not a hundred thousand.</p>
<p>That is the whole point of row virtualization: DOM size tracks viewport size, not dataset size. The engine renders what is visible, discards what leaves the window, and reuses the nodes for incoming rows rather than creating new ones. Ten rows or a hundred thousand rows, the same handful of elements do the work.</p>
<p>Sorting the full hundred thousand rows by value took a moment on the first trigger - you are sorting 100k objects in JavaScript, that is not free. But the sorted render came back fast, because displaying the sorted result is still just showing the viewport window.</p>
<h2 id="where-runes-changed-the-story">Where runes changed the story</h2><p>The virtualization result was expected. The runes result was the one that mattered more for how we would build everything else.</p>
<p>We triggered a mutation: a single <code>value</code> field on one row, deep in the dataset.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import { tableFeatures } from &#39;@svgrid/grid&#39;
  import type { SvGridApi } from &#39;@svgrid/grid&#39;

  const features = tableFeatures({})
  const data = $state(generateRows(100_000))

  let api: SvGridApi&lt;typeof features&gt; | undefined

  function updateOneRow() {
    // Mutate in place - runes track this at the field level
    data[49_999].value = 9999
  }
&lt;/script&gt;

&lt;SvGrid
  {data}
  columns={columns}
  onApiReady={(a) =&gt; { api = a }}
/&gt;

&lt;button onclick={updateOneRow}&gt;Update row 50,000&lt;/button&gt;
</code></pre>
<p>On an older reactivity model - one that tracks arrays as a whole - mutating <code>data[49999].value</code> would signal that the entire array changed. The framework would reconcile from scratch. With a hundred thousand rows, that is expensive even with virtualization, because the derived state (sort order, filter matches, display values) all has to be recomputed.</p>
<p>With runes, the mutation is tracked at the property level. Only the derived state that reads <code>data[49999].value</code> becomes stale. The rest of the hundred thousand rows, and all their derived values, are untouched. The repaint is one cell.</p>
<p>The practical consequence: you can drive a live-updating grid with frequent field mutations without batching tricks, debounce wrappers, or immutable update patterns. Mutate in place, let runes sort out the rest.</p>
<h2 id="stable-references-and-the-one-mistake-worth-avoiding">Stable references and the one mistake worth avoiding</h2><p>There is one pattern that defeats both of these wins at once.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  // DON&#39;T do this on a timer or in response to every event:
  let data = $state(rows)

  function refreshData() {
    // Replacing the whole array reference forces a full re-evaluation.
    // At 100k rows this is noticeable.
    data = generateRows(100_000)  // &lt;-- new reference every time
  }

  // DO this instead - mutate the existing array/objects:
  function updateField(index: number, newValue: number) {
    data[index].value = newValue  // runes tracks this surgically
  }

  // Or use applyTransaction for bulk changes via the API:
  function bulkUpdate(changes: { index: number; value: number }[]) {
    api?.applyTransaction({
      update: changes.map(({ index, value }) =&gt; ({ ...data[index], value }))
    })
  }
&lt;/script&gt;
</code></pre>
<p>Replacing the whole data reference on a polling interval is the primary way to make a large grid feel sluggish even when everything else is right. The grid sees a new array, considers all row identity lost, and rebuilds. Mutating in place costs almost nothing because runes see exactly what changed.</p>
<h2 id="what-the-test-actually-validated">What the test actually validated</h2><p>Running a hundred thousand rows was not a milestone to mark on a roadmap. It was a diagnostic for whether the architecture was sound enough to build the rest of the product on.</p>
<p>Virtualization holding at scale meant features like grouping, master-detail rows, and tree data would not break the DOM budget when the data got large. Runes staying surgical meant inline editing, live server updates, and collaborative editing would be viable without heroic optimization work.</p>
<p>The features in the grid today - sorting, filtering, grouping, aggregation, server-side data, pivot, cell editing, undo/redo - all stand on that architecture. None of them required revisiting the foundation because the foundation held.</p>
<p>That is what first light means in practice: not that the thing looks good, but that you can trust what you built on.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/day-one-building-svgrid/">How We Started Building SvGrid</a></li>
<li><a href="https://svgrid.com/blog/why-the-world-needed-another-grid/">Why the World Needed Another Grid</a></li>
<li><a href="https://svgrid.com/blog/the-idea-a-native-svelte-grid/">The Idea - Svelte 5 Deserves a Data Grid Built for It</a></li>
<li><a href="https://svgrid.com/blog/column-virtualization-explained/">Column Virtualization Explained</a></li>
<li><a href="https://svgrid.com/blog/virtual-scrolling-explained/">Virtual Scrolling Explained</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>A Fill Handle (Drag to Fill) in SvGrid</title>
      <link>https://svgrid.com/blog/fill-handle-drag-to-fill/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/fill-handle-drag-to-fill/</guid>
      <pubDate>Wed, 29 Jul 2026 07:12:00 GMT</pubDate>
      <dc:creator>Boyko Markov</dc:creator>
      <category>fill handle</category>
      <category>editing</category>
      <category>spreadsheet</category>
      <category>cell selection</category>
      <category>recipe</category>
      <description>Build a working spreadsheet-style fill handle on top of SvGrid's cell selection and editing - pointer tracking, range highlighting, series fill, and undo/redo integration all covered.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/fill-handle-drag-to-fill.png" width="1200" height="630" alt="" /></p><p>The fill handle is the first thing spreadsheet users notice when it is missing. That tiny square in the active cell&#39;s bottom-right corner - grab it, drag it down, and the value propagates. It feels like a minor convenience but for anyone doing repetitive data entry it is the difference between a grid and a toy.</p>
<p>SvGrid provides cell selection, editable columns, and a mutation API. The fill handle is not a built-in because it is genuinely presentation-layer work: a DOM element inside a cell snippet, a couple of pointer event listeners, and logic to decide what gets written to the filled range. This post walks through a complete implementation, including series detection and undo integration.</p>
<p><img src="https://svgrid.com/blog-media/fill-handle.png" alt="A spreadsheet fill handle in SvGrid">
<em>Drag the corner handle to propagate a value down a column.</em></p>
<h2 id="the-architecture-in-one-sentence">The architecture in one sentence</h2><p>Render a handle inside the active cell snippet, track <code>pointermove</code> to determine the fill range, and on <code>pointerup</code> apply the source value to every row in that range through the normal edit commit path.</p>
<p>That last part - through the normal commit path - is the part most quick implementations skip, and it is what causes bugs later.</p>
<h2 id="setting-up-the-cell-snippet">Setting up the cell snippet</h2><p>SvGrid accepts a Svelte 5 snippet for cell rendering. The fill handle is a child element rendered only when the cell is active. The handle itself is just a styled <code>&lt;span&gt;</code> positioned at the bottom-right corner via CSS.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid, { type ColumnDef, type SvGridApi } from &#39;@svgrid/grid&#39;

  type Row = { id: number; qty: number; price: number; category: string }

  let rows = $state&lt;Row[]&gt;([
    { id: 1, qty: 10, price: 4.99, category: &#39;A&#39; },
    { id: 2, qty: 0,  price: 0,    category: &#39;&#39; },
    { id: 3, qty: 0,  price: 0,    category: &#39;&#39; },
    { id: 4, qty: 0,  price: 0,    category: &#39;&#39; },
  ])

  let api = $state&lt;SvGridApi&gt;()
  let activeCell = $state&lt;{ row: number; col: string } | null&gt;(null)
  let fillFrom = $state&lt;{ row: number; col: string; value: unknown } | null&gt;(null)
  let fillTo   = $state&lt;number | null&gt;(null)
&lt;/script&gt;

{#snippet fillCell(p: { row: Row; rowIndex: number; field: string; value: unknown })}
  {@const isActive = activeCell?.row === p.rowIndex &amp;&amp; activeCell?.col === p.field}
  {@const isFillTarget =
    fillFrom &amp;&amp; fillTo !== null &amp;&amp;
    fillFrom.col === p.field &amp;&amp;
    p.rowIndex &gt; fillFrom.row &amp;&amp;
    p.rowIndex &lt;= fillTo}
  &lt;span
    class=&quot;sg-cell-inner&quot;
    class:fill-target={isFillTarget}
    onpointerdown={() =&gt; { activeCell = { row: p.rowIndex, col: p.field } }}
  &gt;
    {p.value}
    {#if isActive}
      &lt;span
        class=&quot;fill-handle&quot;
        onpointerdown={(e) =&gt; startFill(e, p.rowIndex, p.field, p.value)}
      &gt;&lt;/span&gt;
    {/if}
  &lt;/span&gt;
{/snippet}

&lt;SvGrid
  data={rows}
  columns={columns}
  editable
  enableCellSelection
  onApiReady={(a) =&gt; { api = a }}
/&gt;
</code></pre>
<p>The <code>fill-target</code> class lets you highlight the fill range visually as the user drags - a blue overlay on each candidate cell. The active cell check ensures only the selected cell shows the handle, not every cell in the column.</p>
<h2 id="tracking-the-drag">Tracking the drag</h2><p>Pointer capture keeps the drag smooth even if the cursor leaves the grid area. The key calculation is <code>rowIndexAt(clientY)</code>: translate a Y coordinate into a row index by querying the grid&#39;s row elements or using the virtualizer&#39;s scroll offset.</p>
<pre><code class="language-ts">let rowHeightPx = 36 // match your rowHeight prop

function rowIndexAt(clientY: number): number {
  const gridEl = document.querySelector(&#39;.sv-grid-body&#39;) as HTMLElement
  if (!gridEl) return 0
  const rect = gridEl.getBoundingClientRect()
  const scrollTop = gridEl.scrollTop
  const relY = clientY - rect.top + scrollTop
  return Math.max(0, Math.min(rows.length - 1, Math.floor(relY / rowHeightPx)))
}

function startFill(
  e: PointerEvent,
  fromRow: number,
  fromCol: string,
  value: unknown
) {
  e.preventDefault()
  e.stopPropagation()
  ;(e.target as Element).setPointerCapture(e.pointerId)

  fillFrom = { row: fromRow, col: fromCol, value }
  fillTo = fromRow

  function onMove(ev: PointerEvent) {
    const idx = rowIndexAt(ev.clientY)
    if (idx &gt; fromRow) fillTo = idx
  }

  function onUp() {
    commitFill()
    fillFrom = null
    fillTo = null
    window.removeEventListener(&#39;pointermove&#39;, onMove)
    window.removeEventListener(&#39;pointerup&#39;, onUp)
  }

  window.addEventListener(&#39;pointermove&#39;, onMove)
  window.addEventListener(&#39;pointerup&#39;, onUp)
}
</code></pre>
<p>One detail: only allow downward fills (<code>idx &gt; fromRow</code>). You can add upward fills, but they are rarely needed and complicate the range indicator. Ship the common case first.</p>
<h2 id="committing-the-fill">Committing the fill</h2><p>This is where most implementations cut corners. Directly mutating <code>rows[i][field]</code> works, but it bypasses any validation and leaves undo broken. Route the fill through <code>api.applyTransaction</code> instead, which integrates with SvGrid&#39;s edit history.</p>
<pre><code class="language-ts">function commitFill() {
  if (!fillFrom || fillTo === null || fillTo &lt;= fillFrom.row) return

  const { row: fromRow, col: field, value: sourceValue } = fillFrom

  // Optional: detect numeric series (1, 2 -&gt; 3, 4, 5...)
  const step = detectStep(field, fromRow)

  const updates: Row[] = []

  for (let r = fromRow + 1; r &lt;= fillTo; r++) {
    const current = rows[r]
    const filled = step !== null
      ? { ...current, [field]: Number(sourceValue) + step * (r - fromRow) }
      : { ...current, [field]: sourceValue }
    updates.push(filled)
  }

  // applyTransaction keeps undo/redo working
  api?.applyTransaction({ update: updates })
}

function detectStep(field: string, fromRow: number): number | null {
  // Require at least two rows above the drag start to detect a pattern
  if (fromRow &lt; 1) return null
  const prev = rows[fromRow - 1][field as keyof Row]
  const curr = rows[fromRow][field as keyof Row]
  if (typeof prev !== &#39;number&#39; || typeof curr !== &#39;number&#39;) return null
  const step = curr - prev
  // Only treat as a series if the step is non-zero and reasonably small
  return step !== 0 &amp;&amp; Math.abs(step) &lt; 1e6 ? step : null
}
</code></pre>
<p><code>detectStep</code> looks at the two rows immediately above the drag start. If they are both numbers with a consistent difference, the fill extrapolates rather than copying. Dates can be handled similarly by converting to timestamps, incrementing by <code>step</code> milliseconds, and converting back.</p>
<h2 id="the-css">The CSS</h2><p>Keep it minimal - a small square that appears on hover of the active cell and changes the cursor to a crosshair during drag:</p>
<pre><code class="language-css">.sg-cell-inner {
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  padding: 0 var(--sg-cell-px);
  line-height: var(--sg-cell-height, 36px);
}

.fill-handle {
  position: absolute;
  right: -3px;
  bottom: -3px;
  width: 7px;
  height: 7px;
  background: var(--sg-accent, #3b82f6);
  border: 1px solid #fff;
  border-radius: 1px;
  cursor: crosshair;
  z-index: 10;
}

.fill-target {
  background: color-mix(in srgb, var(--sg-accent, #3b82f6) 15%, transparent);
  outline: 1px solid var(--sg-accent, #3b82f6);
  outline-offset: -1px;
}
</code></pre>
<p>Using <code>--sg-accent</code> means the handle color will match any theme the user applies to the grid. If the user customises <code>--sg-accent</code> to their brand color, the fill handle follows automatically.</p>
<h2 id="column-definition-wiring">Column definition wiring</h2><p>Attach the snippet to whichever columns should be fillable. Non-fillable columns - row numbers, action buttons - should not show the handle:</p>
<pre><code class="language-ts">const columns: ColumnDef[] = [
  { id: &#39;id&#39;,       field: &#39;id&#39;,       header: &#39;#&#39;,        width: 60,  editable: false },
  { id: &#39;qty&#39;,      field: &#39;qty&#39;,      header: &#39;Qty&#39;,      width: 100, editable: true, type: &#39;number&#39;, cell: fillCell },
  { id: &#39;price&#39;,    field: &#39;price&#39;,    header: &#39;Price&#39;,    width: 120, editable: true, type: &#39;number&#39;, cell: fillCell },
  { id: &#39;category&#39;, field: &#39;category&#39;, header: &#39;Category&#39;, width: 160, editable: true, cell: fillCell },
]
</code></pre>
<p>The <code>fillCell</code> snippet is the same one for all fillable columns - the <code>field</code> parameter inside it carries the column identity, so one snippet serves all columns.</p>
<h2 id="where-this-breaks-and-what-to-do-about-it">Where this breaks and what to do about it</h2><p><strong>Virtualization and row height.</strong> The <code>rowIndexAt</code> function above uses a fixed <code>rowHeightPx</code>. If your grid uses variable row heights or the <code>autoRowHeight</code> option, you need a different approach: iterate the rendered row elements and find the one whose bounding rect contains <code>clientY</code>. Slower, but accurate.</p>
<p><strong>Pinned columns.</strong> Pinned columns render in a separate DOM container. The fill handle&#39;s <code>rowIndexAt</code> call needs to account for which pane the pointer is in. If your fillable columns are all in the scrollable body, this is not an issue.</p>
<p><strong>Read-only rows.</strong> If some rows are conditionally read-only, add a guard in <code>commitFill</code> that skips those rows rather than overwriting them.</p>
<p><strong>Touch devices.</strong> <code>PointerEvent</code> covers both mouse and touch, but <code>setPointerCapture</code> behaves differently on touch. Test on mobile if your users care about it; the fill handle is fundamentally a mouse interaction and often it is acceptable to leave it mouse-only.</p>
<p>The fill handle is about 80 lines of logic. Most of that complexity lives in <code>commitFill</code> and <code>detectStep</code> - the actual drag tracking is straightforward. Keeping it attached to <code>applyTransaction</code> rather than direct mutation means undo, redo, and any server-sync logic your app has will treat fills the same as any other edit.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/paste-from-excel/">Paste from Excel into a Svelte Data Grid</a></li>
<li><a href="https://svgrid.com/blog/editable-select-dropdown-cell/">An Editable Select / Dropdown Cell in SvGrid</a></li>
<li><a href="https://svgrid.com/blog/date-picker-cell-editor/">A Date-Picker Cell Editor in SvGrid</a></li>
<li><a href="https://svgrid.com/blog/autocomplete-cell-editor/">An Autocomplete Cell Editor in SvGrid</a></li>
<li><a href="https://svgrid.com/blog/cell-range-selection/">Spreadsheet-Style Cell Range Selection in SvGrid</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>What Makes a Svelte Data Grid Fast (and How to Measure It)</title>
      <link>https://svgrid.com/blog/fastest-svelte-data-grid/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/fastest-svelte-data-grid/</guid>
      <pubDate>Tue, 28 Jul 2026 07:12:00 GMT</pubDate>
      <dc:creator>Kamelia M</dc:creator>
      <category>performance</category>
      <category>benchmark</category>
      <category>comparison</category>
      <category>svelte data grid</category>
      <description>Performance claims are easy to make. Here is how to actually measure grid speed - what metrics matter, what traps to avoid, and what the fundamentals look like in code.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/fastest-svelte-data-grid.png" width="1200" height="630" alt="" /></p><p>Most &quot;fastest grid&quot; posts are marketing. This one is not. If you need to pick a Svelte data grid and performance is on your list of requirements, here is what to measure, why naive benchmarks mislead you, and what actually separates a fast grid from a slow one.</p>
<p><img src="https://svgrid.com/blog-media/million-rows.png" alt="A million-row dataset in SvGrid, kept smooth by virtualization.">
<em>A million-row dataset in SvGrid, kept smooth by virtualization.</em></p>
<h2 id="the-three-things-that-actually-determine-speed">The three things that actually determine speed</h2><p>Grid performance comes down to three independent problems, and a grid can be good at one while being terrible at the others.</p>
<p><strong>Virtualization.</strong> This is the most important one. A grid that renders 100,000 rows to the DOM is not a data grid - it is a crash waiting to happen. Real virtualization keeps the number of rendered rows constant regardless of dataset size, typically in the range of visible rows plus a small overscan buffer. Row virtualization is table stakes; column virtualization matters once you have more than ~30 columns.</p>
<p><strong>Update cost.</strong> How expensive is it to change a cell value or apply a batch of updates? Virtual-DOM grids have to diff the entire rendered tree on each update. Svelte 5&#39;s fine-grained reactivity eliminates that: each cell tracks its own dependencies and only the affected cells repaint. The difference is invisible at 100 rows and dramatic at 10,000.</p>
<p><strong>Pipeline overhead.</strong> Sort, filter, group - these transformations run on every state change. A well-designed grid sorts once per change and reuses the result; a poorly designed one sorts inside the render function and throws it away. With large datasets this is the thing that makes UI interactions feel laggy even when the DOM work is fast.</p>
<h2 id="setting-up-a-meaningful-benchmark">Setting up a meaningful benchmark</h2><p>If you want to compare grids seriously, here is the minimal setup that gives you honest results.</p>
<p>Same data, same columns, production builds, same browser, same machine. Run on a mid-range laptop with CPU throttled to 4x in DevTools - that is where differences are visible. Average at least five runs per scenario.</p>
<p>The scenarios worth measuring:</p>
<ul>
<li><strong>Initial render</strong>: time from data load to interactive. Use the browser&#39;s Performance panel, not <code>Date.now()</code>.</li>
<li><strong>Scroll frame budget</strong>: scroll from top to bottom, check the main thread for frames over 16ms.</li>
<li><strong>Live update throughput</strong>: apply 1,000 cell updates per second and measure jank.</li>
<li><strong>Sort/filter latency</strong>: time from user click to repaint on a 50,000-row dataset.</li>
</ul>
<p>Here is a minimal SvGrid setup you can use as a baseline for your own benchmark:</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import {
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    rowSelectionFeature,
    rowPaginationFeature,
    type ColumnDef,
    type SvGridApi,
  } from &#39;@svgrid/grid&#39;

  // Generate a realistic dataset
  const ROW_COUNT = 50_000
  const rows = Array.from({ length: ROW_COUNT }, (_, i) =&gt; ({
    id: i,
    name: `Row ${i}`,
    value: Math.random() * 1000,
    category: [&#39;A&#39;, &#39;B&#39;, &#39;C&#39;, &#39;D&#39;][i % 4],
    status: i % 3 === 0 ? &#39;active&#39; : &#39;inactive&#39;,
    score: Math.round(Math.random() * 100),
  }))

  const features = tableFeatures({
    rowSortingFeature,
    columnFilteringFeature,
    rowSelectionFeature,
    rowPaginationFeature,
  })

  const columns: ColumnDef&lt;typeof features, (typeof rows)[0]&gt;[] = [
    { id: &#39;id&#39;,       field: &#39;id&#39;,       header: &#39;ID&#39;,       width: 80  },
    { id: &#39;name&#39;,     field: &#39;name&#39;,     header: &#39;Name&#39;,     width: 180 },
    { id: &#39;value&#39;,    field: &#39;value&#39;,    header: &#39;Value&#39;,    width: 120, type: &#39;number&#39; },
    { id: &#39;category&#39;, field: &#39;category&#39;, header: &#39;Category&#39;, width: 120 },
    { id: &#39;status&#39;,   field: &#39;status&#39;,   header: &#39;Status&#39;,   width: 120 },
    { id: &#39;score&#39;,    field: &#39;score&#39;,    header: &#39;Score&#39;,    width: 100, type: &#39;number&#39; },
  ]

  let api: SvGridApi | undefined

  // Mark render complete for timing
  $effect(() =&gt; {
    if (api) {
      performance.mark(&#39;grid-ready&#39;)
    }
  })
&lt;/script&gt;

&lt;SvGrid
  data={rows}
  {columns}
  sortable
  filterable
  virtualization={true}
  rowHeight={30}
  onApiReady={(a) =&gt; { api = a }}
/&gt;
</code></pre>
<p>Run that, open the Performance panel, record a sort click on a column, and look at the resulting flame chart. That tells you far more than any published benchmark.</p>
<h2 id="the-update-scenario-most-benchmarks-skip">The update scenario most benchmarks skip</h2><p>Scroll and initial render get all the attention. Live updates are where production apps actually hurt.</p>
<p>Consider a trading dashboard or a monitoring view where hundreds of cells change per second. The naive approach - mutate the data array and let the grid re-render - forces the grid to re-evaluate every visible cell on every tick.</p>
<p>SvGrid handles this through <code>applyTransaction</code>, which accepts explicit add/update/remove lists and only repaints affected cells:</p>
<pre><code class="language-typescript">import type { SvGridApi } from &#39;@svgrid/grid&#39;

// Simulate a live feed applying 200 changes every 100ms
function startFeed(api: SvGridApi) {
  setInterval(() =&gt; {
    const updates = Array.from({ length: 200 }, () =&gt; ({
      id: Math.floor(Math.random() * 50_000),
      value: Math.random() * 1000,
      score: Math.round(Math.random() * 100),
    }))

    api.applyTransaction({ update: updates })
  }, 100)
}
</code></pre>
<p>At 200 updates per 100ms (2,000/sec), a grid without surgical update support will visibly stutter on mid-range hardware. The right thing to measure here is not &quot;does it work&quot; but &quot;what is the 99th-percentile frame time under sustained load.&quot;</p>
<h2 id="what-conditional-formatting-costs-you">What conditional formatting costs you</h2><p>One performance foot-gun that rarely gets benchmarked: conditional formatting functions run on every cell during render. If they are expensive, they show up everywhere.</p>
<pre><code class="language-typescript">import { type ColumnDef } from &#39;@svgrid/grid&#39;

// Cheap: pure value comparison
const scoreColumn: ColumnDef&lt;typeof features, Row&gt; = {
  id: &#39;score&#39;,
  field: &#39;score&#39;,
  header: &#39;Score&#39;,
  width: 100,
  type: &#39;number&#39;,
  conditionalFormat: [
    { condition: ({ value }) =&gt; value &lt; 40,  style: { color: &#39;var(--red)&#39;,   fontWeight: &#39;bold&#39; } },
    { condition: ({ value }) =&gt; value &gt;= 80, style: { color: &#39;var(--green)&#39;, fontWeight: &#39;bold&#39; } },
  ],
}

// Expensive: avoid anything that allocates or accesses external state per cell
// Bad:
// conditionalFormat: [{ condition: ({ value, row }) =&gt; expensiveRankLookup(row), style: {...} }]
</code></pre>
<p>The condition function gets called for every visible cell on every render cycle. Keep it a pure value comparison. If you need something more complex, precompute a lookup when the data changes and close over it.</p>
<h2 id="server-side-data-and-where-virtualization-changes">Server-side data and where virtualization changes</h2><p>The setup above assumes client-side data. If you are loading from a server, the performance model is different: you are not virtualizing 50,000 local rows, you are paging through a server-side cursor. SvGrid&#39;s <code>createServerDataSource</code> handles the wiring:</p>
<pre><code class="language-typescript">import { createServerDataSource } from &#39;@svgrid/grid&#39;

const ds = createServerDataSource({
  fetch: async ({ page, pageSize, sort, filters }) =&gt; {
    const params = new URLSearchParams({
      page: String(page),
      size: String(pageSize),
    })

    if (sort.length &gt; 0) {
      params.set(&#39;sortField&#39;, sort[0].id)
      params.set(&#39;sortDir&#39;, sort[0].desc ? &#39;desc&#39; : &#39;asc&#39;)
    }

    for (const f of filters) {
      params.set(`filter_${f.id}`, JSON.stringify(f.value))
    }

    const res = await fetch(`/api/rows?${params}`)
    const json = await res.json()
    return { rows: json.data, total: json.total }
  },
})
</code></pre>
<p>In this mode, render performance is almost irrelevant - you are rarely showing more than a few hundred rows at once. What matters is request latency, debounce on filter input, and avoiding redundant fetches. Profile the network tab, not the flame chart.</p>
<h2 id="reading-the-flame-chart">Reading the flame chart</h2><p>When you do record a Performance trace, look for these patterns:</p>
<ul>
<li><strong>Long tasks on sort/filter</strong>: the pipeline is not caching the sorted result.</li>
<li><strong>Many small Svelte update tasks on scroll</strong>: virtualization is working but row recycling is expensive - check if custom cell snippets are doing unnecessary work.</li>
<li><strong>Flat main thread during bulk updates</strong>: good sign that surgical updates are working correctly.</li>
<li><strong>Paint calls on every keypress in a filter input</strong>: the grid is re-rendering on every keystroke rather than debouncing.</li>
</ul>
<p>No grid, including SvGrid, will have a perfect flame chart on every workload. The goal is understanding where the time goes so you can decide whether it matters for your specific case.</p>
<h2 id="an-honest-take-on-published-benchmarks">An honest take on published benchmarks</h2><p>Published &quot;fastest grid&quot; benchmarks are almost always produced by the grid vendor. That is not necessarily dishonest - vendors know their product and can set it up optimally - but it does mean the comparison grids are often configured suboptimally or measured in scenarios where the vendor&#39;s approach happens to shine.</p>
<p>The only benchmark that matters for your project is the one you run on your actual data, your actual columns, your actual update patterns, on the hardware your users have. Set that up, measure the candidates, and pick the one that wins on the metrics your app cares about.</p>
<p>SvGrid&#39;s approach - Svelte 5 runes for surgical cell updates, row and column virtualization on by default, and a headless pipeline that separates data transformation from rendering - is designed to be fast across all three dimensions, not just one. Test it against your workload and see where it lands.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/porting-mui-x-datagrid-to-svelte/">Porting a React MUI X DataGrid Screen to Svelte</a></li>
<li><a href="https://svgrid.com/blog/open-source-vs-commercial-svelte-grids/">Open-Source vs Commercial Svelte Data Grids</a></li>
<li><a href="https://svgrid.com/blog/most-accessible-svelte-data-grid/">Choosing the Most Accessible Svelte Data Grid</a></li>
<li><a href="https://svgrid.com/blog/migrating-from-datatables-to-svelte/">Migrating from DataTables.net to a Svelte Data Grid</a></li>
<li><a href="https://svgrid.com/blog/migrating-from-ag-grid-react-to-svelte/">Migrating from ag-grid-react to a Svelte Stack</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
    <item>
      <title>Empty, Loading, and Error States for a Svelte Data Grid</title>
      <link>https://svgrid.com/blog/empty-loading-and-error-states-svelte-grid/</link>
      <guid isPermaLink="true">https://svgrid.com/blog/empty-loading-and-error-states-svelte-grid/</guid>
      <pubDate>Mon, 27 Jul 2026 07:12:00 GMT</pubDate>
      <dc:creator>Kamelia M</dc:creator>
      <category>empty state</category>
      <category>loading</category>
      <category>error handling</category>
      <category>ux</category>
      <category>recipe</category>
      <description>How to handle the three non-data states in a Svelte grid - empty (with two subtypes), skeleton loading that does not flash, and recoverable errors that keep existing rows visible.</description>
      <content:encoded><![CDATA[<p><img src="https://svgrid.com/og/blog/empty-loading-and-error-states-svelte-grid.png" width="1200" height="630" alt="" /></p><p>Every grid demo ships with data already loaded. Every grid in production starts empty, stalls while fetching, and occasionally breaks. Those three states are where the real UX work happens, and they are almost always the last things teams implement - if at all.</p>
<p><img src="https://svgrid.com/blog-media/quick-start.png" alt="Empty, Loading, and Error States for a Svelte Data Grid"></p>
<p>The fix is not complicated. It is mostly a matter of thinking through each state before you write the happy path instead of after.</p>
<h2 id="two-empties-two-messages">Two empties, two messages</h2><p>The single most common mistake I see in grids is conflating two distinct empty states into one. &quot;No data&quot; and &quot;no matches&quot; are different problems, and treating them the same sends the user down the wrong path.</p>
<p>If a user clears the search box and gets &quot;No records found,&quot; they might think the table is broken or that no data was ever loaded. If they applied three filters and your grid says &quot;Add your first record,&quot; that is equally confusing. You need to know which empty you are in.</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import {
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
  } from &#39;@svgrid/grid&#39;
  import type { ColumnDef, TableFeatures } from &#39;@svgrid/grid&#39;

  let { rows, columns, loading, error, onRetry } = $props()

  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })

  // Derived from the filter state, not a hardcoded flag
  let api = $state(null)
  let hasActiveFilters = $derived(
    api ? api.getState().columnFilters?.length &gt; 0 : false
  )

  function clearFilters() {
    api?.clearAllFilters()
  }
&lt;/script&gt;

{#if !loading &amp;&amp; !error &amp;&amp; rows.length === 0}
  &lt;div class=&quot;sg-empty-state&quot;&gt;
    {#if hasActiveFilters}
      &lt;p&gt;No rows match the active filters.&lt;/p&gt;
      &lt;button onclick={clearFilters}&gt;Clear filters&lt;/button&gt;
    {:else}
      &lt;p&gt;No records yet.&lt;/p&gt;
    {/if}
  &lt;/div&gt;
{:else if !error}
  &lt;SvGrid
    data={rows}
    {columns}
    {features}
    onApiReady={(a) =&gt; { api = a }}
  /&gt;
{/if}
</code></pre>
<p>The <code>hasActiveFilters</code> check reads from the grid&#39;s own state rather than a manually-tracked boolean - that way it stays accurate when filters are applied programmatically, not just through the filter row.</p>
<h2 id="skeleton-on-first-load-overlay-on-refetch">Skeleton on first load, overlay on refetch</h2><p>Spinners have two problems: they give no sense of layout, and they cause a jarring layout shift when they disappear. A skeleton that mimics the grid&#39;s row structure avoids both. Users see where the columns will land before data arrives.</p>
<p>The subtler problem is what to show on a refetch - when the user pages, sorts, or refreshes and the grid already has rows. Blanking the grid on every page flip feels like a bug. The better pattern is to keep the current rows visible and apply a low-opacity overlay to signal &quot;fetching without replacing.&quot;</p>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import { tableFeatures, rowPaginationFeature, rowSortingFeature } from &#39;@svgrid/grid&#39;
  import { createServerDataSource } from &#39;@svgrid/grid&#39;
  import type { ColumnDef } from &#39;@svgrid/grid&#39;

  let { columns } = $props()

  let loading = $state(true)
  let fetching = $state(false)  // subsequent loads
  let rows = $state([])

  const features = tableFeatures({ rowSortingFeature, rowPaginationFeature })

  const ds = createServerDataSource({
    fetch: async ({ page, pageSize, sort, filters }) =&gt; {
      // First load vs refetch distinction
      if (rows.length === 0) loading = true
      else fetching = true

      try {
        const res = await fetch(
          `/api/records?page=${page}&amp;size=${pageSize}`
        )
        const json = await res.json()
        rows = json.data
        return { rows: json.data, total: json.total }
      } finally {
        loading = false
        fetching = false
      }
    }
  })
&lt;/script&gt;

{#if loading}
  &lt;!-- Skeleton: fixed height rows that match the real row height --&gt;
  &lt;div class=&quot;sg-skeleton&quot; aria-busy=&quot;true&quot; aria-label=&quot;Loading data&quot;&gt;
    {#each { length: 8 } as _, i}
      &lt;div class=&quot;sg-skeleton-row&quot; style=&quot;--delay: {i * 60}ms&quot;&gt;&lt;/div&gt;
    {/each}
  &lt;/div&gt;
{:else}
  &lt;div class=&quot;sg-grid-wrapper&quot; class:sg-fetching={fetching}&gt;
    &lt;SvGrid
      data={ds}
      {columns}
      {features}
      pageable
    /&gt;
  &lt;/div&gt;
{/if}
</code></pre>
<pre><code class="language-css">.sg-skeleton-row {
  height: 32px;
  margin-bottom: 1px;
  background: linear-gradient(
    90deg,
    var(--sg-border) 25%,
    var(--sg-bg) 50%,
    var(--sg-border) 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.4s ease-in-out var(--delay, 0ms) infinite;
  border-radius: var(--sg-radius);
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

.sg-fetching {
  opacity: 0.6;
  pointer-events: none;
  transition: opacity 150ms ease;
}
</code></pre>
<p>The <code>--delay</code> custom property staggers the shimmer animation across rows so they do not all pulse in unison, which looks mechanical.</p>
<h2 id="error-handling-that-does-not-lose-the-users-work">Error handling that does not lose the user&#39;s work</h2><p>The instinct is to replace the grid with an error panel. That instinct is wrong when the grid already has data. A failed page change or refresh should not wipe the screen - the user is still looking at valid (if stale) rows.</p>
<p>The two cases to handle separately:</p>
<ol>
<li><strong>Initial load fails</strong> - nothing to show, so display the error with a retry button.</li>
<li><strong>Refetch fails</strong> - keep the current rows visible and show a non-blocking error notice, probably a toast or a small banner above the grid.</li>
</ol>
<pre><code class="language-svelte">&lt;script lang=&quot;ts&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import { tableFeatures, rowSortingFeature } from &#39;@svgrid/grid&#39;
  import type { ColumnDef } from &#39;@svgrid/grid&#39;

  let { columns } = $props()

  let rows = $state([])
  let error = $state&lt;string | null&gt;(null)
  let fetchError = $state&lt;string | null&gt;(null)  // non-destructive error

  const features = tableFeatures({ rowSortingFeature })

  async function loadData() {
    error = null
    fetchError = null
    try {
      const res = await fetch(&#39;/api/records&#39;)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      rows = await res.json()
    } catch (e) {
      if (rows.length === 0) {
        // Initial load failed - nothing to keep
        error = (e as Error).message
      } else {
        // Refetch failed - show non-destructive notice
        fetchError = &#39;Could not refresh. Showing previous data.&#39;
      }
    }
  }

  $effect(() =&gt; { loadData() })
&lt;/script&gt;

{#if error}
  &lt;div class=&quot;sg-error-state&quot; role=&quot;alert&quot;&gt;
    &lt;p&gt;Could not load data: {error}&lt;/p&gt;
    &lt;button onclick={loadData}&gt;Retry&lt;/button&gt;
  &lt;/div&gt;
{:else}
  {#if fetchError}
    &lt;div class=&quot;sg-error-banner&quot; role=&quot;status&quot;&gt;{fetchError}&lt;/div&gt;
  {/if}
  &lt;SvGrid
    data={rows}
    {columns}
    {features}
  /&gt;
{/if}
</code></pre>
<p>One detail worth getting right: <code>role=&quot;alert&quot;</code> on the full-screen error triggers screen reader announcement immediately. <code>role=&quot;status&quot;</code> on the non-destructive banner announces it politely without interrupting.</p>
<h2 id="putting-it-together-in-a-shared-component">Putting it together in a shared component</h2><p>These three states belong in a single wrapper component you drop in once and reuse across every grid in the app. The wrapper handles state logic; the grid handles data display.</p>
<pre><code class="language-svelte">&lt;!-- GridShell.svelte --&gt;
&lt;script lang=&quot;ts&quot; generics=&quot;TRow&quot;&gt;
  import SvGrid from &#39;@svgrid/grid&#39;
  import type { ColumnDef, SvGridOptions } from &#39;@svgrid/grid&#39;

  let {
    data,
    columns,
    features,
    loading = false,
    fetching = false,
    error = null,
    refetchError = null,
    emptyMessage = &#39;No records.&#39;,
    hasActiveFilters = false,
    onClearFilters,
    onRetry,
    ...rest
  }: {
    data: TRow[]
    columns: ColumnDef&lt;any, TRow&gt;[]
    features: any
    loading?: boolean
    fetching?: boolean
    error?: string | null
    refetchError?: string | null
    emptyMessage?: string
    hasActiveFilters?: boolean
    onClearFilters?: () =&gt; void
    onRetry?: () =&gt; void
  } = $props()
&lt;/script&gt;

{#if loading}
  &lt;div class=&quot;sg-skeleton&quot; aria-busy=&quot;true&quot;&gt;
    {#each { length: 8 } as _, i}
      &lt;div class=&quot;sg-skeleton-row&quot; style=&quot;--delay: {i * 60}ms&quot;&gt;&lt;/div&gt;
    {/each}
  &lt;/div&gt;
{:else if error}
  &lt;div class=&quot;sg-error-state&quot; role=&quot;alert&quot;&gt;
    &lt;p&gt;{error}&lt;/p&gt;
    {#if onRetry}&lt;button onclick={onRetry}&gt;Retry&lt;/button&gt;{/if}
  &lt;/div&gt;
{:else if data.length === 0}
  &lt;div class=&quot;sg-empty-state&quot;&gt;
    {#if hasActiveFilters}
      &lt;p&gt;No rows match the active filters.&lt;/p&gt;
      {#if onClearFilters}&lt;button onclick={onClearFilters}&gt;Clear filters&lt;/button&gt;{/if}
    {:else}
      &lt;p&gt;{emptyMessage}&lt;/p&gt;
    {/if}
  &lt;/div&gt;
{:else}
  {#if refetchError}
    &lt;div class=&quot;sg-error-banner&quot; role=&quot;status&quot;&gt;{refetchError}&lt;/div&gt;
  {/if}
  &lt;div class=&quot;sg-grid-wrapper&quot; class:sg-fetching={fetching}&gt;
    &lt;SvGrid {data} {columns} {features} {...rest} /&gt;
  &lt;/div&gt;
{/if}
</code></pre>
<p>The generics annotation on the script tag keeps column type inference intact through the wrapper, so you do not lose the TypeScript benefit of typed row data.</p>
<h2 id="the-states-worth-testing-explicitly">The states worth testing explicitly</h2><p>Happy-path tests are not enough here. Each state needs at least one test case where you can verify the right message appears and the right action is available. In practice that means Playwright tests or Storybook stories - one story per state, with static data stubbed for each scenario.</p>
<p>Three states, three stories: empty with no filters, empty with filters active, loading skeleton, initial error with retry, refetch error with stale data visible. That is the minimum bar for a grid you would call production-ready.</p>
<!-- related:start -->

<h2 id="related-reading">Related reading</h2><ul>
<li><a href="https://svgrid.com/blog/importing-csv-into-the-grid/">Importing CSV into a Svelte Data Grid</a></li>
<li><a href="https://svgrid.com/blog/sync-grid-state-to-url/">Sync Grid State to the URL in Svelte</a></li>
<li><a href="https://svgrid.com/blog/progress-bar-cells/">Progress and Percentage Bar Cells in SvGrid</a></li>
<li><a href="https://svgrid.com/blog/paste-from-excel/">Paste from Excel into a Svelte Data Grid</a></li>
<li><a href="https://svgrid.com/blog/optimistic-ui-explained/">Optimistic UI Explained</a></li>
</ul>
<!-- related:end -->
]]></content:encoded>
    </item>
  </channel>
</rss>
