Skip to main content

Component | Table

Display and interact with tabular data. Start with a single function call, and scale to an enterprise-grade feature set when you need it. Tables offer:

  • Actions to add interactivity to each row.
  • Customizable columns that can be pinned, hidden, and formatted for a variety of data types.
  • Built-in data controls to search, sort, and filter data, along with views to save configurations of these controls.
  • Pagination and virtualization to efficiently scale to millions of rows.
const customers = [{ name: "Apple", tier: "Enterprise", arr: 150000, onboarded: true }, /* ... */]

ui.table(
"customers-table",
customers,
{
actions: [
{
label: "Details",
onClick: (row) => page.modal(() => ui.json(row), { title: "Customer Details" })
}
],
columns: [
"name",
{ key: "tier", format: "tag" },
{ key: "arr", label: "ARR", format: "currency" },
"onboarded"
],
views: [
{
label: "Highest ARR",
description: "Sort by ARR (decreasing)",
sortBy: [{ key: "arr", direction: "desc" }],
isDefault: true,
},
{
label: "Enterprise Customers",
description: "Filter to show only enterprise customers",
filterBy: {
key: "tier",
operator: "hasAny",
value: ["Enterprise"],
},
},
]
}
)

Row actions

Actions are a powerful construct that allows you to add row-level interactivity to your table. Tables can have an unlimited number of actions, rendered either as buttons or nested inside a dropdown menu.

Actions are often used as a foundation to create robust multipage apps.

Each action's callback will receive the row and index as arguments.

ui.table("table-key",
[ /* list of users */ ],
{
actions: [
{
label: "Details",
onClick: (row) => page.link("user-details", { params: { id: row.id } })
},
{
label: "Delete",
onClick: (row) => deleteUser(row.id),
appearance: "danger"
},
{
label: "View",
onClick: (row, idx) => page.modal(() => ui.json(row)),
surface: true // Render as button instead of inside dropdown (or vice-versa if false).
}
]
})

Action object properties

label

required string
The user-facing label to display for the action.

onClick

required (row: TableRow, idx: number) => void
Callback to execute when the action is clicked. The row and index will be passed as arguments.

appearance

optional string literal

The appearance of the action. Defaults to outline. Options:

  • "primary"
  • "outline"
  • "warning"
  • "danger"

surface

optional boolean

Set to true to render the action as a button. Set to false to render the action inside a dropdown.

By default, if the table has only one action, it will render as a button. If there are multiple actions, they'll render inside a dropdown.


Columns

By default, Compose infers which columns to show (and how to render them) from your data. To take control, pass a columns array: either simple key strings or full column-definition objects.

Selecting & ordering

Choose which fields to show, and in what order, by listing their property keys.

const users = [
{ name: "John", age: 30, createdAt: new Date("2021-01-01"), isActive: true },
{ name: "Jane", age: 25, createdAt: new Date("2021-01-02"), isActive: false },
]

// Show only name -> age -> isActive
ui.table("table-key", users, { columns: ["name", "age", "isActive"] })

Formatting

Compose will infer column labels and data types from your data. Override this behavior by passing objects that allows you to more granularly control each column:

  • label: specify the header text.
  • width: specify a custom width for the column.
  • format: choose from a variety of built-in types like date, currency, tag, json, etc.
  • tagColors: map values to pill colors when using the tag format.
const users = [
{ name: "John", createdAt: new Date("2021-01-01"), isActive: true, tier: "Enterprise" },
{ name: "Jane", createdAt: new Date("2021-01-02"), isActive: false, tier: "Basic" },
]

ui.table("table-key", users, {
columns: [
"name",
{ key: "createdAt", format: "date", width: "150px" },
{ key: "isActive", label: "Status", format: "boolean" },
{ key: "tier", format: "tag", tagColors: { "blue": ["Enterprise", "Pro"], "green": "Basic" } }
]
})

Visibility & pinning

While end-users can toggle column visibility and pinning via the table's toolbar, you can also control the initial settings:

  • hidden: hide a column.
  • pinned: pin a column to the left or right of the table.
ui.table("table-key", users, {
columns: [
{ key: "name", pinned: "left" },
{ key: "createdAt", hidden: true },
"isActive",
"tier"
]
})

