Faceting

Faceting works out what a filter UI should offer: which values a column still has, how often each occurs, and what its numeric range is. It computes those from the rows the table's other filters have already narrowed, so the choices in one filter control stay live while the user edits it. This page is the port of TanStack Table's Faceting guide.

Faceting never filters the table. It only produces values you can render. Column Filtering owns the filter state and decides which rows match.

What is faceting?

For one column, faceting answers:

  • Which values are available?
  • How often does each occur?
  • What are the smallest and largest values?
  • Which rows should a facet calculation of your own run over?

That is what turns a plain checkbox list into one with counts:

Department
☐ Engineering   3
☐ Design        2
☐ Sales         1

The names and the counts come from the faceted row model. Filter another column to active = True and the counts change to describe only those rows.

Faceting and aggregation are different

Both summarise rows, for different readers. Faceting produces metadata for a filter control. Aggregation computes a value to display, such as a sum in a footer or on a group row. Faceted counts never create group rows and never use a column's aggregation function.

  • Filtering answers: which rows remain?
  • Faceting answers: which filtering choices remain?
  • Aggregation answers: what summary can be computed from these rows?

How a facet responds to filters

A column's faceted row model applies every active filter except that column's own. That is what lets a facet keep showing the alternatives the user could switch to instead of collapsing to the one they already picked.

With a Department filter and a Tags filter:

  1. The user picks Department = Engineering.
  2. The Tags facet applies that filter and recounts its tags.
  3. The user picks Tags = compilers.
  4. The table shows Engineering rows tagged compilers.
  5. The Tags facet still counts across all Engineering rows, because it leaves out its own filter.

The Department facet, meanwhile, now counts only rows tagged compilers. That is how two facets narrow each other.

Passing globalFacetKey as the column id leaves out the global filter instead of a column filter, and gathers values across every globally filterable column.

State

Faceting has no state slice of its own. It reads State.columnFilters and State.globalFilter to decide which filters to apply, and nothing else.

-- read, never written, by the faceting functions
-- State.columnFilters : List ColumnFilter
-- State.globalFilter : Value

Config options

None are specific to faceting. Two affect it indirectly:

Option Type Default Description
enableFilters Bool True False leaves nothing for a facet to leave out, so every facet describes the whole table.
manualFiltering Bool False Faceting still applies filters itself, so it keeps working, but its counts will not match a server-filtered table. Compute facets on the server instead.

Column options

Builder Type Default Description
withGetUniqueValues (row -> List Value) -> Column row -> Column row Nothing The values one row contributes to this column's facet. Without it a row contributes its single cell value.

withGetUniqueValues is what a list-valued column needs: without it, a row whose cell is Value.List [ Value.String "navy", Value.String "compilers" ] contributes that whole list as one facet value, which is not what a tag filter wants.

columns : List (Table.Column Person)
columns =
    [ Table.column "department" (.department >> Value.String)
        |> Table.withHeader "Department"
        |> Table.withFilterFn FilterFn.equalsString
    , Table.column "salary" (.salary >> Value.Number)
        |> Table.withHeader "Salary"
        |> Table.withFilterFn FilterFn.inNumberRange
    , Table.column "tags" (.tags >> List.map Value.String >> Value.List)
        |> Table.withHeader "Tags"
        |> Table.withGetUniqueValues (.tags >> List.map Value.String)
        |> Table.withFilterFn FilterFn.arrIncludesSome
    ]

Because a row can contribute several values, the counts are occurrence counts rather than row counts, and their total can be larger than the number of rows. Return each value at most once per row if you want the counts to read as rows. getUniqueValues reports what one row contributes for one column.

Transitions

Faceting has none. It is read-only: nothing you call here changes the State. A facet control writes through the ordinary filter transitions, setColumnFilter and setGlobalFilter, which are covered on Column Filtering.

Queries

All three take the pre-filtered row model, the same one you hand to Table.filteredRowModel, plus the column id whose own filter should be left out.

Function Result Use it for
facetedRowModel RowModel row: the rows that pass every other active filter A facet calculation of your own
facetedUniqueValues List ( Value, Int ): each distinct value with its count, in first-seen order Checkboxes, select menus, autocomplete
facetedMinMax Maybe ( Float, Float ): the numeric range, Nothing when no cell coerces to a number Number inputs and range sliders
globalFacetKey String, the id "__global__" Pass it as the column id to facet across every globally filterable column

