Table State
TanStack Table describes itself as a state-management coordinator for tables. The same sentence fits this package, with one change: the state is a plain record that lives in your own model, and nothing in the package keeps a copy of it.
Table.State is a record with one field per feature. You create one with
Table.initialState, store it in your
model, hand it to every function that needs it, and replace it when something
happens. There is no table instance, no store, and no subscription.
The state record
type alias State =
{ sorting : List SortColumn
, columnFilters : List ColumnFilter
, globalFilter : Value
, grouping : List String
, expanded : Expanded
, rowSelection : Set String
, pagination : Pagination
, columnOrder : List String
, columnVisibility : Dict String Bool
, columnPinning : ColumnPinning
, columnSizing : Dict String Float
, rowPinning : RowPinning
, cellSelection : List CellSelectionRange
}
Every slice is always present. TanStack v9 only creates a state slice for a
feature you registered in features; here the record is fixed, and a feature
you never touch simply leaves its slice at the value Table.initialState gave
it.
| Slice | Type | initialState |
Guide |
|---|---|---|---|
sorting |
List Table.SortColumn ({ id : String, desc : Bool }) |
[] |
Sorting |
columnFilters |
List Table.ColumnFilter ({ id : String, value : Value }) |
[] |
Column Filtering |
globalFilter |
Table.Value.Value |
Value.Null |
Global Filtering |
grouping |
List String |
[] |
Grouping |
expanded |
Table.Expanded |
Table.expandedIds Set.empty |
Expanding |
rowSelection |
Set String |
Set.empty |
Row Selection |
pagination |
Table.Pagination ({ pageIndex : Int, pageSize : Int }) |
{ pageIndex = 0, pageSize = 10 } |
Pagination |
columnOrder |
List String |
[] |
Column Ordering |
columnVisibility |
Dict String Bool |
Dict.empty |
Column Visibility |
columnPinning |
Table.ColumnPinning ({ left : List String, right : List String }) |
{ left = [], right = [] } |
Column Pinning |
columnSizing |
Dict String Float |
Dict.empty |
Column Sizing |
rowPinning |
Table.RowPinning ({ top : List String, bottom : List String }) |
{ top = [], bottom = [] } |
Row Pinning |
cellSelection |
List Table.CellSelectionRange |
[] |
Cell Selection |
Table.Expanded is the one slice that is not a plain data structure. It is
either Table.expandAll (TanStack's
expanded: true) or
Table.expandedIds wrapping a
Set String. Read it back with
Table.expandedIdsOf, which gives
Nothing for expandAll.
Accessing table state
Read a slice off the record:
model.state.pagination.pageIndex
model.state.sorting
Or use the query function a feature exposes, which applies that feature's rules instead of making you re-derive them:
Table.getIsSorted model.state "age"
Table.getFilterValue model.state "firstName"
Table.getIsGrouped model.state "department"
TanStack's page spends most of its length on how to read state without
re-rendering too much: table.atoms, table.store, the useTable selector,
and table.Subscribe. None of that has a counterpart here. Elm's update loop
already decides when view runs, and a read of model.state is a plain record
access with no subscription attached.
Setting table state
Every transition has the shape ... -> State -> State. It is a pure function
from one state to the next, so it composes with |> and can be tested on its
own:
{ model | state = Table.setPage config 2 model.state }
Prefer the transitions over editing the record by hand. They carry the feature's
own rules: setColumnFilter drops a
blank filter value instead of storing it,
setPage clamps to the page count,
setPageSize moves the page index so the
row at the top of the page stays in view, and
toggleSort respects the multi-sort cap.
Writing the record field yourself skips all of that.
update : Msg -> Model -> Model
update msg model =
case msg of
GoToPage index ->
withState (Table.setPage config index model.state) model
SortBy columnId ->
withState
(Table.toggleSort config
model.rowModel
columnId
{ desc = Nothing, multi = False }
model.state
)
model
BackToStartState ->
withState (Table.setSorting startState.sorting model.state) model
Which row model a transition wants
Three transitions take a Table.RowModel row as well as the state, because
their answer depends on the data:
toggleSort has to sample values to pick
a first sort direction, setColumnFilter
has to find the column's automatic filter function to decide whether the value
auto-removes, and toggleExpanded has
to turn expandAll into concrete row ids before the change lands.
Each one wants a different stage of the pipeline:
| Transition | Stage it wants | Why |
|---|---|---|
setColumnFilter, setColumnFilters |
the core row model | filtering runs after the core stage, so the values it inspects are the unfiltered ones |
toggleSort |
the pre-sort model: core, filtered, grouped | sorting runs on the grouped model, so the first sort direction is sampled from the rows that will actually be sorted |
toggleExpanded |
the sorted model | expansion runs on the sorted model, so that is where the expandable row ids come from |
coreOnly : Table.State -> List Person -> Table.RowModel Person
coreOnly state data =
Table.coreRowModelFromList config state data
preSorted : Table.State -> List Person -> Table.RowModel Person
preSorted state data =
Table.coreRowModelFromList config state data
|> Table.filteredRowModel config state
|> Table.groupedRowModel config state
preExpanded : Table.State -> List Person -> Table.RowModel Person
preExpanded state data =
preSorted state data
|> Table.preExpandedRowModel config state
Passing the full Table.rows result to all
three is common in small tables and is what the
Quick Start example does. It gives the same answer whenever the
later stages do not remove rows these functions read, which stops being true
once pagination cuts the row list down or a manual stage is switched on. Passing
the stage named above is always right.
Storing the row model next to the state
Nothing in this package is memoized. Every call recomputes from its Config,
State, and data, so calling Table.rows twice does the work twice. The usual
answer is to compute the row model once per update and store it in the model
beside the state:
type alias Model =
{ data : List Person
, state : Table.State
, rowModel : Table.RowModel Person
}
One helper keeps the two in step. Every branch of update that changes the
state or the data goes through it, and view only ever reads model.rowModel:
withState : Table.State -> Model -> Model
withState state model =
{ model
| state = state
, rowModel = Table.rowsFromList config state model.data
}
init : Model
init =
{ data = people
, state = startState
, rowModel = Table.rowsFromList config startState people
}
The rule is: the stored row model must be rebuilt whenever the state changes or the data changes. If you forget, the table renders the previous state's rows.
Custom initial state
To start with something other than the defaults, build a state value once and
use it in init.
Elm's record update syntax needs a plain name on the left, so bind
Table.initialState first:
startState : Table.State
startState =
let
base : Table.State
base =
Table.initialState
in
{ base
| sorting = [ { id = "age", desc = True } ]
, pagination = { pageIndex = 0, pageSize = 25 }
}
The same thing written with transitions, which is safer because each one applies its feature's rules:
startStateFromTransitions : Table.State
startStateFromTransitions =
Table.initialState
|> Table.setSorting [ { id = "age", desc = True } ]
|> Table.setPageSize 25
Unlike TanStack's initialState table option, this is just a value. The package
never sees it and never remembers it, which is what the next section is about.
Resetting
resetX puts a slice back to the feature's built-in default, not back to a
starting state you supplied. It is TanStack's resetX(table, true), the
"blank/default state" form. TanStack's plain table.resetSorting() resets to
table.initialState, and there is no table instance here to hold one.
| Function | Puts the slice back to |
|---|---|
resetSorting |
sorting = [] |
resetColumnFilters |
columnFilters = [] |
resetGlobalFilter |
globalFilter = Value.Null |
resetGrouping |
grouping = [] |
resetExpanded |
expanded = Table.expandedIds Set.empty |
resetRowSelection |
rowSelection = Set.empty |
resetPagination |
pagination = { pageIndex = 0, pageSize = 10 } |
resetPageIndex |
pageIndex = 0 |
resetPageSize |
pageSize = 10 |
resetColumnOrder |
columnOrder = [], restoring definition order |
resetColumnVisibility |
columnVisibility = Dict.empty, which shows every column |
resetColumnPinning |
columnPinning = { left = [], right = [] } |
resetColumnSizing |
columnSizing = Dict.empty |
resetColumnSize |
one column's entry removed, the rest left alone |
resetRowPinning |
rowPinning = { top = [], bottom = [] } |
cellSelection has no resetCellSelection; the equivalent is
clearCellSelection, which drops
every range.
To go back to your starting state, call the matching setX with the value you
started from. Keep that value in scope, the way startState above is a
top-level declaration, and restoring one slice is one call:
Table.setSorting startState.sorting model.state
Controlled state
There is no controlled/uncontrolled split, because the state is always yours.
TanStack has to offer initialState, external state plus on[State]Change,
and external atoms, so an app can decide which slices it owns. Here the answer
is fixed: you own all of them.
That removes several TanStack options outright. There is no state option, no
atoms option, no on[State]Change callback, and no onStateChange. The
"updater is a value or a function" question does not arise either, since a
transition is already a function from the old state to the new one and you can
wrap it in any function of your own.
Server-side work, which is TanStack's main reason for controlled state, is a
matter of reading the slice you need out of your model and putting it in the
request, plus a manual flag on the config. See
Client-Side vs Server-Side.
Putting state in a URL or a flag
Because Table.State is a plain record of plain data, a slice can be written to
a query string, a flag, or localStorage without going through the package.
There is no serializer in the package; the encoding is yours to pick.
sortingParam : Table.State -> String
sortingParam state =
state.sorting
|> List.map
(\column ->
if column.desc then
"-" ++ column.id
else
column.id
)
|> String.join ","
sortingFromParam : String -> List Table.SortColumn
sortingFromParam param =
param
|> String.split ","
|> List.filter (\part -> part /= "")
|> List.map
(\part ->
case String.uncons part of
Just ( '-', id ) ->
{ id = id, desc = True }
_ ->
{ id = part, desc = False }
)
Feed the decoded slice back in through the matching setX so the feature's
rules still apply, then build the row model from the result:
stateFromParams : String -> Table.State
stateFromParams sortParam =
Table.setSorting (sortingFromParam sortParam) Table.initialState
expanded is the one slice that needs an accessor rather than a field read,
because Table.Expanded is opaque:
expandedParam : Table.State -> List String
expandedParam state =
Table.expandedIdsOf state.expanded
|> Maybe.map Set.toList
|> Maybe.withDefault []
What TanStack's page covers that this one does not
table.atoms,table.store,table.state, selectors, andtable.Subscribe. These exist so a React component can re-render on some state changes and not others. Elm decides that itself.on[State]Changecallbacks and state updaters. A transition returns the next state; what you do with it is ordinaryupdatecode.- Feature-based state. TanStack only creates a slice for a registered
feature. Every slice is always present here, and a feature you do not use
costs you its
initialStatevalue and nothing more. - State types. TanStack exports
SortingState,PaginationState,TableState<typeof features>and so on for annotating React state. The Elm types are the ones in the table above, all exposed fromTable:SortColumn,ColumnFilter,Pagination,ColumnPinning,RowPinning,Expanded,CellSelectionRange, andStateitself.