Column object properties

key

required string
Specify which property of the data object should be displayed in that column.

label

optional string

Specify a custom label for the column. If not provided, the label will be inferred from the key


width

optional string

Specify a custom width for the column (e.g. "150px" or "3rem"). If not provided, the width will be inferred from the content.


format

optional string literal

The data type of the column. Specifying this allows Compose to render the column in a more user-friendly format, and sort and filter correctly. If not provided, the format will be inferred from the data.

Options:

  • "date": Render a date in a human-friendly format (e.g. Oct 10, 2000). Expects a Date object or ISO string.
  • "datetime": Render a datetime in a human-friendly format (e.g. Oct 10, 2000, 11:14 AM). Expects a Date object or ISO string.
  • "number": Render a number in a human-friendly format (e.g. 1,234).
  • "currency": Render a number in a currency format (e.g. $1,234.56).
  • "boolean": Render a boolean in a human-friendly format (e.g. `✅` or `❌`). Will perform a truthiness check on non-boolean values.
  • "tag": Render values (or lists of values) as colored pills. Useful for enum-type data. You can pass a tagColors property to specify the colors of the tags. If not provided, Compose will randomly assign colors to each value.
  • "json": Render JSON objects and arrays as formatted code blocks.
  • "string": Stringify the value and render as is. Use when you don't want any special formatting.

tagColors

optional Record<string, string | string[]>SDK v0.23.0+

Map tag colors to column values when using format: "tag". If this property is not provided, Compose will randomly assign colors to each value.

Colors can be mapped to single values or arrays of values. If a value isn't mapped to a color, it will use the _default color if specified, or be automatically assigned a color.

Available colors: red, orange, yellow, green, blue, purple, pink, gray, brown.

Example:

{
key: "status",
format: "tag",
tagColors: {
purple: ["todo", "backlog"],
orange: "in_progress",
green: "done",
_default: "gray" // Render any unmapped values as gray
}
}

Compose tags support string, number, and boolean values, or lists of these values. Any other value will be rendered as plain text.


hidden

optional boolean
Whether to initially hide the column from the table. This can always be toggled by the user via the table's toolbar. By default, all columns are visible.

pinned

optional string literal

Whether to pin the column to the left or right of the table. Options:

  • "left": Pin the column to the left of the table.
  • "right": Pin the column to the right of the table.

By default, columns are not pinned.


overflow

optional string literal

Whether to truncate the cell content if it overflows the column width. In most cases, you should use the table's overflow property instead. This property will override the table-wide setting for this column. Options:

  • "clip": Clip the cell content if it overflows.
  • "dynamic": Expand the cell's height to fit the content.
  • "ellipsis": Show an ellipsis (...) when the cell content overflows.

The default behavior is to use the table-wide overflow setting, which itself defaults to "ellipsis".


Data controls

Every table includes built-in data controls that enable users to search, sort, and filter data.

Views

Views let your users switch between preset configurations of data and presentation controls. They enable complex workflows, while taking just a few minutes to setup.

As an example, the following table includes three views, each of which serves a unique purpose.

const customers = db.selectCompanies();

page.add(() => ui.table("customers-table", customers, {
columns: [
"name",
"createdAt",
"onboardingComplete",
{
key: "plan",
format: "tag",
tagColors: {
"blue": "Enterprise",
"green": "Pro",
"red": "Basic"
}
},
{
key: "arr",
label: "ARR",
format: "currency"
},
{
key: "featureFlags",
format: "json",
hidden: true
},
],
views: [
// View 1: Enterprise customers, sorted by ARR.
{
label: "Enterprise Customers",
description: "Enterprise customers, sorted by ARR.",
filterBy: {
key: "plan",
operator: "hasAny",
value: ["Enterprise"]
},
sortBy: [
{
key: "arr",
direction: "desc"
}
]
},
// View 2: Feature flags, with table rows expanded to show the flags object.
{
label: "Feature Flags",
description: "View customer feature flags",
columns: {
featureFlags: {
hidden: false,
pinned: "right"
},
arr: {
hidden: true
}
},
overflow: "dynamic"
},
// View 3: Customers still in onboarding, sorted by oldest to newest.
{
label: "Onboarding Customers",
description: "Customers still in onboarding, sorted by oldest to newest.",
filterBy: {
key: "onboarding",
operator: "is",
value: false
},
sortBy: [
{
key: "createdAt",
direction: "asc"
}
]
}
]
}))