facetedUniqueValues and facetedMinMax each build the faceted row model themselves and then walk its flatRows, so every row of the tree contributes, sub-rows included.

prefilteredRows : Table.State -> Table.RowModel Person
prefilteredRows state =
    Table.coreRowModelFromList config state people
departmentOptions : Table.State -> List ( Value.Value, Int )
departmentOptions state =
    Table.facetedUniqueValues config state (prefilteredRows state) "department"
        |> List.sortBy (Tuple.first >> Value.toString)

The counts come back as an association list rather than TanStack's Map, because Value is not comparable in Elm and cannot be a Dict key. Sort it yourself if you want the options in an order.

viewDepartmentFacet : Table.State -> List (Html Msg)
viewDepartmentFacet state =
    departmentOptions state
        |> List.map
            (\( facetValue, count ) ->
                let
                    name : String
                    name =
                        Value.toString facetValue
                in
                label []
                    [ input
                        [ type_ "checkbox"
                        , checked (Table.getFilterValue state "department" == Just facetValue)
                        , onClick (DepartmentPicked name)
                        ]
                        []
                    , text (name ++ " (" ++ String.fromInt count ++ ")")
                    ]
            )

For a range control, facetedMinMax gives the bounds:

salaryRange : Table.State -> ( Float, Float )
salaryRange state =
    Table.facetedMinMax config state (prefilteredRows state) "salary"
        |> Maybe.withDefault ( 0, 1 )

And for autocomplete on a search box, the same functions take Table.globalFacetKey instead of a column id:

searchSuggestions : Table.State -> List String
searchSuggestions state =
    Table.facetedUniqueValues config state (prefilteredRows state) Table.globalFacetKey
        |> List.map (Tuple.first >> Value.toString)
        |> List.take 10

When you need the rows rather than the summary, ask for the row model itself:

departmentFacetRows : Table.State -> List (Table.Row Person)
departmentFacetRows state =
    let
        model : Table.RowModel Person
        model =
            Table.facetedRowModel config state (prefilteredRows state) "department"
    in
    model.flatRows

Bucketed faceting

Dates, prices, and file sizes produce too many distinct values to list. Use withGetUniqueValues to report a bucket key while the accessor keeps the raw value for sorting and display, and give the column a filter function that buckets the cell value the same way with Table.FilterFn.withResolveDataValue.

salaryBucket : Float -> String
salaryBucket salary =
    if salary < 90000 then
        "under 90k"

    else if salary < 110000 then
        "90k to 110k"

    else
        "110k and up"
salaryBucketFilter : FilterFn.FilterFn
salaryBucketFilter =
    FilterFn.custom
        (\dataValue filterValue ->
            case filterValue of
                Value.List selected ->
                    List.member dataValue selected

                _ ->
                    False
        )
        |> FilterFn.withResolveDataValue
            (Value.toNumber >> salaryBucket >> Value.String)
        |> FilterFn.withAutoRemove
            (\filterValue ->
                case filterValue of
                    Value.List selected ->
                        List.isEmpty selected

                    _ ->
                        True
            )
salaryBucketColumn : Table.Column Person
salaryBucketColumn =
    Table.column "salary" (.salary >> Value.Number)
        |> Table.withHeader "Salary"
        |> Table.withGetUniqueValues
            (\person -> [ Value.String (salaryBucket person.salary) ])
        |> Table.withFilterFn salaryBucketFilter

Faceting and filtering have to use the same bucket definition, or the counts will not match the rows a bucket selects. Sharing one salaryBucket function between them is what keeps that true.

Performance

Nothing is memoized in this package, so a faceting call walks the rows every time it is made. Compute a facet once in update and store the result rather than calling facetedUniqueValues inside a List.map over the options. For a column with many distinct values, bucket them, show only the first handful, or let the user search the list before you render it.

What this page does not cover

  • Registering row model factories. There is nothing to register; the three functions are always available.
  • Custom facetedUniqueValues / facetedMinMaxValues factories for server-side faceting. There is no factory slot to override. Fetch the server's facets into your own model and render from that; the package's faceting functions are ordinary functions you can simply not call.
  • Memoization. TanStack caches these row models. Here you decide what to keep, as with every other row model. See Row Models.
  • table.getGlobalFacetedRowModel() and friends. There is no separate global set of functions: pass Table.globalFacetKey to the same three.

Example

Faceted Filters, ported from TanStack's Faceted Filters example.