Grouping

Grouping

Grouping replaces the rows of one pipeline stage with group rows: one row per distinct value of a grouped column, holding the matching rows as its sub-rows. It is how you turn a flat list of people into one row per department that you can open to see the people inside. This page ports TanStack Table's Grouping (React) Guide.

Grouping runs as the third stage of the row model pipeline, after filtering and before sorting, so group rows are sorted by their aggregated values. The values a group row shows for its other columns come from Aggregation, and the open/closed state of a group row comes from Expanding.

Client-side vs server-side grouping

Client-side grouping needs the whole dataset in the browser, because a group is only correct when every row that belongs in it is present. When your server returns one page at a time, or does the grouping itself, set manualGrouping = True and the grouped stage passes its input straight through. See Client-Side vs Server-Side.

Grouping and column order

Grouping can move columns. Column order is decided in one place, orderColumns, which applies State.columnOrder first, then Config.groupedColumnMode, then column visibility, and finally the pinning split. So a grouped column is moved to the front of the leaf column list after your own explicit order and before pinning partitions it. See Column Ordering.

State

Grouping owns one state slice: the ids of the columns to group by, in the order the groups nest.

-- in Table.State
grouping : List String

-- in Table.initialState
grouping = []

[ "department", "age" ] groups by department first, then by age inside each department.

Config options

Option Type Default Description
enableGrouping Bool True Turns grouping off for the whole table. With it False, getCanGroup is False for every column.
groupedColumnMode GroupedColumnMode groupedColumnsReorder What the leaf column list does with a grouped column.
manualGrouping Bool False Skip the grouped stage. groupedRowModel returns its input unchanged.

Config is a plain record, so you set these with a record update:

config : Table.Config Person
config =
    let
        base : Table.Config Person
        base =
            Table.config columns
                |> Table.withGetRowId (\person _ _ -> person.id)
    in
    { base | groupedColumnMode = Table.groupedColumnsReorder }

GroupedColumnMode is an abstract type with three values, standing in for TanStack's 'reorder' | 'remove' | false:

Value What it does
groupedColumnsReorder Move grouped columns to the front of the leaf column list. This is the default.
groupedColumnsRemove Drop grouped columns from the leaf column list.
groupedColumnsIgnore Leave the list alone. TanStack's false.

Column options

Builder Type Default Description
withEnableGrouping Bool -> Column row -> Column row True Allow or forbid grouping by this column.
withGetGroupingValue (row -> Int -> Value) -> Column row -> Column row none Read the value to group by when it differs from the accessor value. The Int is the row's index, mirroring TanStack's getGroupingValue(originalRow, index, row).
withAggregationFn AggregationFn -> Column row -> Column row none (automatic) The aggregation this column's group rows show. See Aggregation.
withMaxAggregationDepth Int -> Column row -> Column row 0 How far below a group row its aggregation looks for values.

A column can be grouped when the table and the column both allow it and the column has either an accessor or a grouping-value function. A grouping-value function lets you group by something the cell does not show, such as a band rather than an exact number:

