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)
    }
  • rows is the tree: root rows only, each carrying its children in rowSubRows. This is the list you render from.
  • flatRows has every row at the top level, sub-rows included. Use it to count or scan without walking the tree.
  • rowsById is the lookup by row id. Table.findRow reads 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.