Controlled state

The engine never owns its state - you do. You pass state in and the engine calls your onXxxChange handlers with the next value. That one rule is what makes headless SvGrid click with Svelte 5 $state, and it's what lets state survive an unmount or be shared across two grids.

The in/out contract

let sorting = $state<{ id: string; desc: boolean }[]>([])

const table = $derived.by(() => createSvGrid({
  _features: features,
  _rowModels: { coreRowModel: createCoreRowModel<Row>(), sortedRowModel: createSortedRowModel<Row>() },
  data, columns,
  state: { sorting },                         // ── in
  onSortingChange: (u) =>                      // ── out
    (sorting = typeof u === 'function' ? u(sorting) : u),
}))

The handler receives either the next value or an updater function - always handle both (typeof u === 'function' ? u(prev) : u). Because sorting is $state and table is $derived, changing it re-runs the pipeline.

The same shape applies to every state channel: onColumnFiltersChange, onPaginationChange, onGroupingChange, onExpandedChange, onRowSelectionChange, onColumnVisibilityChange.

createGridState - lift state out of the component

Sometimes you want a piece of state to live outside the grid: so it survives an unmount, persists to storage, or is shared by two grids. createGridState returns a [get, set] tuple - a reactive store you own (createSvGridState is an alias).

import { createGridState } from '@svgrid/grid/core'

// [getter, setter] - the getter reads reactive $state, the setter takes a
// value or an updater function.
const [getSorting, setSorting] = createGridState<Sort[]>([])

setSorting([{ id: 'stars', desc: true }])   // set a value
setSorting((prev) => [...prev])             // or an updater
console.log(getSorting())                    // read it

Wire it into a grid via the state / onXxxChange channel:

const table = $derived.by(() => createSvGrid({
  get data() { return rows },
  get columns() { return columns },
  state: { sorting: getSorting() },
  onSortingChange: setSorting,   // the setter already handles updaters
}))

Share one store across two grids

Pass the same [get, set] pair to two engines and they stay in lockstep - sort in one, the other re-sorts, because both read the same reactive store.

Open the live example: Two grids, one shared state (Headless)

const [getSorting, setSorting] = createGridState<Sort[]>([])

const makeGrid = () => $derived.by(() => createSvGrid({
  get data() { return rows }, get columns() { return columns },
  state: { sorting: getSorting() },
  onSortingChange: setSorting,
}))

const gridA = makeGrid()
const gridB = makeGrid()   // shares getSorting/setSorting -> stays in sync

subscribeGrid - react outside a component

Inside a Svelte component, $effect is the natural way to react to grid changes. Outside one - a Svelte action, an analytics hook, an integration test - use subscribeGrid for a plain pub/sub interface (subscribeSvGrid is an alias).

import { subscribeGrid } from '@svgrid/grid/core'

const unsub = subscribeGrid(table, (event) => {
  if (event.type === 'sorting') analytics.track('grid_sort', event.value)
  if (event.type === 'rowSelection') syncSelectionToUrl(event.value)
})

// later
unsub()

You own the state

Every slice you pass in state is yours: the engine reads it and reports changes through the matching on*Change, but never writes it. That is what lets the same value drive a URL, a saved view, or two grids at once.

<script lang="ts">
  import {
    createSvGrid,
    createCoreRowModel,
    createSortedRowModel,
    tableFeatures,
    rowSortingFeature,
    type ColumnDef,
  } from '@svgrid/grid/core'

  type Repo = { name: string; lang: string; stars: number }

  const data: Repo[] = [
    { name: 'svelte',   lang: 'JavaScript', stars: 78000 },
    { name: 'vite',     lang: 'TypeScript', stars: 68000 },
    { name: 'sv-grid',  lang: 'TypeScript', stars: 172 },
    { name: 'rollup',   lang: 'JavaScript', stars: 25000 },
    { name: 'esbuild',  lang: 'Go',         stars: 38000 },
  ]

  const features = tableFeatures({ rowSortingFeature })
  const columns: ColumnDef<typeof features, Repo>[] = [
    { field: 'name',  header: 'Repo' },
    { field: 'stars', header: 'Stars' },
  ]

  let sorting = $state([{ id: 'stars', desc: true }])

  const table = createSvGrid({
    _features: features,
    _rowModels: {
      coreRowModel: createCoreRowModel<Repo>(),
      sortedRowModel: createSortedRowModel<Repo>(),
    },
    data,
    columns,
    state: { sorting },
    onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u),
  })

  // Touch the state this component owns so the derived re-runs:
  // the engine's store is framework-free and not a rune.
  const rows = $derived.by(() => {
    sorting
    return table.getRowModel().rows
  })
