Skip to content

Data tables

The walkthrough builds a data table with @tanstack/solid-table, through Partas.Solid.TanStack.Table, styled with Tailwind. It follows the solid-ui port of the shadcn-ui data table.

For a table on Partas.Solid 3.0 today, the live example below sorts one with @tanstack/table-core. The repository's runtime tests also hold a sortable, filterable table in plain Partas.Solid: tests/Partas.Solid.Tests.Runtime/Integration/Apps/E-DataTable.fs.

Try it on Partas.Solid 3.0

@tanstack/solid-table is still Solid 1, but @tanstack/table-core has no framework dependency at all, so it works with Solid 2 today. You bind the three functions you need with [<Import>], and describe the parts of the table object you call with a few F# interfaces. Nothing here is generated: an interface member such as getRowModel: unit -> RowModel compiles to a plain table.getRowModel() call.

[<Import("createTable", "@tanstack/table-core")>]
let createTable (options: obj): obj = jsNative

[<Import("getCoreRowModel", "@tanstack/table-core")>]
let getCoreRowModel (): obj = jsNative

[<Import("getSortedRowModel", "@tanstack/table-core")>]
let getSortedRowModel (): obj = jsNative

type ColumnSort = {| id: string; desc: bool |}

type Column =
    abstract id: string
    abstract columnDef: {| header: string; meta: {| numeric: bool |} |}
    /// false, "asc" or "desc"
    abstract getIsSorted: unit -> obj
    abstract getToggleSortingHandler: unit -> (obj -> unit)

type Header =
    abstract id: string
    abstract column: Column

type Cell =
    abstract id: string
    abstract column: Column
    abstract getValue: unit -> obj

type Row =
    abstract id: string
    abstract getVisibleCells: unit -> Cell array

type RowModel =
    abstract rows: Row array

type Table =
    abstract initialState: obj
    abstract setOptions: (obj -> obj) -> unit
    abstract getFlatHeaders: unit -> Header array
    abstract getRowModel: unit -> RowModel

table-core does not own any state. The sorting lives in a Solid signal, and a memo hands it to the table with setOptions whenever it changes. Everything that reads the table reads that memo first, so it always sees the current sort. Click a header to sort by it; shift-click to add a second sort key.

