Row Models
A row model is the result of running your data through one processing stage.
TanStack Table builds them from row model factories you register as slots on
a features object. elm-table has no features object and no slots: the six
stages are fixed, always present, and each one is an ordinary exposed
function you can call yourself.
This page is the port of TanStack's Row Models guide.
What are row models?
Row models transform the data you passed in so the table can filter, group, sort, expand, and paginate it. The rows you end up rendering are not a 1:1 mapping of your original data: they may be a page of a filtered and sorted set, with group rows inserted and collapsed branches removed.
Every stage takes a RowModel row and returns a RowModel row, so the
stages compose in a plain pipeline.
The pipeline
core -> filtered -> grouped -> sorted -> expanded -> paginated
| Stage | Function | What it does | Skipped when |
|---|---|---|---|
| core | coreRowModel, coreRowModelFromList |
One row per datum, sub-rows resolved, ids assigned | never |
| filtered | filteredRowModel |
Drops the rows that fail the column filters and the global filter | Config.manualFiltering = True |
| grouped | groupedRowModel |
Replaces the rows with group rows and computes aggregated values | Config.manualGrouping = True |
| sorted | sortedRowModel |
Sorts every level of the row tree | Config.manualSorting = True |
| expanded | expandedRowModel |
Flattens the expanded branches into the row list | Config.manualExpanding = True |
| paginated | paginatedRowModel |
Keeps only the rows of the current page | Config.manualPagination = True |
All five manual* flags default to False, so the whole pipeline runs
unless you turn a stage off. A skipped stage returns its input unchanged.
See Client-Side vs Server-Side for when
to set them.
There is nothing to configure and nothing to import per feature. Sorting
works because the sorting stage is in the pipeline, not because you
registered createSortedRowModel().
Running the pipeline
Table.rows runs all six stages over an
Array:
wholePipelineFromArray : Table.State -> Array Person -> Table.RowModel Person
wholePipelineFromArray state data =
Table.rows config state data
Table.rowsFromList is the same
thing for a List, which is what most Elm code has on hand:
wholePipeline : Table.State -> Table.RowModel Person
wholePipeline state =
Table.rowsFromList config state people
Both are equivalent to writing the six stages out:
byHand : Table.State -> Table.RowModel Person
byHand state =
Table.coreRowModelFromList config state people
|> Table.filteredRowModel config state
|> Table.groupedRowModel config state
|> Table.sortedRowModel config state
|> Table.expandedRowModel config state
|> Table.paginatedRowModel config state
Running one stage at a time
Because each stage is its own function, you can stop wherever you like. This counts the rows that survive filtering, without grouping, sorting, or paging them:
matchCount : Table.State -> Int
matchCount state =
Table.coreRowModelFromList config state people
|> Table.filteredRowModel config state
|> .rows
|> List.length
A stage always takes the previous stage's RowModel, so the order above is
the only valid order. Handing sortedRowModel a core row model compiles, but
it sorts rows that were never filtered or grouped.
Available row models
TanStack exposes both the stage output (getSortedRowModel()) and the input
to that stage (getPreSortedRowModel()). Here the input to a stage is just
whatever you passed in, so the "pre" accessors are thin aliases that exist to
make the intent readable at the call site.
| TanStack | elm-table | Notes |
|---|---|---|
getRowModel() |
rows, rowsFromList |
All six stages |
getCoreRowModel() |
coreRowModel, coreRowModelFromList |
|
getPreFilteredRowModel() |
none | It is the core row model you already hold |
getFilteredRowModel() |
filteredRowModel |
|
getPreGroupedRowModel() |
preGroupedRowModel |
Another name for filteredRowModel |
getGroupedRowModel() |
groupedRowModel |
|
getPreSortedRowModel() |
none | It is groupedRowModel, as in TanStack |
getSortedRowModel() |
sortedRowModel |
|
getPreExpandedRowModel() |
preExpandedRowModel |
Another name for sortedRowModel |
getExpandedRowModel() |
expandedRowModel |
|
getPrePaginationRowModel() |
prePaginationRowModel |
Another name for expandedRowModel |
getPaginatedRowModel() |
paginatedRowModel |
|
getSelectedRowModel() |
selectedRowModel |
Takes any stage's row model and keeps the selected rows |
Each "pre" function takes the row model one stage further back and applies
the stage in between, because that is what the name means. preGroupedRowModel
is "the row model grouping runs on", so you hand it the core row model and it
gives you the filtered one:
preGrouped : Table.State -> Table.RowModel Person
preGrouped state =
Table.coreRowModelFromList config state people
|> Table.preGroupedRowModel config state
There is no preSortedRowModel. TanStack's getPreSortedRowModel() is the
grouped row model, and here that is groupedRowModel under its own name:
preSorted : Table.State -> Table.RowModel Person
preSorted state =
Table.coreRowModelFromList config state people
|> Table.filteredRowModel config state
|> Table.groupedRowModel config state
TanStack's getFilteredSelectedRowModel() and
getGroupedSelectedRowModel() have no separate counterpart either. Apply
selectedRowModel to whichever
stage output you want the selection of. See
Row Selection.
The order of row model execution
coreRowModel
-> filteredRowModel
-> groupedRowModel
-> sortedRowModel
-> expandedRowModel
-> paginatedRowModel
This is TanStack's order, including the part that surprises people: grouping
runs before sorting, which is why TanStack's getPreSortedRowModel() is the
grouped model and not the filtered one. Rows are filtered, then grouped, then
sorted, then expanded, and paged last.
Row model data structure
RowModel row is a plain record with three views of the same rows:
type alias RowModel row =
{ rows : List (Row row)
, flatRows : List (Row row)
, rowsById : Dict String (Row row)
}
rowsis the tree: root rows only, each carrying its children inrowSubRows. This is the list you render from.flatRowshas every row at the top level, sub-rows included. Use it to count or scan without walking the tree.rowsByIdis the lookup by row id.Table.findRowreads it for you.
The record is not opaque, so model.rows works and so does record update.
The rows inside it are opaque; read them with the functions on
Rows.
Two stages hand back the row map of the stage before them rather than
rebuilding it, matching TanStack: the sorted and paginated models carry the
pre-stage rowsById.
The rows you render
paginatedRowModel.rows is the current page, but it is not always the exact
list to draw. Table.rowsInDisplayOrder
is:
displayRows : Table.State -> List (Table.Row Person)
displayRows state =
Table.rowsInDisplayOrder config state (Table.rowsFromList config state people)
With the default Config.paginateExpandedRows = True it returns
model.rows unchanged. With paginateExpandedRows = False the page counts
only root rows, and the expanded descendants that pagination did not carry
are inserted here. See Expanding.
Row pinning is applied separately, not by rowsInDisplayOrder. Pinned rows
come from topRows and
bottomRows, the rest from
centerRows. See
Row Pinning.
Nothing is memoized
TanStack caches each row model and recomputes it only when its inputs change.
This package has no table instance to hang a cache on, so every call
recomputes from the Config, the State, and your data.
That makes the calling pattern important: compute the row model once per
update and store it in your model, rather than calling Table.rowsFromList
from several places in view.
type alias Model =
{ state : Table.State
, data : List Person
, rowModel : Table.RowModel Person
}
refresh : Model -> Model
refresh model =
{ model | rowModel = Table.rowsFromList config model.state model.data }
Every transition then reads the stored row model and refreshes it once:
update : Msg -> Model -> Model
update msg model =
case msg of
SortBy id ->
refresh
{ model
| state =
Table.toggleSort config
model.rowModel
id
{ desc = Nothing, multi = False }
model.state
}
Table State covers this pattern in full, including which transitions want which stage's row model.
Customize/fork row models
TanStack tells you to copy a row model factory's source and modify it. Here you do not have to fork anything: call the stages yourself and put your own step between two of them.
A stage is just RowModel row -> RowModel row. This one drops inactive
people after filtering and before grouping:
isActive : Table.Row Person -> Bool
isActive row =
(Table.rowOriginal row).active
rowModelOf : List (Table.Row Person) -> Table.RowModel Person
rowModelOf flat =
{ rows = flat
, flatRows = flat
, rowsById = Dict.fromList (List.map (\r -> ( Table.rowId r, r )) flat)
}
withOwnStage : Table.State -> Table.RowModel Person
withOwnStage state =
Table.coreRowModelFromList config state people
|> Table.filteredRowModel config state
|> (\model -> rowModelOf (List.filter isActive model.rows))
|> Table.groupedRowModel config state
|> Table.sortedRowModel config state
|> Table.expandedRowModel config state
|> Table.paginatedRowModel config state
rowModelOf rebuilds all three fields of the record. That is the part to get
right: a step that changes rows must also narrow flatRows and rowsById,
or the later stages and every findRow lookup will disagree with what you
render. The version above is correct for a flat row model; with sub-rows,
build flatRows by walking rowSubRows.
Faceted row models
Faceting answers "what values could the user pick here", so a column's facet
list has to ignore that column's own filter while respecting the others.
Table.facetedRowModel takes the
pre-filtered row model, which is the core row model you handed to
filteredRowModel, and a column id:
departmentOptions : Table.State -> List ( Value.Value, Int )
departmentOptions state =
Table.facetedUniqueValues config
state
(Table.coreRowModelFromList config state people)
"department"
salaryRange : Table.State -> Maybe ( Float, Float )
salaryRange state =
Table.facetedMinMax config
state
(Table.coreRowModelFromList config state people)
"salary"
TanStack's three faceting slots map to three functions:
| TanStack | elm-table |
|---|---|
createFacetedRowModel() |
facetedRowModel |
createFacetedUniqueValues() |
facetedUniqueValues |
createFacetedMinMaxValues() |
facetedMinMax |
Passing globalFacetKey as the
column id excludes the global filter instead of a column filter. Full details
are in Faceting.
Manual stages
Setting a manual* flag makes that stage a pass-through, which is how you
hand the work to a server:
serverSideConfig : Table.Config Person
serverSideConfig =
{ config
| manualFiltering = True
, manualSorting = True
, manualPagination = True
}
The state slices still work the same way. Your update reads
State.sorting and State.pagination, sends a request, and puts the rows
the server returned into the model. Config.pageCount and Config.rowCount
let you report totals the client cannot count. See
Client-Side vs Server-Side.
Not ported
Function registries. TanStack's filterFns, sortFns, and
aggregationFns slots register functions under string names so a column def
can say filterFn: 'fuzzy'. There are no registries here: you pass the
function value itself to Table.withFilterFn, Table.withSortFn, or
Table.withAggregationFn, and getFilterFn / getSortFn hand the function
back. The 'auto' string has a counterpart in
getAutoFilterFn,
getAutoSortFn, and
getAutoAggregationFn, which
sample the data and return a real function.
Row model slots. There is no tableFeatures() call, so nothing to
register and no bundle-size reason to leave a stage out.