</script>

<button type="button" onclick={() => (sorting = [{ id: 'name', desc: false }])}>
  Sort by name from outside
</button>

<p>State: <code>{JSON.stringify(sorting)}</code></p>

<ol>
  {#each rows as r (r.id)}
    {@const repo = r.original as Repo}
    <li>{repo.name} - {repo.stars.toLocaleString()}</li>
  {/each}
</ol>

The updater can be a function

An on*Change hands you either the next value or a function of the previous one, the way setState does. Handling only the value silently drops the toggle path, and a header click stops working.

<script lang="ts">
  import {
    createSvGrid,
    createCoreRowModel,
    createSortedRowModel,
    tableFeatures,
    rowSortingFeature,
    type ColumnDef,
  } from '@svgrid/grid/core'

  type Repo = { name: string; lang: string; stars: number }

  const data: Repo[] = [
    { name: 'svelte',   lang: 'JavaScript', stars: 78000 },
    { name: 'vite',     lang: 'TypeScript', stars: 68000 },
    { name: 'sv-grid',  lang: 'TypeScript', stars: 172 },
    { name: 'rollup',   lang: 'JavaScript', stars: 25000 },
    { name: 'esbuild',  lang: 'Go',         stars: 38000 },
  ]

  const features = tableFeatures({ rowSortingFeature })
  const columns: ColumnDef<typeof features, Repo>[] = [
    { field: 'name',  header: 'Repo' },
    { field: 'stars', header: 'Stars' },
  ]

  let sorting = $state<Array<{ id: string; desc: boolean }>>([])
  let updates = $state(0)

  const table = createSvGrid({
    _features: features,
    _rowModels: {
      coreRowModel: createCoreRowModel<Repo>(),
      sortedRowModel: createSortedRowModel<Repo>(),
    },
    data,
    columns,
    state: { sorting },
    onSortingChange: (u) => {
      // Both shapes. Dropping the function branch is the classic bug here.
      sorting = typeof u === 'function' ? u(sorting) : u
      updates += 1
    },
  })

  // Touch the state this component owns so the derived re-runs:
  // the engine's store is framework-free and not a rune.
  const rows = $derived.by(() => {
    sorting
    updates
    return table.getRowModel().rows
  })
</script>

<table>
  <thead>
    {#each table.getHeaderGroups() as hg (hg.id)}
      <tr>
        {#each hg.headers as h (h.id)}
          <th onclick={h.column.getToggleSortingHandler()}>{h.column.columnDef.header}</th>
        {/each}
      </tr>
    {/each}
  </thead>
  <tbody>
    {#each rows as r (r.id)}
      {@const repo = r.original as Repo}
      <tr><td>{repo.name}</td><td>{repo.stars.toLocaleString()}</td></tr>
    {/each}
  </tbody>
</table>

<p>{updates} sort update(s)</p>

See also

Related articles

  • Controlled vs Uncontrolled Grid State - Who owns sort, filter, and page state - you or the grid? The answer shapes your whole integration. Here is how to think about it and when each mode pays off.
  • $state Deep Dive for Data-Heavy Svelte Apps - A working engineer's guide to $state, $state.raw, and $state.snapshot - when each one is the right tool, and the exact failure modes when you pick the wrong one.
  • Runes vs Stores in Svelte 5 - When to Use Which - Svelte 5 runes and stores are not competitors - they solve different problems. Here is a concrete breakdown of when $state wins, when writable still earns its place, and how to mix both without subtle bugs.