Filtering

Compose tables support complex filtering operations via a combination of filter rules and filter groups.

  • Filter rule: A single filter that's applied to a column. For example, "Revenue column greater than 1000".
  • Filter group: A collection of filter rules and groups that are applied together using a logical operator. For example, "Revenue column greater than 1000 AND Country column is United States".

Disable column filtering by setting the table's filterable property to false.

const customers = db.selectCompanies();

page.add(() => ui.table("customers-table", customers, {
columns: [
"name",
"createdAt",
"onboardingComplete",
{ key: "plan", format: "tag" },
{ key: "arr", format: "currency" },
],
views: [
{
label: "Enterprise Customers",
// Example 1: Apply a single filter rule.
filterBy: {
key: "plan",
operator: "hasAny",
value: ["Enterprise"]
},
},
{
label: "Urgent Customers",
// Example 2: A complex nested filter group.
filterBy: {
logicalOperator: "and",
filters: [
{
key: "plan",
operator: "hasAny",
value: ["Enterprise"]
},
{
logicalOperator: "or",
filters: [
{
key: "arr",
operator: "greaterThan",
value: 1000
},
{
key: "onboardingComplete",
operator: "is",
value: false
}
]
}
]
}
},
]
}))

Filtering API reference

A complete type definition of the column filter property is provided below for reference.

type ColumnFilterLogicOperator = "and" | "or";

// Note: Each column format supports a subset of these operators. More info below.
type ColumnFilterOperator =
| "is"
| "isNot"
| "includes"
| "notIncludes"
| "greaterThan"
| "greaterThanOrEqual"
| "lessThan"
| "lessThanOrEqual"
| "isEmpty"
| "isNotEmpty"
| "hasAny"
| "notHasAny"
| "hasAll"
| "notHasAll";

interface TableColumnFilterRule<TData extends TableDataRow[]> {
key: StringOnlyKeys<TData[number]>;
operator: ColumnFilterOperator;
value: any;
}

interface TableColumnFilterGroup<TData extends TableDataRow[]> {
logicOperator: ColumnFilterLogicOperator;
filters: NonNullable<TableColumnFilterModel<TData>>[];
}

type TableColumnFilterModel<TData extends TableDataRow[]> =
| TableColumnFilterRule<TData>
| TableColumnFilterGroup<TData>
| null;
Column FormatSupported Operators
string, jsonis, isNot, includes, notIncludes, isEmpty, isNotEmpty
number, currency, date, datetimegreaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual, isEmpty, isNotEmpty
booleanis, isNot, isEmpty, isNotEmpty
tagis, isNot, hasAny, notHasAny, hasAll, notHasAll, isEmpty, isNotEmpty

Searching

Compose tables support global search via a search bar at the top of the table. A default query can be specified via the searchQuery property.

Disable global search by setting the table's searchable property to false.

const customers = db.selectCompanies();

page.add(() => ui.table("customers-table", customers, {
columns: [
"name",
"createdAt",
"notes"
],
views: [
{
label: "Urgent customers",
description: "Search the table for any row containing the word 'urgent'.",
searchQuery: "urgent",
},
]
}))

Searching API reference

// Either a string or null if no search is being performed.
type TableSearchQuery = string | null;

Sorting

Compose tables support multi-column sorting via an ordered list of sort rules.

Disable sorting by setting the table's sortable property to false, or limit to single-column sorting by setting the property to "single".

const customers = db.selectCompanies();

page.add(() => ui.table("customers-table", customers, {
columns: [
"name",
"createdAt",
"tier",
"seatCount"
],
views: [
{
label: "Largest to smallest customers",
description: "Sort the table by seat count, then by name.",
sortBy: [
{ key: "seatCount", direction: "desc" },
{ key: "name", direction: "asc" }
]
},
]
}))

Sorting API reference

interface TableColumnSortRule<TData extends TableDataRow[]> {
key: StringOnlyKeys<TData[number]>; // The column to sort by.
direction: "asc" | "desc"; // The direction to sort by.
}