[<SolidComponent>]
let PlanetTable () =
    let sorting, setSorting = createSignal<ColumnSort array> [||]

    let column key header numeric =
        {| accessorKey = key; header = header; meta = {| numeric = numeric |} |}

    // table-core passes either the new sorting or a function of the old one.
    let onSortingChange (updater: obj) =
        if jsTypeof updater = "function" then
            let update = unbox<ColumnSort array -> ColumnSort array> updater
            setSorting (update (sorting ()))
        else
            setSorting (unbox updater)

    let tbl: Table =
        createTable
            {| data = planets
               columns =
                   [| column "name" "Planet" false
                      column "moons" "Moons" true
                      column "diameter" "Diameter (km)" true
                      column "au" "Distance (AU)" true |]
               state = createObj []
               onStateChange = ignore
               onSortingChange = onSortingChange
               renderFallbackValue = null
               getCoreRowModel = getCoreRowModel ()
               getSortedRowModel = getSortedRowModel () |}
        |> unbox

    // Hand the signal to table-core. Returns the sorting, so every reader depends on it.
    let synced =
        createMemo (fun (_: ColumnSort array option) ->
            let current = sorting ()
            let state = JS.Constructors.Object.assign (createObj [], tbl.initialState, {| sorting = current |})
            tbl.setOptions (fun prev -> JS.Constructors.Object.assign (createObj [], prev, {| state = state |}))
            current)

    let headers () = synced () |> ignore; tbl.getFlatHeaders ()
    let rows () = synced () |> ignore; tbl.getRowModel().rows

    let arrow (column: Column) =
        synced () |> ignore
        match string (column.getIsSorted ()) with
        | "asc" -> "▲"
        | "desc" -> "▼"
        | _ -> ""

    let cellStyle (column: Column) =
        let align =
            if column.columnDef.meta.numeric then "text-align: right; font-variant-numeric: tabular-nums;"
            else "font-weight: 500;"
        let tint = if arrow column <> "" then " background: var(--nacara-primary-subtle);" else ""
        align + tint

    let summary () =
        match synced () |> Array.toList with
        | [] -> "Unsorted. Click a header."
        | keys ->
            keys
            |> List.map (fun key -> key.id + (if key.desc then " descending" else " ascending"))
            |> String.concat ", then "
            |> sprintf "Sorted by %s."

    let sortButton =
        "background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer;"
        + " display: inline-flex; gap: .375rem; align-items: center"
    let footer =
        "display: flex; align-items: center; justify-content: space-between; gap: 1rem;"
        + " margin-top: .75rem; font-size: .875rem; color: var(--nacara-text-muted)"

    div () {
        table (style = "background: var(--nacara-bg)") {
            thead () {
                tr () {
                    For.Keyed(each = headers ()) {
                        yield fun header _ ->
                            let col = header.column
                            th (style = if col.columnDef.meta.numeric then "text-align: right" else "") {
                                button (
                                    class' = "planet-sort",
                                    style = sortButton,
                                    onClick = fun e -> col.getToggleSortingHandler () e
                                ) {
                                    col.columnDef.header
                                    span (style = "font-size: .7em; width: 1em; color: var(--nacara-primary)") {
                                        arrow col
                                    }
                                }
                            }
                    }
                }
            }
            tbody () {
                For.KeyedFn(each = rows (), keyed = fun (row: Row) -> box row.id) {
                    yield fun row _ ->
                        tr () {
                            For.Keyed(each = row().getVisibleCells ()) {
                                yield fun cell _ ->
                                    td (style = cellStyle cell.column) { string (cell.getValue ()) }
                            }
                        }
                }
            }
        }
        div (style = footer) {
            span () { summary () }
            button (class' = "p-btn p-btn--secondary", onClick = fun _ -> setSorting [||]) { "Clear sorting" }
        }
    }
export function PlanetTable() {
    let columns, state, renderFallbackValue, matchValue_1, arg;
    const patternInput = createSignal([]);
    const sorting = patternInput[0];
    const setSorting = patternInput[1];
    const column = (key, header, numeric) => ({
        accessorKey: key,
        header: header,
        meta: {
            numeric: numeric,
        },
    });
    const tbl = createTable((columns = [column("name", "Planet", false), column("moons", "Moons", true), column("diameter", "Diameter (km)", true), column("au", "Distance (AU)", true)], (state = {}, (renderFallbackValue = defaultOf(), {
        columns: columns,
        data: planets,
        getCoreRowModel: getCoreRowModel(),
        getSortedRowModel: getSortedRowModel(),
        onSortingChange: (updater) => {
            if ((typeof updater) === "function") {
                setSorting(updater(sorting()));
            }
            else {
                setSorting(updater);
            }
        },
        onStateChange: (value) => value,
        renderFallbackValue: renderFallbackValue,
        state: state,
    }))));
    const synced = createMemo((_arg) => {
        const current = sorting();
        const state_1 = Object.assign({}, tbl.initialState, {
            sorting: current,
        });
        tbl.setOptions((prev) => Object.assign({}, prev, {
            state: state_1,
        }));
        return current;
    });
    const arrow = (column_1) => {
        synced();
        const matchValue = toString(column_1.getIsSorted());
        switch (matchValue) {
            case "asc":
                return "▲";
            case "desc":
                return "▼";
            default:
                return "";
        }
    };
    const sortButton = "background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer; display: inline-flex; gap: .375rem; align-items: center";
    const footer = "display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-top: .75rem; font-size: .875rem; color: var(--nacara-text-muted)";
    return <div>
        <table style="background: var(--nacara-bg)">
            <thead>
                <tr>
                    <KeyedFor each={(synced(), tbl.getFlatHeaders())}>
                        {(header_1, _arg_1) => {
                            const col = header_1.column;
                            return <th style={col.columnDef.meta.numeric ? "text-align: right" : ""}>
                                <button class="planet-sort"
                                    style={sortButton}
                                    onClick={(e) => {
                                        col.getToggleSortingHandler()(e);
                                    }}>
                                    {col.columnDef.header}
                                    <span style="font-size: .7em; width: 1em; color: var(--nacara-primary)">
                                        {arrow(col)}
                                    </span>
                                </button>
                            </th>;
                        }}
                    </KeyedFor>
                </tr>
            </thead>
            <tbody>
                <KeyedFnFor each={(synced(), tbl.getRowModel().rows)}
                    keyed={(row_1) => row_1.id}>
                    {(row, _arg_2) => <tr>
                        <KeyedFor each={row().getVisibleCells()}>
                            {(cell, _arg_3) => {
                                let column_2;
                                return <td style={(column_2 = cell.column, (column_2.columnDef.meta.numeric ? "text-align: right; font-variant-numeric: tabular-nums;" : "font-weight: 500;") + ((arrow(column_2) !== "") ? " background: var(--nacara-primary-subtle);" : ""))}>
                                    {toString(cell.getValue())}
                                </td>;
                            }}
                        </KeyedFor>
                    </tr>}
                </KeyedFnFor>
            </tbody>
        </table>
        <div style={footer}>
            <span>
                {(matchValue_1 = ofArray(synced()), isEmpty(matchValue_1) ? "Unsorted. Click a header." : ((arg = join(", then ", map((key_1) => (key_1.id + (key_1.desc ? " descending" : " ascending")), matchValue_1)), toText(printf("Sorted by %s."))(arg))))}
            </span>
            <button class="p-btn p-btn--secondary"
                onClick={(_arg_4) => {
                    setSorting([]);
                }}>
                Clear sorting
            </button>
        </div>
    </div>;
}

Setting up the 2.x project

The rest of this walkthrough needs a Partas.Solid 2.x project on Solid 1.9, set up as in the 2.x documentation, with Tailwind 4 and the shadcn colour variables from solid-ui. On top of that, it uses these packages:

{
  "dependencies": {
    "@kobalte/core": "^0.13.8",
    "@tanstack/solid-table": "^8.21.2",
    "clsx": "^2.1.1",
    "lucide-solid": "^0.474.0",
    "tailwind-merge": "^2.6.0"
  }
}
dotnet add package Partas.Solid.TanStack.Table
dotnet add package Partas.Solid.Kobalte
dotnet add package Partas.Solid.Lucide

Next: Utilities.

Edit this page