Migrating to v7
Hand this to your AI assistant
Every page of these docs is also published as plain Markdown, so a coding assistant can read the guide directly. Copy this prompt into it from your project’s root:
Upgrade this project from @humanspeak/svelte-headless-table 6.x to 7.
1. Read https://table.svelte.page/docs/guides/migrating-to-v7.md in full before
changing anything. Every docs page is also available as Markdown: append
.md to its URL, for example
https://table.svelte.page/docs/plugins/add-pagination.md. The index of all
pages is https://table.svelte.page/llms.txt.
2. If any template still uses <Subscribe>, $headerRows / $rows / $pageRows /
$tableAttrs, or cell.attrs() / cell.props(), convert it to current.* first,
following https://table.svelte.page/docs/guides/moving-to-current.md.
3. Install @humanspeak/svelte-headless-table@^7 and apply every row of the
guide's "Breaking changes at a glance" table. Table data must be an array
or a getter, never a Svelte store. Plugin state is read and written through
.current. Files that create $state must be .svelte or .svelte.ts.
4. Run every grep in the guide's "Checklist" section and fix each hit. Review
every initialFilterValue: it is now applied when the view model is built.
5. Run the project's type check and tests. Report what you changed, and list
anything you could not convert with the file and line.Upgrade this project from @humanspeak/svelte-headless-table 6.x to 7.
1. Read https://table.svelte.page/docs/guides/migrating-to-v7.md in full before
changing anything. Every docs page is also available as Markdown: append
.md to its URL, for example
https://table.svelte.page/docs/plugins/add-pagination.md. The index of all
pages is https://table.svelte.page/llms.txt.
2. If any template still uses <Subscribe>, $headerRows / $rows / $pageRows /
$tableAttrs, or cell.attrs() / cell.props(), convert it to current.* first,
following https://table.svelte.page/docs/guides/moving-to-current.md.
3. Install @humanspeak/svelte-headless-table@^7 and apply every row of the
guide's "Breaking changes at a glance" table. Table data must be an array
or a getter, never a Svelte store. Plugin state is read and written through
.current. Files that create $state must be .svelte or .svelte.ts.
4. Run every grep in the guide's "Checklist" section and fix each hit. Review
every initialFilterValue: it is now applied when the view model is built.
5. Run the project's type check and tests. Report what you changed, and list
anything you could not convert with the file and line.If your assistant cannot fetch URLs, paste the contents of migrating-to-v7.md after the prompt, or give it llms-full.txt, which is the whole documentation in one file.
What v7 is
v7 rebuilds the core on Svelte 5 runes: the view model is a chain of $derived values and the library imports nothing from svelte/store. current.* (vm.current, row.current, cell.current) is now the only way to read the table, and plugin state is plain reactive objects with a current property. Plugins are written against getters instead of stores.
The rewrite is also faster. Median of three 30-iteration runs per version on the same machine (details in scripts/perf-v6-vs-v7.md in the repository):
| Scenario | 6.5.4 | 7.0 | v7 / v6 |
|---|---|---|---|
| 10,000 rows, first paint | 372.75 ms | 215.80 ms | 0.58× |
| 1,000 rows, three sort changes | 123.50 ms | 86.85 ms | 0.70× |
| 1,000 rows with seven plugins, first paint | 61.55 ms | 55.50 ms | 0.90× |
Before you start
- Be on 6.4 or later, with templates on
current.*. 6.4 addedcurrentnext to the stores, so you can move templates first while everything still runs on 6.x. If your templates still use<Subscribe>,$headerRowsorcell.attrs(), follow Moving to current.* first: that part of the migration is the same on both versions. - Svelte
^5.30is the minimum peer version. - Code that runs
createTableor reads plugin state in.tsfiles needs rune support there: rename them to.svelte.tsif they create$state.
Breaking changes at a glance
| Area | 6.x | 7.x |
|---|---|---|
| Data input | createTable(readable(items)), createTable(writable(items)) | createTable(items) or createTable(() => items); a store throws |
| View-model stores | vm.rows, vm.pageRows, vm.headerRows, vm.tableAttrs, … (Readable) | removed; read vm.current.rows, vm.current.pageRows, … |
Subscribe | <Subscribe attrs={cell.attrs()} let:attrs> | removed; read cell.current.attrs |
attrs() / props() | row.attrs(), cell.props() return stores | removed; row.current.attrs, cell.current.props |
| Plugin state reads/writes | $pageIndex, pageIndex.set(2), pageIndex.update(...) | pageIndex.current, pageIndex.current = 2 |
| Set-like plugin state | selectedDataIds / expandedIds / groupByIds stores with helper methods | RecordSet / ArraySet: .current plus add, remove, toggle, clear, … |
| Per-row state | getRowState(row).isSelected is a Writable<boolean> | a Box<boolean>; canExpand is a plain boolean |
createRender events | createRender(C, props).on('click', fn), eventHandlers | removed; pass onclick: fn as a prop |
| Dynamic render values | Readable accepted as a RenderConfig, as createRender props and as snippet args | pass a getter (() => value) |
| Label state | header: (_, { rows }) => derived(rows, ...) | header: (_, { rows }) => () => ...rows()... |
| Removed helpers | createSortKeysStore, createPageStore, getRowState(row).invalidate() | createSortKeys, createPageState; invalidate() is gone (nothing to invalidate) |
| Component plumbing | row.applyHook(), row.injectState(), row.state (and the same on cells and header components) | removed; labels and display-column data functions receive the table state as their second argument |
| Column filters | initialFilterValue applied lazily, when a header’s props were first read | applied when the view model is built |
| Plugin-author contract | deriveRows: (rows: Readable<Row[]>) => Readable<Row[]>, hooks return { props?: Readable, attrs?: Readable } | deriveRows: (rows: () => Row[]) => () => Row[], hooks return { props?: () => Props, attrs?: () => Attrs }; the init argument gains upstream (the rows and columns entering your plugin’s position); transformFlatColumnsFn removed |
Step by step
Data input
A plain array for static data; a getter over rune state for data that changes.
// 6.x
import { readable, writable } from 'svelte/store'
const table = createTable(readable(people))
const data = writable(people)
const table = createTable(data)
data.update((rows) => [...rows, newPerson])
// 7.x
const table = createTable(people)
let data = $state.raw(people)
const table = createTable(() => data)
data = [...data, newPerson]// 6.x
import { readable, writable } from 'svelte/store'
const table = createTable(readable(people))
const data = writable(people)
const table = createTable(data)
data.update((rows) => [...rows, newPerson])
// 7.x
const table = createTable(people)
let data = $state.raw(people)
const table = createTable(() => data)
data = [...data, newPerson]$state.raw is enough (and cheaper than $state) when you replace the array rather than mutating it. If the data comes from a store you do not control, convert it in your own code with Svelte’s fromStore:
import { fromStore } from 'svelte/store'
const rows = fromStore(externalStore)
const table = createTable(() => rows.current)import { fromStore } from 'svelte/store'
const rows = fromStore(externalStore)
const table = createTable(() => rows.current)createTable throws: createTable: pass an array or a getter, e.g. createTable(() => items); Svelte stores are not accepted in v7. The same applies to addPagination's serverItemCount and addVirtualScroll's totalRows / dataOffset.View-model stores
<!-- 6.x -->
<script>
const { headerRows, pageRows, tableAttrs, tableBodyAttrs } = table.createViewModel(columns)
</script>
<table {...$tableAttrs}>
<tbody {...$tableBodyAttrs}>
{#each $pageRows as row (row.id)}
<!-- 7.x -->
<script>
const vm = table.createViewModel(columns)
</script>
<table {...vm.current.tableAttrs}>
<tbody {...vm.current.tableBodyAttrs}>
{#each vm.current.pageRows as row (row.id)}<!-- 6.x -->
<script>
const { headerRows, pageRows, tableAttrs, tableBodyAttrs } = table.createViewModel(columns)
</script>
<table {...$tableAttrs}>
<tbody {...$tableBodyAttrs}>
{#each $pageRows as row (row.id)}
<!-- 7.x -->
<script>
const vm = table.createViewModel(columns)
</script>
<table {...vm.current.tableAttrs}>
<tbody {...vm.current.tableBodyAttrs}>
{#each vm.current.pageRows as row (row.id)}vm.flatColumns and vm.pluginStates stay plain properties. vm.current.visibleColumns replaces $visibleColumns, and vm.current.originalRows replaces $originalRows.
Subscribe, attrs() and props()
<!-- 6.x -->
{#each headerRow.cells as cell (cell.id)}
<Subscribe attrs={cell.attrs()} let:attrs props={cell.props()} let:props>
<th {...attrs} onclick={props.sort.toggle}>
<Render of={cell.render()} />
</th>
</Subscribe>
{/each}
<!-- 7.x -->
{#each headerRow.cells as cell (cell.id)}
<th {...cell.current.attrs} onclick={cell.current.props.sort.toggle}>
<Render of={cell.render()} />
</th>
{/each}<!-- 6.x -->
{#each headerRow.cells as cell (cell.id)}
<Subscribe attrs={cell.attrs()} let:attrs props={cell.props()} let:props>
<th {...attrs} onclick={props.sort.toggle}>
<Render of={cell.render()} />
</th>
</Subscribe>
{/each}
<!-- 7.x -->
{#each headerRow.cells as cell (cell.id)}
<th {...cell.current.attrs} onclick={cell.current.props.sort.toggle}>
<Render of={cell.render()} />
</th>
{/each}If you already moved to current.* on 6.4+, the only change is deleting the Subscribe import.
Plugin state
Every plugin state value is a Box (current is readable and writable), a ReadonlyBox (current is readable) or a set class. Replace $x with x.current and .set(v) / .update(fn) with an assignment. This is the pagination demo from these docs:
<!-- 6.x -->
<script>
const { pageIndex, pageCount, pageSize, hasNextPage, hasPreviousPage } = vm.pluginStates.page
</script>
<button onclick={() => $pageIndex--} disabled={!$hasPreviousPage}>Previous page</button>
{$pageIndex + 1} out of {$pageCount}
<button onclick={() => $pageIndex++} disabled={!$hasNextPage}>Next page</button>
<input type="number" min={1} bind:value={$pageSize} />
<!-- 7.x -->
<script>
const { pageIndex, pageCount, pageSize, hasNextPage, hasPreviousPage } = vm.pluginStates.page
</script>
<button onclick={() => (pageIndex.current -= 1)} disabled={!hasPreviousPage.current}>
Previous page
</button>
{pageIndex.current + 1} out of {pageCount.current}
<button onclick={() => (pageIndex.current += 1)} disabled={!hasNextPage.current}>Next page</button>
<input type="number" min={1} bind:value={pageSize.current} /><!-- 6.x -->
<script>
const { pageIndex, pageCount, pageSize, hasNextPage, hasPreviousPage } = vm.pluginStates.page
</script>
<button onclick={() => $pageIndex--} disabled={!$hasPreviousPage}>Previous page</button>
{$pageIndex + 1} out of {$pageCount}
<button onclick={() => $pageIndex++} disabled={!$hasNextPage}>Next page</button>
<input type="number" min={1} bind:value={$pageSize} />
<!-- 7.x -->
<script>
const { pageIndex, pageCount, pageSize, hasNextPage, hasPreviousPage } = vm.pluginStates.page
</script>
<button onclick={() => (pageIndex.current -= 1)} disabled={!hasPreviousPage.current}>
Previous page
</button>
{pageIndex.current + 1} out of {pageCount.current}
<button onclick={() => (pageIndex.current += 1)} disabled={!hasNextPage.current}>Next page</button>
<input type="number" min={1} bind:value={pageSize.current} />bind:value works directly on a box’s current. A few boxes do extra work on read or write: pageSize clamps writes below 1, and pageIndex clamps reads to the last page (6.x wrote the clamped value back into the store).
Values are replaced, never mutated in place. Assign a new array or object:
// 6.x
hiddenColumnIds.update((ids) => [...ids, 'email'])
filterValues.update((values) => ({ ...values, status: 'single' }))
// 7.x
hiddenColumnIds.current = [...hiddenColumnIds.current, 'email']
filterValues.current = { ...filterValues.current, status: 'single' }// 6.x
hiddenColumnIds.update((ids) => [...ids, 'email'])
filterValues.update((values) => ({ ...values, status: 'single' }))
// 7.x
hiddenColumnIds.current = [...hiddenColumnIds.current, 'email']
filterValues.current = { ...filterValues.current, status: 'single' }Box, ReadonlyBox, RecordSet and ArraySet are exported from the package root, together with the box, derivedBox and keyedBox helpers used to build them.
Set-like state (RecordSet, ArraySet)
addSelectedRows’ selectedDataIds and addExpandedRows’ expandedIds are RecordSets; addGroupBy’s groupByIds is an ArraySet. The helper methods keep their names; the value moves to current.
// 6.x
$selectedDataIds // { '1': true }
selectedDataIds.add('2')
selectedDataIds.clear()
// 7.x
selectedDataIds.current // { '1': true }
selectedDataIds.add('2')
selectedDataIds.clear()
selectedDataIds.has('2') // new: a membership check// 6.x
$selectedDataIds // { '1': true }
selectedDataIds.add('2')
selectedDataIds.clear()
// 7.x
selectedDataIds.current // { '1': true }
selectedDataIds.add('2')
selectedDataIds.clear()
selectedDataIds.has('2') // new: a membership checkPer-row state and indicator components
getRowState(row) returns boxes. Pass them as props to the indicator component, and read and write current there, with checked + onchange instead of bind:checked={$isSelected}:
<!-- SelectIndicator.svelte, 6.x -->
<script lang="ts">
import type { Readable, Writable } from 'svelte/store'
export let isSelected: Writable<boolean>
export let isSomeSubRowsSelected: Readable<boolean>
</script>
<input type="checkbox" bind:checked={$isSelected} indeterminate={$isSomeSubRowsSelected} />
<!-- SelectIndicator.svelte, 7.x -->
<script lang="ts">
import type { Box, ReadonlyBox } from '@humanspeak/svelte-headless-table'
const { isSelected, isSomeSubRowsSelected }: {
isSelected: Box<boolean>
isSomeSubRowsSelected: ReadonlyBox<boolean>
} = $props()
</script>
<input
type="checkbox"
checked={isSelected.current}
indeterminate={isSomeSubRowsSelected.current}
onchange={(e) => (isSelected.current = e.currentTarget.checked)}
/><!-- SelectIndicator.svelte, 6.x -->
<script lang="ts">
import type { Readable, Writable } from 'svelte/store'
export let isSelected: Writable<boolean>
export let isSomeSubRowsSelected: Readable<boolean>
</script>
<input type="checkbox" bind:checked={$isSelected} indeterminate={$isSomeSubRowsSelected} />
<!-- SelectIndicator.svelte, 7.x -->
<script lang="ts">
import type { Box, ReadonlyBox } from '@humanspeak/svelte-headless-table'
const { isSelected, isSomeSubRowsSelected }: {
isSelected: Box<boolean>
isSomeSubRowsSelected: ReadonlyBox<boolean>
} = $props()
</script>
<input
type="checkbox"
checked={isSelected.current}
indeterminate={isSomeSubRowsSelected.current}
onchange={(e) => (isSelected.current = e.currentTarget.checked)}
/>The column definition does not change: createRender(SelectIndicator, { isSelected, isSomeSubRowsSelected }). For addExpandedRows, isExpanded is a Box<boolean>, isAllSubRowsExpanded a ReadonlyBox<boolean>, and canExpand is now a plain boolean (read it without $).
Filter components rendered by addColumnFilters get the same treatment: filterValue is a Box, and values, preFilteredValues and preFilteredRows are ReadonlyBoxes.
Column filter initial values
initialFilterValue lazily, the first time that header's props were read. v7 applies it when the view model is built. An initial value on a column whose header props you never rendered used to have no effect; now it filters. In particular initialFilterValue: '' on a matchFilter column now filters every row out. Use undefined (or leave the option out) for "no filter".createRender events and dynamic props
// 6.x
const noticeProps = writable({ count: 0 })
createRender(Notice, noticeProps).on('click', () => $noticeProps.count++)
// 7.x
let count = $state(0)
createRender(Notice, () => ({ count, onclick: () => count++ }))// 6.x
const noticeProps = writable({ count: 0 })
createRender(Notice, noticeProps).on('click', () => $noticeProps.count++)
// 7.x
let count = $state(0)
createRender(Notice, () => ({ count, onclick: () => count++ }))createRender(Component, props) takes a props object or a getter returning one. .on() and eventHandlers are gone: handlers are ordinary on<event> props. createSnippetRender(snippet, args) likewise takes a value or a getter (a function argument is always called as a getter; wrap a function value as () => fn).
Dynamic labels
A label receives TableState, whose members are getters in v7. Return a getter for dynamic text, or pass a props getter to createRender:
// 6.x
header: (_, { rows }) => derived(rows, (r) => `Name (${r.length} users)`)
header: (_, { rows }) => createRender(Italic, derived(rows, (r) => ({ text: `${r.length}` })))
// 7.x
header: (_, { rows }) => () => `Name (${rows().length} users)`
header: (_, { rows }) => createRender(Italic, () => ({ text: `${rows().length}` }))// 6.x
header: (_, { rows }) => derived(rows, (r) => `Name (${r.length} users)`)
header: (_, { rows }) => createRender(Italic, derived(rows, (r) => ({ text: `${r.length}` })))
// 7.x
header: (_, { rows }) => () => `Name (${rows().length} users)`
header: (_, { rows }) => createRender(Italic, () => ({ text: `${rows().length}` }))Removed helpers
createSortKeysStore(keys)→createSortKeys(keys). The result is aSortKeysbox withtoggleId(id, options)andclearId(id).createPageStore(config)→createPageState(config), whereconfig.itemsis a getter.getRowState(row).invalidate()onaddExpandedRowsandaddSelectedRows→ delete the call. Per-row views read the plugin’s set directly, are cached per row object, and never go stale.applyHook(),injectState()andstateon rows, cells and header components → removed; they were view-model plumbing. Read the table state from the second argument of a label or a display column’sdatafunction instead ofrow.state/cell.state.
Plugin-author contract
The derive functions take a getter for the upstream value and return a getter; hooks return getters for props and attrs; tableState members are getters. transformFlatColumnsFn was removed: use deriveFlatColumns.
// 6.x
const deriveRows: DeriveRowsFn<Item> = (rows) =>
derived([rows, sortKeys], ([$rows, $sortKeys]) => sort($rows, $sortKeys))
hooks: {
'thead.tr.th': (cell) => ({ props: derived(sortKeys, ($keys) => ({ order: orderOf($keys, cell.id) })) })
}
// 7.x
const deriveRows: DeriveRowsFn<Item> = (rows) => {
const sorted = $derived.by(() => sort(rows(), sortKeys.current))
return () => sorted
}
hooks: {
'thead.tr.th': (cell) => ({ props: () => ({ order: orderOf(sortKeys.current, cell.id) }) })
}// 6.x
const deriveRows: DeriveRowsFn<Item> = (rows) =>
derived([rows, sortKeys], ([$rows, $sortKeys]) => sort($rows, $sortKeys))
hooks: {
'thead.tr.th': (cell) => ({ props: derived(sortKeys, ($keys) => ({ order: orderOf($keys, cell.id) })) })
}
// 7.x
const deriveRows: DeriveRowsFn<Item> = (rows) => {
const sorted = $derived.by(() => sort(rows(), sortKeys.current))
return () => sorted
}
hooks: {
'thead.tr.th': (cell) => ({ props: () => ({ order: orderOf(sortKeys.current, cell.id) }) })
}Writing a v7 plugin
A complete plugin: it hides rows whose value in a chosen column is below a minimum, and gives each header cell an active flag and an activate() handler. Plugins that use runes live in .svelte.ts files.
// addMinValue.svelte.ts
import { box, type BodyRow, type Box, type ReadonlyBox } from '@humanspeak/svelte-headless-table'
import type {
DeriveRowsFn,
NewTablePropSet,
TablePlugin
} from '@humanspeak/svelte-headless-table/plugins'
export interface MinValueState<Item> {
/** The column being filtered, if any. */
columnId: Box<string | undefined>
/** Rows whose value in `columnId` is below `min` are hidden. */
min: Box<number>
/** The rows before this plugin filtered them. */
preFilteredRows: ReadonlyBox<BodyRow<Item>[]>
}
export type MinValuePropSet = NewTablePropSet<{
'thead.tr.th': { active: boolean; activate: () => void }
}>
export const addMinValue =
<Item>({ initialMin = 0 }: { initialMin?: number } = {}): TablePlugin<
Item,
MinValueState<Item>,
Record<string, never>,
MinValuePropSet
> =>
({ upstream }) => {
const columnId = box<string | undefined>(undefined)
const min = box(initialMin)
// Expose the pre-transform rows by reading `upstream`, the rows the
// view model feeds this plugin, not by copying them into state.
const preFilteredRows: ReadonlyBox<BodyRow<Item>[]> = {
get current() {
return upstream.rows()
}
}
const deriveRows: DeriveRowsFn<Item> = (rows) => {
// Read state while deriving; never write it here.
const filtered = $derived.by(() => {
const id = columnId.current
if (id === undefined) return rows()
return rows().filter((row) => {
const cell = row.cellForId[id]
return !cell?.isData() || Number(cell.value) >= min.current
})
})
return () => filtered
}
return {
pluginState: { columnId, min, preFilteredRows },
deriveRows,
hooks: {
'thead.tr.th': (cell) => {
// Allocate the handler once per cell, not on every read.
const activate = () => {
columnId.current = cell.id
}
return {
props: () => ({ active: columnId.current === cell.id, activate })
}
}
}
}
}// addMinValue.svelte.ts
import { box, type BodyRow, type Box, type ReadonlyBox } from '@humanspeak/svelte-headless-table'
import type {
DeriveRowsFn,
NewTablePropSet,
TablePlugin
} from '@humanspeak/svelte-headless-table/plugins'
export interface MinValueState<Item> {
/** The column being filtered, if any. */
columnId: Box<string | undefined>
/** Rows whose value in `columnId` is below `min` are hidden. */
min: Box<number>
/** The rows before this plugin filtered them. */
preFilteredRows: ReadonlyBox<BodyRow<Item>[]>
}
export type MinValuePropSet = NewTablePropSet<{
'thead.tr.th': { active: boolean; activate: () => void }
}>
export const addMinValue =
<Item>({ initialMin = 0 }: { initialMin?: number } = {}): TablePlugin<
Item,
MinValueState<Item>,
Record<string, never>,
MinValuePropSet
> =>
({ upstream }) => {
const columnId = box<string | undefined>(undefined)
const min = box(initialMin)
// Expose the pre-transform rows by reading `upstream`, the rows the
// view model feeds this plugin, not by copying them into state.
const preFilteredRows: ReadonlyBox<BodyRow<Item>[]> = {
get current() {
return upstream.rows()
}
}
const deriveRows: DeriveRowsFn<Item> = (rows) => {
// Read state while deriving; never write it here.
const filtered = $derived.by(() => {
const id = columnId.current
if (id === undefined) return rows()
return rows().filter((row) => {
const cell = row.cellForId[id]
return !cell?.isData() || Number(cell.value) >= min.current
})
})
return () => filtered
}
return {
pluginState: { columnId, min, preFilteredRows },
deriveRows,
hooks: {
'thead.tr.th': (cell) => {
// Allocate the handler once per cell, not on every read.
const activate = () => {
columnId.current = cell.id
}
return {
props: () => ({ active: columnId.current === cell.id, activate })
}
}
}
}
}Use it like any other plugin: createTable(() => items, { min: addMinValue({ initialMin: 18 }) }), then cell.current.props.min.active in the header and vm.pluginStates.min.min.current = 21 anywhere.
Three rules keep a plugin correct:
- No state writes during derivation. A
deriveRowsgetter, a$derivedand a hook’sprops/attrsgetter must only read. Writing a$stateor a box there throwsstate_unsafe_mutation. Clamp on read instead of writing a corrected value back (this is whataddPagination’spageIndexdoes), and apply initial values when the plugin is created, not in a hook. - Allocate handlers once per cell. Create callbacks in the hook factory (it runs once per component) and return the same function from the
propsgetter, which runs on every read. - Read pre-transform rows from
upstream. The init argument’supstream.rows,upstream.pageRowsandupstream.flatColumnsare the values entering your plugin’s position in each chain. They are getters that work from the moment the plugin is created, so read them from aReadonlyBox, aspreFilteredRowsdoes above. Copying them into state would be a write during derivation.
Outside components
createViewModel works in a component <script>, at the top level of a .svelte.ts module, in a SvelteKit load function and in a test. Plain reads of vm.current.* and of plugin state return the current value anywhere, including during server rendering:
const table = createTable(people, { sort: addSortBy() })
const vm = table.createViewModel(columns)
vm.pluginStates.sort.sortKeys.current = [{ id: 'age', order: 'desc' }]
const oldest = vm.current.rows[0].original // a plain read, no subscription neededconst table = createTable(people, { sort: addSortBy() })
const vm = table.createViewModel(columns)
vm.pluginStates.sort.sortKeys.current = [{ id: 'age', order: 'desc' }]
const oldest = vm.current.rows[0].original // a plain read, no subscription neededTo observe changes outside a component, read inside an $effect created under $effect.root, and call the returned cleanup when you are done:
const stop = $effect.root(() => {
$effect(() => {
console.log(vm.current.rows.length)
})
})
// ...
stop()const stop = $effect.root(() => {
$effect(() => {
console.log(vm.current.rows.length)
})
})
// ...
stop()Effects only run in a client environment (in Vitest: environment: 'jsdom' with Svelte’s browser build); plain reads work in both.
$effect or $effect.root that is torn down while you still use it: its derivations would stop updating (Svelte warns derived_inert).Checklist
Run these from your project root; each should print nothing when you are done.
grep -rn "svelte/store" src— nothing left imports stores for the table. (AfromStorebridge you added on purpose is fine.)grep -rn "<Subscribe" src— everySubscribeis gone.grep -rnE "\.(attrs|props)\(\)" src— noattrs()/props()calls.grep -rnE "\$(pluginStates|sortKeys|pageIndex|pageSize|filterValue|filterValues|selectedDataIds|isSelected|isExpanded|expandedIds|groupByIds|hiddenColumnIds|columnIdOrder|columnWidths)\b" src— no$-prefixed plugin state.grep -rnE "\$(rows|pageRows|headerRows|tableAttrs|tableBodyAttrs|visibleColumns)\b" src— no view-model stores.grep -rnE "createSortKeysStore|createPageStore|invalidate\(\)|\.on\('" src— no removed helpers.grep -rn "initialFilterValue: ''" src— review each hit on amatchFiltercolumn.