// An ordered list of column sort rules.
type TableColumnSortModel<TData extends TableDataRow[]> = TableColumnSortRule<TData>[];

View object properties

label

required string
The user-facing label for the view.

description

optional string

A brief description of what the view does.


isDefault

optional boolean

Whether to apply this view by default when the table loads. Defaults to false. Only one view can be the default.


filterBy

optional TableColumnFilterModel

Filter the table according to the provided filter model. Learn more in the filtering section.


sortBy

optional TableColumnSortModel

Sort the table according to the provided sort model.

Should be an ordered list of sort rules. Each rule is an object with a key field that maps to a column key, and a direction field that is either "asc" or "desc".

Learn more in the sorting section.


searchQuery

optional string

A global search query to filter the table by. Learn more in the searching section.


density

optional string literal

Override the default density of the table rows for this view. Options:

  • "compact": 32px row height.
  • "standard": 40px row height.
  • "comfortable": 48px row height.

overflow

optional string literal

Override the default overflow behavior of the table cells for this view. Options:

  • "clip": Clip the cell content if it overflows.
  • "dynamic": Expand the cell's height to fit the content.
  • "ellipsis": Show an ellipsis (...) when the cell content overflows.

columns

optional string literal

Override the default pinned and hidden properties of specified table columns for this view. For example:

{
name: { pinned: "left" },
uuid: { hidden: false }
}

The structure is an object where the keys are the column keys, and the values are an object with the properties to override. Currently, the supported properties are pinned and hidden.


Pagination

Automatic Pagination

Tables with more than 2500 rows are automatically paginated without any additional configuration on your end, enabling Compose to remain performant at scale.

Auto-pagination disables searching, filtering, and sorting since the browser only has a paginated subset of the data. You can re-enable these features by manually paginating the table, which enables you to manage these features on your own server-side via a simple callback function.

Manual Pagination

For very large datasets, you should manually paginate tables by passing a getter function to the data parameter. Manual pagination enables you to:

  • fetch data from your data store as needed, instead of having to load everything into memory at once.
  • manage search, filtering, and sorting using your own custom logic.
  • scale endlessly (millions of rows) without performance degradation.
import { TablePageChangeParams, TablePageChangeResponse } from "@composehq/sdk"

async function onPageChange(params: TablePageChangeParams): TablePageChangeResponse {
const {
offset,
pageSize,
refreshTotalRecords,
prevTotalRecords,
searchQuery, // optional to handle search
sortBy, // optional to handle sorting
filterBy, // optional to handle filtering
} = params;

const totalRecords = refreshTotalRecords || prevTotalRecords === null
? await postgres.countUsers(searchQuery, filterBy)
: prevTotalRecords;

const pageOfUsers = await postgres.getUsers(offset, pageSize, searchQuery, sortBy, filterBy);

return {
data: pageOfUsers,
totalRecords,
};
}

page.add(() => ui.table("users-table", onPageChange))

Pagination type definitions

// Passed to the onPageChange callback as a single object.
interface TablePageChangeParams<TData extends TableDataRow[]> {
// The number of rows to skip.
offset: number;
// The number of rows to return. You can return less than this if there
// aren't enough rows to fill the page (e.g. the last page).
pageSize: number;
// The search query to filter the rows by. Will be null if no search
// is being performed.
searchQuery: string | null;
// The sort model to sort the rows by. Will be an empty list if no sorting
// is being performed.
sortBy: TableColumnSortModel<TData>;
// The filter model to filter the rows by. Will be null if no filtering
// is being performed.
filterBy: TableColumnFilterModel<TData> | null;
// Whether to refresh the total number of records. Provided so that you
// only recalculate the total record count when needed.
refreshTotalRecords: boolean;
// The previous total number of records. If refreshTotalRecords is false,
// simply return the previous total number of records.
prevTotalRecords: number | null;
}


// Expected return type of the onPageChange callback.
interface TablePageChangeResponse {
// The data for the current page. Should be an array of objects.
data: TableData;
// The total number of records. This is used to calculate the number
// of pages.
totalRecords: number;
}

Learn more about the format of the data controls:

