1- # tanstack -table-markojs
1+ # marko -table
22
33> Marko 6 adapter for [ @tanstack/table-core ] ( https://tanstack.com/table ) — SSR, CSR, and virtualized tables with full resumability support.
44
@@ -28,10 +28,9 @@ Marko's `_const` runtime function uses strict reference equality (`!==`) to dete
2828// Marko runtime (simplified)
2929function _const (key , fn ) {
3030 return (scope , value ) => {
31- if (scope[key] !== value) {
32- // ← strict equality
31+ if (scope[key] !== value) { // ← strict equality
3332 scope[key] = value;
34- fn (scope); // only propagates if value changed
33+ fn (scope); // only propagates if value changed
3534 }
3635 };
3736}
@@ -88,10 +87,12 @@ static const columns = [
8887
8988// ── Serializable state in <let> ───────────────────────────────────────────────
9089// These are written to the resume frame. Every value must be JSON-serializable.
90+ // Use `as Type` casts on empty arrays/objects so the Marko language server
91+ // infers the correct type rather than `never[]` or `{}`.
9192<let/tableId = generateTableId() />
92- <let/sorting: SortingState = [] />
93- <let/pagination: PaginationState = { pageIndex: 0, pageSize: 10 } />
94- <let/rowSelection: RowSelectionState = {} />
93+ <let/sorting = ([] as SortingState) />
94+ <let/pagination = ( { pageIndex: 0, pageSize: 10 } as PaginationState) />
95+ <let/rowSelection = ({} as RowSelectionState) />
9596<let/globalFilter = "" />
9697
9798// ── IIFE: all table reads are local to this function ─────────────────────────
@@ -113,7 +114,8 @@ static const columns = [
113114 { sorting, pagination, rowSelection, globalFilter },
114115 (updater) => {
115116 const cur = { sorting, pagination, rowSelection, globalFilter };
116- const nxt = typeof updater === "function" ? updater(cur) : updater;
117+ // Cast nxt as typeof cur so property accesses are typed correctly
118+ const nxt = (typeof updater === "function" ? updater(cur) : updater) as typeof cur;
117119 if (nxt.sorting !== sorting) sorting = nxt.sorting;
118120 if (nxt.pagination !== pagination) pagination = nxt.pagination;
119121 if (nxt.rowSelection !== rowSelection) rowSelection = nxt.rowSelection;
@@ -122,11 +124,10 @@ static const columns = [
122124 );
123125
124126 return {
125- // Pre-map TanStack objects to plain serializable values
126127 tableRows: t.getRowModel().rows.map(row => ({
127- id: row.id, // string ✓
128- isSelected: row.getIsSelected(), // boolean ✓
129- original: { ...row.original }, // plain data ✓
128+ id: row.id,
129+ isSelected: row.getIsSelected(),
130+ original: { ...row.original },
130131 cells: row.getVisibleCells().map(cell => ({
131132 id: cell.id,
132133 colId: cell.column.id,
@@ -152,7 +153,7 @@ static const columns = [
152153 };
153154})() />
154155
155- <effect () { return () => destroyTable(tableId) } />
156+ <script () { return () => destroyTable(tableId) } />
156157
157158<table>
158159 <thead>
@@ -208,11 +209,11 @@ import { syncMarkoTable, generateTableId, destroyTable, flexRender,
208209
209210<let/mounted = false />
210211<let/tableId = generateTableId() />
211- <let/sorting: SortingState = [] />
212- <let/pagination: PaginationState = { pageIndex: 0, pageSize: 10 } />
212+ <let/sorting = ([] as SortingState) />
213+ <let/pagination = ( { pageIndex: 0, pageSize: 10 } as PaginationState) />
213214
214- <effect () { mounted = true } />
215- <effect () { return () => destroyTable(tableId) } />
215+ <script () { mounted = true } />
216+ <script () { return () => destroyTable(tableId) } />
216217
217218<if=!mounted>
218219 <div>Loading...</div>
@@ -221,15 +222,18 @@ import { syncMarkoTable, generateTableId, destroyTable, flexRender,
221222<if=mounted>
222223 // Same IIFE pattern — same-reference issue applies regardless of SSR
223224 <const/view = (() => {
224- const t = syncMarkoTable(tableId, { ... }, { sorting, pagination }, setState);
225+ const t = syncMarkoTable(tableId, { ... }, { sorting, pagination }, (updater) => {
226+ const cur = { sorting, pagination };
227+ const nxt = (typeof updater === "function" ? updater(cur) : updater) as typeof cur;
228+ if (nxt.sorting !== sorting) sorting = nxt.sorting;
229+ if (nxt.pagination !== pagination) pagination = nxt.pagination;
230+ });
225231 return {
226232 tableRows: t.getRowModel().rows.map(row => ({ ... })),
227233 // ... etc
228234 };
229235 })() />
230236
231- // Inside <if=mounted>, Marko doesn't serialize content.
232- // So you CAN close over `view` in handlers (it's a plain object).
233237 <table>
234238 <for|row| of=view.tableRows>
235239 <tr>...</tr>
@@ -252,15 +256,15 @@ import {
252256
253257<let/mounted = false />
254258<let/tableId = generateTableId() />
255- <let/sorting: SortingState = [] />
259+ <let/sorting = ([] as SortingState) />
256260<let/globalFilter = "" />
257261// VirtualRow elements are plain objects — serializable
258- <let/virtualRows: VirtualRow [] = [] />
262+ <let/virtualRows = ( [] as VirtualRow[]) />
259263<let/paddingTop = 0 />
260264<let/paddingBottom = 0 />
261265
262- <effect () { mounted = true } />
263- <effect () { return () => destroyTable(tableId) } />
266+ <script () { mounted = true } />
267+ <script () { return () => destroyTable(tableId) } />
264268
265269<if=mounted>
266270 <const/view = (() => {
@@ -269,7 +273,12 @@ import {
269273 { data: input.data, columns, getCoreRowModel: getCoreRowModel(),
270274 getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel() },
271275 { sorting, globalFilter },
272- setState,
276+ (updater) => {
277+ const cur = { sorting, globalFilter };
278+ const nxt = (typeof updater === "function" ? updater(cur) : updater) as typeof cur;
279+ if (nxt.sorting !== sorting) sorting = nxt.sorting;
280+ if (nxt.globalFilter !== globalFilter) globalFilter = nxt.globalFilter;
281+ },
273282 );
274283
275284 const rows = t.getRowModel().rows;
@@ -294,7 +303,7 @@ import {
294303
295304 // Effect re-runs when view.rowCount changes (after filter/sort)
296305 // measure() forces virtualizer to recalculate and fires onChange synchronously
297- <effect () {
306+ <script () {
298307 syncVirtualizer(tableId, `scroll-${tableId}`, view.rowCount, () => 49,
299308 (vRows, top, bot) => { virtualRows = vRows; paddingTop = top; paddingBottom = bot; });
300309 } />
@@ -329,12 +338,12 @@ import {
329338
330339Creates or retrieves a TanStack Table instance and syncs options with current reactive state.
331340
332- | Parameter | Type | Description |
333- | -------------- | ------------------------- | --------------------------------------------------- |
334- | ` tableId ` | ` string ` | ID from ` generateTableId() ` , stored in ` <let> ` |
335- | ` options ` | ` TableOptions<TData> ` | Any valid TanStack Table options |
336- | ` currentState ` | ` Record<string, unknown> ` | Current values of all state ` <let> ` signals |
337- | ` setState ` | ` (updater) => void ` | Writes TanStack state changes back to Marko signals |
341+ | Parameter | Type | Description |
342+ | ---| ---| ---|
343+ | ` tableId ` | ` string ` | ID from ` generateTableId() ` , stored in ` <let> ` |
344+ | ` options ` | ` TableOptions<TData> ` | Any valid TanStack Table options |
345+ | ` currentState ` | ` Record<string, unknown> ` | Current values of all state ` <let> ` signals |
346+ | ` setState ` | ` (updater) => void ` | Writes TanStack state changes back to Marko signals |
338347
339348Returns: ` Table<TData> ` — the live table instance (same reference every call).
340349
@@ -344,7 +353,7 @@ Returns: `Table<TData>` — the live table instance (same reference every call).
344353
345354### ` generateTableId() `
346355
347- Returns a unique string ID. Store in ` <let/tableId> ` — it's serializable and survives SSR→client.
356+ Returns a unique string ID. Store in ` <let/tableId = generateTableId() / > ` — it's serializable and survives SSR→client.
348357
349358---
350359
@@ -353,18 +362,17 @@ Returns a unique string ID. Store in `<let/tableId>` — it's serializable and s
353362Retrieves a table instance from the module cache by ID. Use in event handlers that can't close over ` t ` (SSR components).
354363
355364``` marko
356- // In SSR handlers, never close over `t` — use getTable instead
357365onClick=() => getTable(tableId)?.firstPage()
358366```
359367
360368---
361369
362370### ` destroyTable(id) `
363371
364- Removes the table instance from the cache. Call in ` <effect > ` cleanup.
372+ Removes the table instance from the cache. Call in ` <script > ` cleanup.
365373
366374``` marko
367- <effect () { return () => destroyTable(tableId) } />
375+ <script () { return () => destroyTable(tableId) } />
368376```
369377
370378---
@@ -383,21 +391,21 @@ ${ flexRender(cell.column.columnDef.cell, cell.getContext()) }
383391
384392Creates or updates a row virtualizer. Requires ` @tanstack/virtual-core ` v3+.
385393
386- | Parameter | Type | Description |
387- | -------------- | ------------------------------------------- | -------------------------------------- |
388- | ` tableId ` | ` string ` | Same ID as ` syncMarkoTable ` |
389- | ` scrollElId ` | ` string ` | ` id ` attribute of the scroll container |
390- | ` count ` | ` number ` | Total filtered row count |
391- | ` estimateSize ` | ` (i: number) => number ` | Estimated row height in pixels |
392- | ` onUpdate ` | ` (rows, paddingTop, paddingBottom) => void ` | Called when virtual items change |
394+ | Parameter | Type | Description |
395+ | ---| ---| ---|
396+ | ` tableId ` | ` string ` | Same ID as ` syncMarkoTable ` |
397+ | ` scrollElId ` | ` string ` | ` id ` attribute of the scroll container |
398+ | ` count ` | ` number ` | Total filtered row count |
399+ | ` estimateSize ` | ` (i: number) => number ` | Estimated row height in pixels |
400+ | ` onUpdate ` | ` (rows, paddingTop, paddingBottom) => void ` | Called when virtual items change |
393401
394- Call inside ` <effect > ` so the scroll container exists in the DOM.
402+ Call inside ` <script > ` so the scroll container exists in the DOM.
395403
396404---
397405
398- ### ` preloadVirtualizer( )`
406+ ### ` destroyVirtualizer(id )`
399407
400- Pre-loads ` @tanstack/virtual-core ` in ESM-only environments where ` require() ` is unavailable .
408+ Removes a virtualizer instance from the cache. Called automatically by ` destroyTable ` .
401409
402410---
403411
@@ -417,7 +425,33 @@ Pre-loads `@tanstack/virtual-core` in ESM-only environments where `require()` is
417425})() />
418426```
419427
420- ### 2. Use ` checkedChange ` not ` onChange ` for controlled checkboxes
428+ ### 2. Use ` as ` casts on typed ` <let> ` initial values
429+
430+ The Marko language server infers ` never[] ` from ` [] ` and ` {} ` without a type hint. Use ` as ` casts so types resolve correctly:
431+
432+ ``` marko
433+ // ❌ Language server infers never[] — downstream type errors
434+ <let/sorting: SortingState = [] />
435+
436+ // ✅ Type inferred correctly from the cast
437+ <let/sorting = ([] as SortingState) />
438+ <let/rowSelection = ({} as RowSelectionState) />
439+ ```
440+
441+ ### 3. Cast ` nxt as typeof cur ` in the setState callback
442+
443+ ` syncMarkoTable ` 's ` setState ` is typed as ` Updater<Record<string, unknown>> ` , so ` updater(cur) ` returns ` Record<string, unknown> ` — every property access is ` unknown ` without the cast:
444+
445+ ``` marko
446+ (updater) => {
447+ const cur = { sorting, pagination };
448+ // ✅ Cast nxt so nxt.sorting has type SortingState, not unknown
449+ const nxt = (typeof updater === "function" ? updater(cur) : updater) as typeof cur;
450+ if (nxt.sorting !== sorting) sorting = nxt.sorting;
451+ }
452+ ```
453+
454+ ### 4. Use ` checkedChange ` not ` onChange ` for controlled checkboxes
421455
422456``` marko
423457// ❌ Preserves old visual state (Marko's uncontrolled mode)
@@ -427,7 +461,7 @@ Pre-loads `@tanstack/virtual-core` in ESM-only environments where `require()` is
427461<input type="checkbox" checked=row.isSelected checkedChange=(v) => {...} />
428462```
429463
430- ### 3 . Pre-map all TanStack objects to plain values before the template
464+ ### 5 . Pre-map all TanStack objects to plain values before the template
431465
432466TanStack ` Row ` , ` Cell ` , ` Header ` , and ` Column ` objects contain functions and cannot be serialized. Extract all needed values inside the IIFE:
433467
@@ -447,7 +481,21 @@ TanStack `Row`, `Cell`, `Header`, and `Column` objects contain functions and can
447481})() />
448482```
449483
450- ### 4. Avoid ` > ` in ` <const> ` expressions
484+ ### 6. Use ` <script> ` not ` <effect> `
485+
486+ ` <effect> ` is deprecated. Use ` <script> ` for all side effects and cleanup:
487+
488+ ``` marko
489+ // ❌ Deprecated
490+ <effect() { mounted = true } />
491+ <effect() { return () => destroyTable(tableId) } />
492+
493+ // ✅ Current
494+ <script() { mounted = true } />
495+ <script() { return () => destroyTable(tableId) } />
496+ ```
497+
498+ ### 7. Avoid ` > ` in ` <const> ` expressions
451499
452500Marko's HTML parser treats ` > ` as a tag-close character:
453501
@@ -457,10 +505,9 @@ Marko's HTML parser treats `>` as a tag-close character:
457505
458506// ✅ Use truthy check or !== instead
459507<const/hasFilters = !!filters.length />
460- <const/hasFilters = filters.length !== 0 />
461508```
462509
463- ### 5 . Event handlers must only close over serializable values
510+ ### 8 . Event handlers must only close over serializable values
464511
465512In SSR components, anything captured in a handler closure is serialized. Only close over ` string ` , ` number ` , ` boolean ` , or ` <let> ` signals:
466513
@@ -480,17 +527,17 @@ onClick=() => {
480527
481528## SSR + Resume: what actually gets serialized
482529
483- | In resume frame | Not in resume frame |
484- | --------------------------------------------- | --------------------------------------------- |
485- | ` tableId ` (string) | Table instance (functions, class prototype) |
486- | ` sorting ` (array of plain objects) | Row objects |
487- | ` pagination ` (plain object) | Header/Cell/Column objects |
488- | ` rowSelection ` (plain string/boolean map) | ` flexRender ` output (recomputed from signals) |
489- | ` globalFilter ` (string) | |
490- | ` columnFilters ` (array of plain objects) | |
491- | ` columnVisibility ` (plain string/boolean map) | |
492- | ` columnSizing ` (plain string/number map) | |
493- | ` expanded ` (plain string/boolean map) | |
530+ | In resume frame | Not in resume frame |
531+ | ---| ---|
532+ | ` tableId ` (string) | Table instance (functions, class prototype) |
533+ | ` sorting ` (array of plain objects) | Row objects |
534+ | ` pagination ` (plain object) | Header/Cell/Column objects |
535+ | ` rowSelection ` (plain string/boolean map) | ` flexRender ` output (recomputed from signals) |
536+ | ` globalFilter ` (string) | |
537+ | ` columnFilters ` (array of plain objects) | |
538+ | ` columnVisibility ` (plain string/boolean map) | |
539+ | ` columnSizing ` (plain string/number map) | |
540+ | ` expanded ` (plain string/boolean map) | |
494541
495542On the server: table rows are fully rendered to HTML. On the client: Marko restores the signals from the resume frame, the first interaction triggers the IIFE, ` syncMarkoTable ` recreates the table instance with the correct state, and the reactive cycle proceeds.
496543
@@ -500,4 +547,4 @@ On the server: table rows are fully rendered to HTML. On the client: Marko resto
500547
501548### 0.1.0
502549
503- Initial release.
550+ Initial release.
0 commit comments