MonoStack

@repo/table

Data grid system built on TanStack Table with URL-based state management.

Usage

Query Endpoint

Create a query endpoint that handles pagination, sorting, and filtering:

// users.remote.ts
import { query } from '$app/server'
import { countSelect, overviewToQuery } from '@repo/db'
import { createOverviewQuerySchema } from '@repo/table/server'

export const getUsersQuery = query(createOverviewQuerySchema(userFilterSchema), async (data) => {
  const { db, usersTable } = await getTenantDb()

  return await overviewToQuery(
    db
      .select({
        id: usersTable.id,
        name: usersTable.name,
        count: countSelect, // Required for pagination
      })
      .from(usersTable)
      .$dynamic(),
    usersTable,
    data,
  )
})

Use @repo/table/server for query schemas and pagination contracts in server or remote modules. Use @repo/table/remote for the browser data-grid API.

DataGrid Component

<script lang="ts">
  import type { QueryDataPoint } from '@repo/table/remote'
  import { getUsersQuery } from '$lib/domains/users/database/users.remote'
  import { userFilterSchema } from '$lib/domains/users/utils/schemas'
  import { createColumnHelper, DataGrid, useDataGrid } from '@repo/table/remote'

  const ch = createColumnHelper<QueryDataPoint<typeof getUsersQuery>>()

  const { table, dataGrid } = useDataGrid({
    tableOptions: {
      columns: ch.columns([
        ch.accessor('name', { header: 'Name' }),
        ch.accessor('createdAt', { header: 'Created At' }),
      ]),
      initialState: {
        sorting: [{ id: 'name', desc: false }],
      },
    },
    query: getUsersQuery,
    filterSchema: userFilterSchema,
    getRowLink: (row) => `users/${row.id}`,
  })
</script>

<DataGrid.Root title="Users" {table} {dataGrid} add="/users/add">
  <DataGrid.FilterForm schema={userFilterSchema} />
</DataGrid.Root>

Set addLabel to show text beside the add icon from the md breakpoint upward. The label remains the button's accessible name when hidden on smaller screens.

Filters render in the toolbar as compact badges. The Filters pill lists inactive fields; choosing a field adds it to the bar, where its popover can edit or remove the filter. Active filters remain URL-backed, so paging, sorting, and reloads preserve the current query.

Set applyFiltersImmediately={false} on DataGrid.Root to stage badge changes. In that mode the toolbar shows a right-aligned Apply button, enabled only while the valid draft differs from the applied URL-backed filters.

Override the table presentation without rebuilding generated rows, states, sorting, or pagination:

<script lang="ts">
  import { Table } from '@repo/components'
</script>

<DataGrid.Root {table} {dataGrid} {title}>
  {#snippet tableLayout({ body, headerRows, loading, pagination })}
    <div class="overflow-hidden rounded-lg border border-primary/20">
      <Table.Root aria-busy={loading}>
        <Table.Header class="bg-primary/5 [&_th]:text-primary">
          {@render headerRows()}
        </Table.Header>
        {@render body()}
      </Table.Root>
      {@render pagination()}
    </div>
  {/snippet}
</DataGrid.Root>

tableLayout replaces the default table container. Render headerRows, body, and pagination once and preserve the table semantics.

Custom Filter Fields

Use DataGrid.FilterField inside DataGrid.FilterForm for custom field rendering (e.g., select dropdowns with dynamic options):

<DataGrid.FilterForm schema={userFilterSchema}>
  {#snippet children({ fields, value })}
    <TypedForm.Field field={fields.name} bind:value={value.name} />
    <TypedForm.Field
      field={fields.status}
      bind:value={value.status}
      input={TypedForm.SelectInput}
      optionItems={statusOptions}
    />
    <TypedForm.Field
      field={fields.roleId}
      bind:value={value.roleId}
      input={TypedForm.SelectInput}
      optionItems={roleOptions}
      label="Role"
    />
  {/snippet}
</DataGrid.FilterForm>

Custom filter snippets receive fields and the current filter value. Bind custom fields to value.*. Apply/Clear buttons are handled automatically by FilterForm.

Option Label Cells

Use optionsCell to display SelectOption labels in table cells while keeping the raw value for sorting and filtering:

<script lang="ts">
  import { optionsCell } from '@repo/table'

  const statusOptions = [
    { label: 'Active', value: 'active' },
    { label: 'Archived', value: 'archived' },
  ]

  const columns = ch.columns([
    ch.accessor('status', {
      header: 'Status',
      cell: optionsCell(statusOptions),
    }),
  ])
</script>

Components

ComponentDescription
DataGrid.RootMain container with title, actions, and optional controls
DataGrid.FilterFormAuto-generated filter form from schema
DataGrid.FilterFieldCustom filter field (use inside FilterForm with children snippet)
TableLower-level table component
PaginationPagination controls

Remote data grids show the filtered and unfiltered counts in the table footer, for example 4 of 12 items.

useDataGrid Options

OptionDescription
tableOptionsTanStack Table options (columns, initial state)
queryRemote query function
filterSchemaZod schema for filters
getRowLinkFunction to generate row links

Row Selection

Enable checkbox-based row selection via tableOptions.enableRowSelection (native TanStack option). A checkbox column is automatically prepended.

<script lang="ts">
  const { table, dataGrid, selectedRows, resetRowSelection } = useDataGrid({
    tableOptions: {
      columns,
      enableRowSelection: true,
    },
    query: getUsersQuery,
  })

  function handleBulkDelete() {
    // selectedRows contains the actual data items
    console.log(selectedRows)
    resetRowSelection()
  }
</script>

A checkbox column is automatically prepended. The header checkbox supports select-all with indeterminate state.

URL State

Sorting, pagination, filters, and column configuration are automatically synced to URL params for shareable links.

Column visibility uses a cols param listing visible column IDs (e.g. ?cols=name,email). No cols param means all columns are visible.

The grid uses TanStack Table v9's feature-bound table hook and external atoms for sorting, pagination, row selection, and column visibility. Read and update these slices through table.state, table.atoms, and table methods; do not add local state bridges or call setOptions to force reactivity.

Column management is enabled by default. Set showColumnManagement={false} on DataGrid.Root to hide its menu. Set enableHiding: false in tableOptions, or on an individual column, to prevent column toggling.

On this page