ageBand : Table.Column Person
ageBand =
    Table.column "age" (.age >> toFloat >> Value.Number)
        |> Table.withHeader "Age"
        |> Table.withGetGroupingValue
            (\person _ -> Value.String (String.fromInt (person.age // 10 * 10) ++ "s"))

With a grouping-value function the group id and rowGroupingValue carry the band, while getValue on the grouping column still carries the accessor value of the group's first member. That matches TanStack.

Transitions

Function Signature Description
toggleGrouping String -> State -> State Append the column to State.grouping, or drop it and keep the rest in order.
setGrouping List String -> State -> State Replace State.grouping.
resetGrouping State -> State Empty State.grouping.

Like TanStack's column.toggleGrouping, toggleGrouping does not check getCanGroup itself. Check it in your click handler if you build grouping controls for columns that may forbid it:

update : Table.RowModel Person -> Msg -> Table.State -> Table.State
update model msg state =
    case msg of
        GroupToggled columnId ->
            if Table.getCanGroup config columnId then
                Table.toggleGrouping columnId state

            else
                state

        GroupingCleared ->
            Table.resetGrouping state

        ExpandToggled rowId ->
            case Table.findRow model rowId of
                Just row ->
                    Table.toggleExpanded config model row Nothing state

                Nothing ->
                    state

To set several grouping levels at once, pass the whole list:

groupByDepartmentThenAge : Table.State -> Table.State
groupByDepartmentThenAge =
    Table.setGrouping [ "department", "age" ]

Queries

Function Signature Description
getCanGroup Config row -> String -> Bool Can this column be grouped?
getIsGrouped State -> String -> Bool Is this column in State.grouping?
getGroupedIndex State -> String -> Int Its position in State.grouping, or -1.
rowIsGrouped Row row -> Bool Was this row built by the grouped stage?
rowGroupingColumnId Row row -> Maybe String The column a group row groups by.
rowGroupingValue Row row -> Value The value a group row groups by.
rowGroupingValueFor Config row -> Row row -> String -> Value The value any row would group by for one column.
rowLeafRows Row row -> List (Row row) The leaf rows a group row was built from. Empty for an ordinary row.
cellIsGrouped State -> Row row -> String -> Bool Is this the cell of the group row's own grouping column?
cellIsPlaceholder State -> Row row -> String -> Bool Is this the cell of some other grouped column? Those render empty.
preGroupedRowModel Config row -> State -> RowModel row -> RowModel row The model grouping runs on, which is the filtered one.
orderGroupedColumns Config row -> State -> List (Column row) -> List (Column row) Apply Config.groupedColumnMode to a leaf column list yourself.

Cell carries its row and column ids rather than a reference to the row, so cellIsGrouped and cellIsPlaceholder take the row and the column id.

Rendering a grouped body

A group row needs three kinds of cell: its own grouping cell, which shows the grouping value and an expand toggle; the placeholder cells of the other grouped columns, which stay empty; and the ordinary cells, which on a group row read back the aggregated value.

viewCell : Table.State -> Table.Row Person -> Table.Column Person -> Html Msg
viewCell state row column =
    let
        columnId : String
        columnId =
            Table.columnId column
    in
    if Table.cellIsGrouped state row columnId then
        Html.td []
            [ Html.button [ onClick (ExpandToggled (Table.rowId row)) ]
                [ Html.text
                    (if Table.getIsExpanded config state row then
                        "-"

                     else
                        "+"
                    )
                ]
            , Html.text (Value.toString (Table.rowGroupingValue row))
            , Html.text (" (" ++ String.fromInt (List.length (Table.rowLeafRows row)) ++ ")")
            ]

    else if Table.cellIsPlaceholder state row columnId then
        Html.td [] []

    else
        Html.td [] [ Html.text (Value.toString (Table.getValue config row columnId)) ]

The rows themselves come from the pipeline in the usual way:

viewRow : Table.State -> Table.Row Person -> Html Msg
viewRow state row =
    Html.tr []
        (List.map (viewCell state row) (Table.visibleLeafColumns config state))

To run only the stages up to grouping, stop at groupedRowModel:

groupedModel : Table.State -> Table.RowModel Person
groupedModel state =
    Table.preGroupedRowModel config state (Table.coreRowModelFromList config state people)
        |> Table.groupedRowModel config state

Group row ids and values

Three details are worth knowing before you key Html nodes off a group row id or read a group row's datum.

Group ids are built from the column id and the grouping value. A group row's id is "<columnId>:<groupingValue>", and a nested group joins its parent's id with >. Grouping by department and then by age gives ids like department:Engineering and department:Engineering>age:36. An id that itself contains : or > can in principle collide. TanStack has the same property.

A Null grouping value lands under the key "null". Value.Null stands for both JavaScript null and undefined in this port, so the two buckets TanStack would keep apart merge into one here.

A group row's original is its first leaf row's original. The group row has no datum of its own, so rowOriginal hands you a real row's datum. Read group rows through rowGroupingValue, getValue, and rowLeafRows instead.

Not covered

TanStack's page also describes controlled grouping state through the atoms option or state.grouping plus onGroupingChange. There is nothing to port: State.grouping is always yours, held in your own model, so it is already readable anywhere in your application.

column.getToggleGroupingHandler has no counterpart either, because the package produces no event handlers. Write onClick (GroupToggled columnId) yourself.

The automatic resets TanStack performs when the grouped row model recomputes (autoResetPageIndex, autoResetExpanded, autoResetAll) are not ported. Nothing recomputes behind your back here, so reset the page index or the expanded rows in the same update branch that changes the grouping.

Example

Grouping, ported from TanStack's Grouping example.