Handle data controls

By default, data controls (search, filtering, sorting) are disabled for paginated tables.

It's up to you to whether you want to handle some or all of these controls in your page change callback. If not handling, you can simply ignore those parameters in your page change callback.

For controls you choose to handle, you'll need to explicitly enable the control in the table component.

ui.table(
"users-table",
onPageChange,
{
searchable: true,
sortable: true,
filterable: true
}
)

Handle row selection

While Compose normally passes a list of full table rows to selection callback functions, it's not possible to do this for paginated tables since only a paginated subset of the data is available at any time.

To enable row selections for paginated tables, you'll need to pass a selectionReturnType: "id" property to the table. When this is enabled, Compose will pass a list of row IDs to any callbacks instead of the rows themselves.

The row ID should be a unique, stable identifier for each row (such as a uuid). It is specified via the primaryKey property. If not provided, Compose will default to using the row index (though this is not recommended since the row index is not stable).

ui.table(
"users-table",
onPageChange,
{
selectionReturnType: "id",
primaryKey: "uuid", // maps to the `uuid` field in the table data
onChange: (selectedRows) => {
console.log(selectedRows) // [uuid1, uuid2, uuid3, ...]
}
}
)

Row selection

Compose tables support row selection out of the box. To enable row selection:

  1. Specify a primaryKey that maps to a unique, stable identifier for each row (such as a uuid). (If not provided, Compose will default to using the row index, though this is not recommended since the row index is not stable.)
  2. Pass an onChange callback that will be called when the user selects/deselects rows.
ui.table(
"users-table",
users,
{
primaryKey: "uuid", // maps to the `uuid` field in the table data
onChange: (selectedUsers) => {
console.log(selectedUsers) // [user1, user2, user3, ...]
}
}
)

Advanced patterns

Row selection actions

A common use case is to take action on a set of selected rows. To do this, it's easy to combine row selections with buttons to quickly build out a UI.

let selectedUsers: User[] = [];

page.add(() => ui.table(
"users-table",
users,
{
primaryKey: "uuid",
onChange: (newSelectedUsers) => {
selectedUsers = newSelectedUsers;
page.update();
}
initialSelectedRows: selectedUsers
}
))

// Show a refund button if there are selected users
page.add(() => ui.cond(selectedUsers.length > 0 {
true: ui.button("Refund selected users", onClick: () => {
selectedUsers.forEach((user) => refundUser(user.uuid));
selectedUsers = [];
page.update(); // update table to clear row selections
})
}))

Usage in forms

To use tables in a form, simply:

  1. Include the table component inside the form.
  2. Pass selectable: true to the table component, which will enable row selections without the need to pass an onChange callback.

Once integrated into a form, row selections will automatically be included as a key in the form's submission payload.

Furthermore, just like other form inputs, table components include the following properties that are useful when used in forms:

  • validate: Perform field-level validation on the row selections to validate the form.
  • required: Whether at least one row must be selected to submit the form.

Paginated tables

Paginated tables require a slightly different configuration to enable row selections. Read more in the paginated row selection guide.

Look & feel

Tables include two powerful properties that make it easy to customize their look and feel.

Density

The density property can be used to control the overall density of table rows.

DensityIs DefaultRow HeightFont SizeBest For
compact32px12pxDense data, many columns
standard40px14pxGeneral use
comfortable48px16pxSparse data, few columns

Cell overflow

The overflow property controls how table cells handle content that exceeds the cell's width.

OverflowIs DefaultBehavior
ellipsisThe content will be truncated with an ellipsis.
clipThe content will be clipped to the cell.
dynamicThe cell height will grow to fit the content.

API reference

Function signature

type TableRow = {
[key: string]: string | number | boolean | Date | null | undefined | object
}

ui.table<T extends TableRow>(
id: string,
data: T[] | TablePageChangeCallback<T>,
properties?: Partial<{
// General
label: string,
description: string,

// Features
columns: TableColumn[],
actions: TableAction<T>[],
views: TableView[],

// Row selection
selectable: boolean,
primaryKey: string,
required: boolean
initialSelectedRows: number[],
validate: (values: T[]) => string | void,
onChange: (values: T[]) => void,
selectionReturnType: "full" | "id",
minSelections: number,
maxSelections: number,

// Data controls
searchable: boolean,
sortable: boolean,
filterable: boolean,
paginate: boolean,

// Style
density: "compact" | "standard" | "comfortable",
overflow: "ellipsis" | "clip" | "dynamic",
style: Style,
}>
)

