Data

Tables start with your data. This page is the Elm counterpart of TanStack Table's Data Guide, which is about the TData generic, reaching values out of nested objects, sub-rows, and giving the data array a stable reference.

The short version: any record type works, the accessor is a function so there are no string key paths, sub-rows come from Table.withSubRows, stable ids come from Table.withGetRowId, and there is no stable-reference rule at all because nothing is memoized.

Defining Your Row Type

TanStack calls your row type TData and threads it through every table, column, row, and cell type. Here the same type is the row parameter of Table.Config row, Table.Column row, Table.Row row, and Table.RowModel row, and it is inferred from your accessors.

Any type works: a record, a custom type, a tuple, even a Dict. The package never looks at a row directly. It only calls the accessor functions you gave each column, plus withGetRowId and withSubRows if you set them.

The examples in these docs use one fixture:

type alias Person =
    { id : String
    , firstName : String
    , lastName : String
    , department : String
    , age : Int
    , salary : Float
    , active : Bool
    , tags : List String
    }

A column reads one field of it and wraps the result in a Value, the single union type every built-in sort, filter, and aggregation function works on:

stableIds : Table.Config Person
stableIds =
    Table.config [ Table.column "firstName" (.firstName >> Value.String) ]
        |> Table.withGetRowId (\person _ _ -> person.id)

There is no columnHelper and no type parameter to pass. Table.column gets its row type from the accessor you hand it, and the compiler checks that every column in one Table.config list agrees. See Column Definitions.

Deep Keyed Data

TanStack lets an accessorKey be a dotted path, 'name.first', because JavaScript objects can be indexed by string. Elm records cannot, so this package has no key-path form: every accessor column takes a function, which covers the same ground.

Given nested records:

type alias Employee =
    { name : { first : String, last : String }
    , info : { age : Int, visits : Int }
    }

reach into them with ordinary field access:

nestedColumns : List (Table.Column Employee)
nestedColumns =
    [ Table.column "firstName" (.name >> .first >> Value.String)
        |> Table.withHeader "First name"
    , Table.column "lastName" (.name >> .last >> Value.String)
        |> Table.withHeader "Last name"
    , Table.column "age" (.info >> .age >> toFloat >> Value.Number)
        |> Table.withHeader "Age"
    ]

Because the accessor is a plain function, a computed value is no different from a stored one: \person -> Value.String (person.firstName ++ " " ++ person.lastName) is a valid accessor. Whatever it returns is what sorting, filtering, grouping, and faceting see.

TanStack's note about periods in a key does not apply. Column ids here are opaque strings; nothing parses them.

Nested Sub-Row Data

If a row can contain child rows, tell the core row model how to reach them with Table.withSubRows. TanStack looks for a subRows field by convention; here you pass the function, so the field can be called anything, and the row type can be a custom type rather than a record:

categoryConfig : Table.Config Category
categoryConfig =
    Table.config
        [ Table.column "name" (\(Category c) -> Value.String c.name)
            |> Table.withHeader "Name"
        , Table.column "total" (\(Category c) -> Value.Number c.total)
            |> Table.withHeader "Total"
        ]
        |> Table.withSubRows (\(Category c) -> c.children)
        |> Table.withGetRowId (\(Category c) _ _ -> c.id)

An Elm type alias cannot refer to itself, so a self-nesting row type has to be a custom type like Category above. The accessors then pattern match on the constructor.

Sub-rows are resolved once, by the core row model, before any other stage runs. Depth, parent id, and the flattened row list all come from that pass. Expanding covers which sub-rows are shown.

Stable Row Ids

Without withGetRowId, a root row's id is its index as a string, "0", "1", "2", and a child's id is its parent's id, a dot, and its own index: "0.1". That is fine until the data reorders, at which point selected, expanded, and pinned rows follow the position rather than the record.

Pass Table.withGetRowId when your data has a real key. It receives the datum, its index among its siblings, and its parent's row id:

Table.withGetRowId : (row -> Int -> Maybe String -> String) -> Config row -> Config row

Row ids are what Row Selection, Expanding, and Row Pinning store in State, so a stable id is what lets that state survive a refetch. This matters most with server-side processing, where each response is a different list of records.

Data of Unknown Shape

When the columns are only known at runtime (an arbitrary API response, an uploaded CSV, a user-configured report), pick a row type that can hold anything and generate the column list from the data. Dict String String is the usual choice:

Table.column key
    (\row -> Dict.get key row |> Maybe.map Value.String |> Maybe.withDefault Value.Null)

Build that list with List.map over the keys of the first record. You lose per-field types, exactly as TanStack does with Record<string, unknown>, and you pick a sort and filter function per column from whatever runtime check you can make. Everything else works unchanged.

Passing Data In: rows and rowsFromList

Two functions run the pipeline, and they differ only in the container they take:

Function Data argument Use it when
Table.rows Array row Your data is already an Array, usually because you index into it elsewhere.
Table.rowsFromList List row Your data is a List, which is what a JSON decoder and most Elm code produce.
fromList : Table.State -> Table.RowModel Person
fromList state =
    Table.rowsFromList stableIds state people
fromArray : Table.State -> Table.RowModel Person
fromArray state =
    Table.rows stableIds state (Array.fromList people)

Table.rows calls Array.toList and then rowsFromList, so neither is faster. Pick the one that matches the type you already hold. Table.coreRowModel and Table.coreRowModelFromList pair off the same way. Every other stage takes a RowModel, so the choice is made once, at the start.

No Stable Reference to Keep

TanStack's longest section warns that data and columns must keep a stable JavaScript reference, because row models are memoized against those references and an unstable one both rebuilds every row on each render and can drive auto-reset features into a render loop.

None of that exists here. Nothing in this package is memoized, so nothing is compared by reference, and there is no auto-reset hook that fires when a row model recomputes. Rebuilding your data list on every call is correct, if wasteful.

The cost is the other side of the same coin: every call recomputes. Calling Table.rowsFromList in view runs all six stages on every frame. The fix is to compute the row model once in update and store it in your model next to the State:

type alias Model =
    { state : Table.State
    , rowModel : Table.RowModel Person
    }

Table State is about that pattern: who owns the State, when to store the RowModel beside it, and how to keep the two in step.

Config deserves the same treatment for a different reason. It holds no state, so build it once at the top level of your module rather than inside view. See Config and State.

How This Package Transforms Data

Your data is never modified. Each pipeline stage builds a new RowModel, and every Table.Row keeps the original datum, which Table.rowOriginal hands back unchanged.

What does change is the row set and the values read from it. Filtering drops rows, grouping replaces them with group rows that carry aggregated values, sorting reorders every level of the tree, expanding flattens branches into the list, and pagination keeps one page. Row Models walks through each stage; Rows covers what a row carries.

How Much Data Can It Handle?

The package's benchmark, on 10,000 rows with elm make --optimize, measures:

Case Shape Median
Core row model, flat 10,000 rows 15.1 ms
Core row model, nested 2,500 parents x 3 children 12.4 ms
Full pipeline one filter, one sort, page size 50 94.3 ms
Full pipeline with grouping plus 20 groups, sum and mean, expand all 166.3 ms

Those numbers are one machine and one row shape. What they cost you depends on the number of columns, the work your accessors do, and the device. Measure with your own data.

Two things move the number more than row count does. Recomputing the row model more often than the state changes is the common one, and storing it in your model fixes it. Running stages you do not need is the other: if the server already sorted the rows, set manualSorting and the stage is skipped altogether. See Client-Side vs Server-Side for that decision, and Virtualization for the separate question of how many rows you draw.