Migrating from TanStack
This page is for someone who knows TanStack Table and wants to know what changed. It is a list of the differences, with the TanStack form and the Elm form side by side. Nothing here is a criticism of TanStack; the changes are what a JavaScript library looks like once it is written in Elm.
If you are new to TanStack Table, read Overview and Quick Start instead.
The big one: there is no table instance
TanStack's useTable returns a table object that owns the options, owns the
state, memoizes the row models, and hangs every API off itself and off the
column, row, header, and cell objects it creates.
There is no such object here. It is replaced by three values you hold yourself:
| Value | What it is | Where it comes from |
|---|---|---|
Table.Config row |
the column definitions and the table-level options | Table.config, then with* builders |
Table.State |
every state slice | Table.initialState, then transitions |
Table.RowModel row |
the rows the pipeline produced | Table.rows or one stage at a time |
Every function takes what it needs out of those three:
const table = useTable({ features, columns, data })
table.getRowModel().rows
table.getColumn('age').toggleSorting()
table.setPageIndex(2)
Table.rowsFromList config state people
Table.toggleSort config rowModel "age" { desc = Nothing, multi = False } state
Table.setPage config 2 state
Two consequences run through everything below. Every state transition is a pure
... -> State -> State, so you apply it in update and store the result. And
because the Config is a plain value, two tables never share anything by
accident.
See Config and State and Table State.
Renames
| TanStack | elm-table | Note |
|---|---|---|
sortingFn (column option) |
withSortFn / withCustomSort |
a built-in SortFn on the cell value, or your own comparison on the whole row |
filterFn (column option) |
withFilterFn / withCustomFilter |
same split |
aggregationFn (column option) |
withAggregationFn |
|
sortUndefined: 'first' |
Table.sortNullsFirst |
passed to withSortUndefined |
sortUndefined: 'last' |
Table.sortNullsLast |
|
sortUndefined: -1 |
Table.sortNullsAsMinusOne |
|
sortUndefined: 1 |
Table.sortNullsAsPlusOne |
|
expanded: true |
Table.expandAll |
|
expanded: Record<string, boolean> |
Table.expandedIds taking a Set String |
read back with expandedIdsOf |
reSplitAlphaNumeric |
SortFn.splitAlphaNumeric |
|
column.toggleSorting |
Table.toggleSort |
|
table.setPageIndex |
Table.setPage |
|
column.getIsSorted(): false | 'asc' | 'desc' |
Table.getIsSorted: State -> String -> Maybe SortDir |
compare against Table.sortAsc / Table.sortDesc |
Beyond the table, the general pattern is that a method on table, column,
row, header, or cell becomes a top-level function in Table whose first
arguments are the values that method would have read off this. So
row.getIsSelected() is Table.getIsRowSelected state row, and
column.getSize() is Table.columnSize config column.
Semantic differences
No function registries
TanStack looks a sort, filter, or aggregation function up by name in a registry
that lives on the table (sortFns, filterFns, aggregationFns). Here they
are values. You pass the function itself, so there is no name to register and no
name to mistype.
columnHelper.accessor('firstName', { sortingFn: 'text', filterFn: 'includesString' })
columns : List (Table.Column Person)
columns =
[ Table.column "firstName" (.firstName >> Value.String)
|> Table.withHeader "First name"
|> Table.withSortFn SortFn.text
|> Table.withFilterFn FilterFn.includesString
, Table.column "salary" (.salary >> Value.Number)
|> Table.withHeader "Salary"
|> Table.withAggregationFn AggregationFn.sum
]
Table.getSortFn and
Table.getFilterFn hand back the
function itself rather than a name. Run one with SortFn.compare or
FilterFn.filter:
compareAges : Table.RowModel Person -> Value.Value -> Value.Value -> Order
compareAges model =
SortFn.compare (Table.getSortFn config model "age")
getSortFn always returns a SortFn, and getFilterFn returns a
Maybe FilterFn. A column set up with withCustomSort or withCustomFilter
has no cell-level function, so these report the automatic choice for it while
the row model uses your comparison.
One Null for null and undefined
Table.Value has no separate case for "missing" and "explicitly null". Both are
Value.Null.
Places where that shows: unique over ['a', null, undefined, 'a'] gives two
entries rather than three; a Null endpoint in a range filter is always
open-ended, where TanStack treats a null minimum as a real minimum coerced to
0; and Null grouping keys land in one bucket rather than a null bucket and
an undefined bucket.
Value.List is the array cell value
A JavaScript array cell value becomes Value.List (List Value). It exists so
arrIncludes, arrIncludesAll, arrIncludesSome, and arrHas have something
to test, so the extent and unique aggregations have something to return, and
so a [min, max] range filter value has a shape. See Values.
The pipeline is fixed and fully exposed
TanStack composes row models out of the factories you registered:
createFilteredRowModel(), createSortedRowModel() and so on, and calls them
through table.getRowModel().
Here the order is fixed at core, filtered, grouped, sorted, expanded,
paginated. Table.rows runs all six, and each
stage is also a function of its own that takes the previous stage's RowModel.
So you can stop early, keep an intermediate result, or put a stage of your own
in the middle:
stopAfterSorting : Table.State -> Table.RowModel Person
stopAfterSorting state =
Table.coreRowModelFromList config state people
|> Table.filteredRowModel config state
|> Table.groupedRowModel config state
|> Table.sortedRowModel config state
A manual flag on the config still skips a stage, the same as in TanStack. See
Row Models.
resetX goes to the feature's default
TanStack's table.resetSorting() resets to table.initialState, and
table.resetSorting(true) resets to the feature's blank default. There is no
table instance here to remember an initial state, so Table.resetSorting is
always the second form.
To go back to your own starting values, call the matching setX:
backToMyStartSorting : Table.State -> Table.State
backToMyStartSorting state =
Table.setSorting [ { id = "age", desc = True } ] state
Every resetX and what it restores is listed in
Table State.
Nothing is memoized
TanStack memoizes each row model and re-runs a stage only when its inputs
change. Every function here recomputes from its Config, State, and data. A
Table.rows call is the whole pipeline, every time.
The pattern that replaces the memoization is to compute the row model once per
update and store it in your model beside the state, which
Table State shows.
For a sense of the cost: the full pipeline over 10,000 rows with one filter, one
sort, and a page size of 50 runs in about 90 ms.
Custom sort and filter functions take the whole row
TanStack's sortingFn is (rowA, rowB, columnId) => number and its filterFn
is (row, columnId, filterValue, addMeta) => boolean, and both are also where a
built-in like 'text' is named. That splits in two here.
withSortFn and
withFilterFn take a built-in that
works on one column's Value.
withCustomSort takes
Row row -> Row row -> Order and
withCustomFilter takes
Row row -> Value -> Bool, so a custom function can read the original record
and any other column. When a column has both, the custom one wins. The custom
filter receives the raw filter value, matching TanStack, which only applies
resolveFilterValue when the filter function carries one.
Auto-detected functions are real functions, not the string 'auto'
TanStack's 'auto' is a sentinel resolved through the registry.
getAutoSortFn,
getAutoFilterFn, and
getAutoAggregationFn sample the
data and return the same kind of value withSortFn, withFilterFn, and
withAggregationFn take, so you can call it, pass it on, or ignore it.
An 'auto' filter or aggregation function is Nothing
Because the functions are values, "no function set" is simply Nothing on the
column, which is what TanStack spells 'auto'. Leave withFilterFn off a
column and the filtered row model resolves getAutoFilterFn for it. Leave
withAggregationFn off and grouping resolves getAutoAggregationFn, which is
sum for numbers and extent for dates.
Reading it back reflects that: getFilterFn and getAggregationFn return
Maybe, and Nothing means "the automatic one applies".
An Infinity page size is Table.unlimitedPageSize
Elm has no Infinity for Int, so
Table.unlimitedPageSize stands in
where TanStack writes pageSize: Infinity. It is Number.MAX_SAFE_INTEGER.
everythingOnOnePage : Table.State -> Table.State
everythingOnOnePage state =
Table.setPageSize Table.unlimitedPageSize state
A pageCount of Infinity has no counterpart. Config.pageCount is a
Maybe Int, and Just -1 is TanStack's "row count unknown", which clamps
nothing.
No per-row columnFiltersMeta
TanStack writes row.columnFilters and row.columnFiltersMeta onto the rows
during filtering, and a filter function can call addMeta to leave a score
behind for a later sort. That is how the fuzzy filtering recipe works.
The filtered row model here evaluates the resolved filters directly and produces
the same rows, with nothing written onto them. There is no addMeta.
Fuzzy Filtering shows what to do instead.
ColumnRegion instead of an optional position argument
TanStack's pinning-aware APIs take an optional
position?: 'start' | 'center' | 'end', where leaving it out means the whole
list. Elm has no optional arguments, so the position is a required
Table.ColumnRegion with four values:
allColumnsRegion (the absent
argument), leftColumnsRegion, centerColumnsRegion, and
rightColumnsRegion.
leftPinnedColumns : Table.State -> List (Table.Column Person)
leftPinnedColumns state =
Table.pinnedVisibleLeafColumns config state Table.leftColumnsRegion
left and right, not start and end
State.columnPinning has left and right fields, and the API keeps those
names: Table.pinnedLeft, Table.pinnedRight,
Table.isSomeColumnsPinnedLeft, and so on. TanStack's logical start / end
wording survives in one place only, the header ids the header builder produces,
because those id strings are asserted by the ported tests.
The shift-click anchor is your state
TanStack keeps a _lastSelectedRowId on the table and works out from the click
event whether a range was meant.
Table.selectRange takes the anchor row
id as an argument instead, so the anchor is a field in your model.
Table.canSelectRange exposes the
guard, so you can tell a range apart from a plain toggle:
selectTo : Table.RowModel Person -> String -> Table.Row Person -> Table.State -> Table.State
selectTo model anchorRowId row state =
if Table.canSelectRange config state model anchorRowId row then
Table.selectRange config model anchorRowId row True state
else
Table.toggleRowSelected config model row Nothing state
Group ids and Null grouping keys
A group row's id is columnId:groupKey, joined to its parent group's id with
>: department:Design, a:a1>b:b1>c:c1. That is TanStack's
createGroupedRowModel verbatim, including the property that an id containing
: or > can in principle collide.
Value.Null grouping values land under the key "null". TanStack keys a bucket
with `${groupingValue}`, so null gives "null" and undefined gives
"undefined"; with one Null here, both land in the same bucket.
Group rows carry an aggregatedValues entry for every leaf column
TanStack replaces getValue on a group row, and the replacement returns
undefined for a column that is neither an ancestor grouping column nor
aggregatable. Elm rows have no method to replace, and Table.getValue would
otherwise fall through to the accessor on the group row's original, which is a
real leaf's record.
So a group row carries an explicit entry in
rowAggregatedValues for every
leaf column, Value.Null included. Dict.size (Table.rowAggregatedValues row)
is the leaf column count on every group row, and "this column has no aggregate"
is a Null entry rather than a missing key:
hasAggregatedValue : Table.Row Person -> String -> Bool
hasAggregatedValue row columnId =
case Dict.get columnId (Table.rowAggregatedValues row) of
Just Value.Null ->
False
Just _ ->
True
Nothing ->
False
sortUndefined is four functions
sortUndefined: 'first' | 'last' | -1 | 1 becomes four values passed to
withSortUndefined:
nullsLastColumn : Table.Column Person
nullsLastColumn =
Table.column "department" (.department >> Value.String)
|> Table.withSortUndefined Table.sortNullsLast
The same treatment applies to the other string unions TanStack uses in options.
Table.Expanded is expandAll or expandedIds, Table.GroupedColumnMode is
groupedColumnsReorder / groupedColumnsRemove / groupedColumnsIgnore, and
so on: one function per variant, so a typo is a compile error.
What has no counterpart
featuresandtableFeatures(). Elm's compiler drops unused code, so there is no feature registration step and no tree-shaking option. Every feature is available on every table.flexRender,columnHelper, and the rendering options (header,cell,footeras renderers). This package produces data; you write theHtml. A column'swithHeaderandwithFootertake aStringlabel.meta,columnMeta,filterMeta, and the type helpers. Yourrowtype is your own record and yourMsgtype is your own union, so there is nothing to attach loosely typed extras to. See Features.table.atoms,table.store, andtable.Subscribe. See Table State.- Keyed aggregations. TanStack's
aggregationFn: [...]produces an object per cell. AnAggregationFnhere is one fold plus an optional merge.