Parameters

id

required string
A unique identifier for the component. This is necessary so that Compose can properly pass user actions back to the component.

data

required TableRow[] | TablePageChangeCallback

The data to be displayed in the table. Each item in the array represents a row in the table, and should be an object where the keys correspond to column names and the values are the cell contents.

Compose supports most data types including strings, numbers, booleans, dates, and objects.

For larger datasets, you can paginate the table server-side by passing a function that returns one page of data at a time. See the pagination section for more details.


properties.label

optional string

The label to display above the table. If not provided, the label will be inferred from the id.


properties.description

optional string

A description to display between the label and table.


properties.columns

optional TableColumn[]

Control how columns are rendered in the table. If not provided, Compose will default to using the keys from the first row of data, then do it's best to infer human readable column names, data types, etc. See the columns section for more details.


properties.actions

optional TableAction[]

Add row-level action buttons that execute custom functions when clicked. See the row actions section for more details.


properties.views

optional TableView[]

Add custom views that allow users to switch between preset configurations of data and presentation controls, such as sorts, filters, pinned columns, etc. See the views section for more details.


properties.selectable

optional boolean

Whether to show the row selection column. Defaults to false, or true if an onChange callback is provided.


properties.primaryKey

optional string

A key that maps to a field in the table data. The field should be a stable, unique identifier for each row (such as a uuid). Setting this property enables proper row selection tracking. If not provided, the row index will be used (not recommended).


properties.required

optional boolean

Validate that the table has at least one row selected before submitting the form it's part of or calling it's onChange hook. Defaults to true.


properties.initialSelectedRows

optional number[]

A list of row indices to pre-select when the table is first rendered. Defaults to [] (empty list).


properties.validate

optional (selectedRows: TableRow[]) => string | void

Validate the selected rows before submitting. If the function returns a string, it will be displayed as an error message.


properties.onChange

optional (selectedRows: TableRow[]) => void

A callback function that is called when the table selection(s) change. The callback is passed the list of currently selected rows.


properties.selectionReturnType

optional string literalSDK v0.27.0+

Options:

  • "full": Return the full row data to callback functions when the user selects rows.
  • "id": Return the row ids to callback functions when the user selects rows.

By default, this property is set to "full".

Paginated tables only support row selections by id. If you're using a paginated table, you'll need to set this property to "id" to enable row selections. If not, Compose will disable row selections for the paginated table.


properties.minSelections

optional number

Validate that the user has selected at least some number of rows. Defaults to 0.


properties.maxSelections

optional number

Validate that the user has selected at most some number of rows. Defaults to 1000000000.


properties.searchable

optional booleanSDK v0.24.0+

Whether to show the search input. Defaults to true for normal tables, false for paginated tables.


properties.sortable

optional booleanSDK v0.27.0+

Whether to allow sorting. Defaults to true for normal tables, false for paginated tables.

Options:

  • true: Allow multi-column sorting.
  • "single": Allow single-column sorting.
  • false: Disable sorting.

properties.filterable

optional booleanSDK v0.27.0+

Whether to allow column filtering. Defaults to true for normal tables, false for paginated tables.


properties.paginate

optional booleanSDK v0.26.7+

Whether to paginate the table server-side. Defaults to false.

Tables with more than 2500 rows will be paginated by default.


properties.density

optional string literalSDK v0.27.0+

The density of the table rows. Defaults to standard.

Options:

  • "compact": 32px row height
  • "standard": 40px row height
  • "comfortable": 48px row height

properties.overflow

optional string literalSDK v0.27.0+

The overflow behavior of the table cells. Defaults to ellipsis.

Options:

  • "ellipsis": The content will be truncated with an ellipsis.
  • "clip": The content will be clipped to the cell.
  • "dynamic": The cell height will grow to fit the content.

properties.style

optional Style

Directly style the underlying element using CSS. See the styling guide for more details on available style properties.