packages/features/data-table/GUIDE.md
A comprehensive guide to using Cal.com's DataTable system for building powerful, filterable, and paginated data tables.
The DataTable system is a comprehensive solution for displaying tabular data with advanced features including:
The system consists of three main layers:
┌─────────────────────────────────────────────────────────────────┐
│ DataTableProvider │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Table State │ │ Filter State │ │ Segment State │ │
│ │ • pagination │ │ • columnFilters │ │ • activeSegment │ │
│ │ • sorting │ │ • searchTerm │ │ • userSegments │ │
│ │ • columnVis │ │ • activeFilters │ │ • systemSegments│ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ DataTableWrapper │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Toolbar │ │ Data Table │ │ Pagination │ │
│ │ • SearchBar │ │ • Columns │ │ • PageControls │ │
│ │ • FilterBar │ │ • Rows │ │ • PageSize │ │
│ │ • Segments │ │ • Selection │ │ • TotalCount │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Backend API │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Filter Processing│ │ Data Fetching │ │ Response Format │ │
│ │ • makeWhereClause│ │ • Prisma Query │ │ • data[] │ │
│ │ • makeSqlCondition│ │ • Raw SQL │ │ • totalCount │ │
│ │ • Column Mapping │ │ • Pagination │ │ • meta │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
DataTableProvider (Context)
└── DataTableWrapper (UI Container)
├── Toolbar
│ ├── ToolbarLeft
│ │ ├── DataTableToolbar.SearchBar
│ │ ├── DataTableFilters.FilterBar
│ │ └── DataTableFilters.ColumnVisibilityButton
│ └── ToolbarRight
│ ├── DataTableFilters.ClearFiltersButton
│ ├── DataTableSegment.SaveButton
│ └── DataTableSegment.Select
├── DataTable (Core Table)
│ ├── Table Header
│ ├── Table Body (Virtualized/Standard)
│ └── Selection Bar (Conditional)
└── Pagination Controls
├── Page Navigation
├── Page Size Selector
└── Total Count Display
// Types and utilities stay in @calcom/features/data-table
import { ColumnFilterType } from "@calcom/features/data-table";
// Hooks, contexts, and providers are in apps/web/modules/data-table
// (use ~/data-table/... imports within apps/web)
import { DataTableProvider } from "~/data-table/DataTableProvider";
import { useDataTable } from "~/data-table/hooks/useDataTable";
// UI components are in apps/web/modules/data-table/components
import { DataTableWrapper } from "~/data-table/components/DataTableWrapper";
import { DataTableFilters } from "~/data-table/components/filters";
// 1. Define your data type
type User = {
id: number;
name: string;
email: string;
role: string;
};
// 2. Create columns with filtering support
const columns = [
{
id: "name",
header: "Name",
accessorKey: "name",
meta: {
type: ColumnFilterType.TEXT,
},
},
{
id: "role",
header: "Role",
accessorKey: "role",
meta: {
type: ColumnFilterType.SINGLE_SELECT,
},
},
];
// 3. Setup the table
function UserTable() {
const table = useReactTable({
data: users,
columns,
// ... other table options
});
const pathname = usePathname();
const tableIdentifier = "hard-coded idenfidier"; // or pathname;
return (
<DataTableProvider tableIdentifier={tableIdentifier}>
<DataTableWrapper
table={table}
paginationMode="standard"
ToolbarLeft={
<>
<DataTableToolbar.SearchBar />
<DataTableFilters.FilterBar table={table} />
</>
}
ToolbarRight={
<>
<DataTableFilters.ClearFiltersButton />
<DataTableSegment.SaveButton />
<DataTableSegment.Select />
</>
}
/>
</DataTableProvider>
);
}
The context provider that manages all table state including filters, sorting, pagination, and segments.
interface DataTableProviderProps {
tableIdentifier: string; // Unique identifier for the table (throws if empty)
children: React.ReactNode;
useSegments?: UseSegments; // Custom segment hook
defaultPageSize?: number; // Default: 10
ctaContainerClassName?: string; // CSS class for CTA container
segments?: FilterSegmentOutput[]; // Provided segments
timeZone?: string; // Timezone for date filters
preferredSegmentId?: SegmentIdentifier | null;
systemSegments?: SystemFilterSegment[];
}
The provider exposes comprehensive state management:
type DataTableContextType = {
// Filters
activeFilters: ActiveFilters;
addFilter: (columnId: string) => void;
updateFilter: (columnId: string, value: FilterValue) => void;
removeFilter: (columnId: string) => void;
clearAll: (exclude?: string[]) => void;
// Sorting
sorting: SortingState;
setSorting: OnChangeFn<SortingState>;
// Column management
columnVisibility: VisibilityState;
setColumnVisibility: OnChangeFn<VisibilityState>;
columnSizing: ColumnSizingState;
setColumnSizing: OnChangeFn<ColumnSizingState>;
// Pagination
pageIndex: number;
pageSize: number;
setPageIndex: (pageIndex: number | null) => void;
setPageSize: (pageSize: number | null) => void;
offset: number;
limit: number;
// Segments
segments: CombinedFilterSegment[];
selectedSegment: CombinedFilterSegment | undefined;
segmentId: SegmentIdentifier | null;
setSegmentId: (id: SegmentIdentifier | null) => void;
canSaveSegment: boolean;
isSegmentEnabled: boolean;
// Search
searchTerm: string;
setSearchTerm: (searchTerm: string | null) => void;
};
The main wrapper component that handles UI concerns, pagination, and toolbar layout.
type DataTableWrapperProps<TData> = {
table: ReactTableType<TData>;
testId?: string;
bodyTestId?: string;
isPending?: boolean;
totalRowCount?: number;
variant?: "default" | "compact";
className?: string;
containerClassName?: string;
headerClassName?: string;
rowClassName?: string;
children?: React.ReactNode;
tableContainerRef?: React.RefObject<HTMLDivElement>;
onRowMouseclick?: (row: Row<TData>) => void;
// Toolbar slots
ToolbarLeft?: React.ReactNode;
ToolbarRight?: React.ReactNode;
// Loading states
EmptyView?: React.ReactNode;
LoaderView?: React.ReactNode;
} & ( // Infinite pagination
| {
paginationMode: "infinite";
hasNextPage: boolean;
fetchNextPage: () => void;
isFetching: boolean;
}
// Standard pagination
| {
paginationMode: "standard";
hasNextPage?: never;
fetchNextPage?: never;
isFetching?: never;
}
);
The core table component with column resizing and pinning support.
The DataTable supports separator rows that act as visual group headers between regular data rows. Separators are useful for grouping related data and providing visual organization.
export type SeparatorRow = {
type: "separator";
label: string;
className?: string;
};
export type DataTableRow<TData> = TData | SeparatorRow;
export function isSeparatorRow<TData>(row: DataTableRow<TData>): row is SeparatorRow {
return typeof row === "object" && row !== null && "type" in row && row.type === "separator";
}
Separator rows are defined in your data array alongside regular rows:
const data = [
{ type: "separator", label: "Active Users" },
{ id: 1, name: "Alice", status: "active" },
{ id: 2, name: "Bob", status: "active" },
{ type: "separator", label: "Inactive Users" },
{ id: 3, name: "Charlie", status: "inactive" },
];
<DataTableWrapper
table={table}
hideSeparatorsOnSort={true}
separatorClassName="custom-separator"
// ... other props
/>;
hideSeparatorsOnSort?: boolean (default: true) - Hide separators when sorting is activehideSeparatorsOnFilter?: boolean (default: false) - Hide separators when filtering is activeseparatorClassName?: string - Global CSS class applied to all separator rowsEach separator can have its own styling:
const data = [
{
type: "separator",
label: "Active Users",
className: "bg-green-50 text-green-900",
},
{ id: 1, name: "Alice" },
{
type: "separator",
label: "Inactive Users",
className: "bg-red-50 text-red-900",
},
{ id: 2, name: "Bob" },
];
Default Behavior:
hideSeparatorsOnSort is true)Why Hide on Sort? When users sort by a column, the logical grouping may no longer make sense. For example, if you group by status but then sort by name, the status groups become meaningless. Hiding separators during sorting provides a cleaner, more intuitive experience.
Custom Behavior:
You can disable automatic hiding by setting hideSeparatorsOnSort={false} and handle separator positioning in your data preparation logic.
Separator rows use the same styling as table headers and span the full width of the table:
.separator-row {
display: flex;
width: 100%;
border: none;
}
.separator-row > div {
background-color: var(--muted);
font-weight: 600;
color: var(--emphasis);
padding: 0.5rem 0.75rem;
}
You can customize styling using:
separatorClassName prop for global stylingclassName property on individual separator objects.separator-row classfunction UserTable() {
const data = useMemo(() => {
const users = getUsers();
const groupedUsers = [];
// Add active users group
const activeUsers = users.filter((u) => u.status === "active");
if (activeUsers.length > 0) {
groupedUsers.push({ type: "separator", label: "Active Users" });
groupedUsers.push(...activeUsers);
}
// Add inactive users group
const inactiveUsers = users.filter((u) => u.status === "inactive");
if (inactiveUsers.length > 0) {
groupedUsers.push({ type: "separator", label: "Inactive Users" });
groupedUsers.push(...inactiveUsers);
}
return groupedUsers;
}, [users]);
return (
<DataTableWrapper
table={table}
hideSeparatorsOnSort={true}
separatorClassName="uppercase text-xs tracking-wide"
/>
);
}
The DataTable supports 5 filter types with various operators and options.
{
type: ColumnFilterType.SINGLE_SELECT,
options: [
{ label: "Admin", value: "admin" },
{ label: "User", value: "user" },
{ label: "Guest", value: "guest" },
]
}
{
type: ColumnFilterType.MULTI_SELECT,
options: [
{ label: "Engineering", value: "eng", section: "Departments" },
{ label: "Marketing", value: "marketing", section: "Departments" },
{ label: "Sales", value: "sales", section: "Departments" },
]
}
{
type: ColumnFilterType.TEXT,
textOptions: {
allowedOperators: ["contains", "equals", "startsWith"],
placeholder: "Search names..."
}
}
Available operators:
equals - Exact matchnotEquals - Not equalcontains - Contains substringnotContains - Does not containstartsWith - Starts withendsWith - Ends withisEmpty - Is emptyisNotEmpty - Is not empty{
type: ColumnFilterType.NUMBER
}
Available operators:
eq - Equal toneq - Not equal togt - Greater thangte - Greater than or equallt - Less thanlte - Less than or equal{
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
range: "past", // or "custom"
convertToTimeZone: true
}
}
Add filtering to columns using the meta property:
const columns = [
{
id: "status",
header: "Status",
accessorKey: "status",
meta: {
type: ColumnFilterType.SINGLE_SELECT,
icon: "circle-dot", // Optional icon
} as ColumnFilterMeta,
},
{
id: "createdAt",
header: "Created",
accessorKey: "createdAt",
meta: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
range: "past",
convertToTimeZone: true,
},
} as ColumnFilterMeta,
},
];
getFacetedUniqueValuesFor select filters that need dynamic options based on data, use getFacetedUniqueValues in your table configuration:
// In your table configuration
const table = useReactTable({
// ... other options
getFacetedUniqueValues: (_, columnId) => () => {
switch (columnId) {
case "teamId":
return convertFacetedValuesToMap(
teams.map((team) => ({
label: team.name,
value: team.id,
}))
);
case "role":
return convertFacetedValuesToMap([
{ label: "Admin", value: "admin" },
{ label: "Member", value: "member" },
]);
default:
return new Map();
}
},
});
Custom Faceted Values Hook Example:
From apps/web/modules/bookings/hooks/useFacetedUniqueValues.ts (hooks live in apps/web/modules/):
export function useFacetedUniqueValues() {
const eventTypes = useEventTypes();
const { data: teams } = trpc.viewer.teams.list.useQuery();
const { data: members } = trpc.viewer.teams.listSimpleMembers.useQuery();
return useCallback(
(_: Table<any>, columnId: string) => (): Map<FacetedValue, number> => {
if (columnId === "eventTypeId") {
return convertFacetedValuesToMap(eventTypes || []);
} else if (columnId === "teamId") {
return convertFacetedValuesToMap(
(teams || []).map((team) => ({
label: team.name,
value: team.id,
}))
);
} else if (columnId === "userId") {
return convertFacetedValuesToMap(
(members || [])
.map((member) => ({
label: member.name,
value: member.id,
}))
.filter((option): option is { label: string; value: number } => Boolean(option.label))
);
}
return new Map<FacetedValue, number>();
},
[eventTypes, teams, members]
);
}
Usage in Table Configuration:
// From apps/web/modules/users/components/UserTable/UserListTable.tsx
const table = useReactTable({
// ... other options
getFacetedUniqueValues: (_, columnId) => () => {
if (facetedTeamValues) {
switch (columnId) {
case "role":
return convertFacetedValuesToMap(facetedTeamValues.roles);
case "teamId":
return convertFacetedValuesToMap(facetedTeamValues.teams);
default:
return new Map();
}
}
return new Map();
},
});
Displays active filters and add filter button:
<DataTableFilters.FilterBar table={table} />
// Add filter button
<DataTableFilters.AddFilterButton table={table} />
// Active filters display
<DataTableFilters.ActiveFilters table={table} />
// Clear all filters
<DataTableFilters.ClearFiltersButton />
// Column visibility toggle
<DataTableFilters.ColumnVisibilityButton table={table} />
Segments allow users to save and share filter configurations. There are two types:
Important: Filter segments are only enabled when you pass the useSegments prop to DataTableProvider:
<DataTableProvider
useSegments={useSegments} // Required to enable segments
>
</DataTableProvider>
Without the useSegments prop, segment functionality will be disabled and segment-related components will not be available.
Predefined segments created by developers:
const systemSegments: SystemFilterSegment[] = [
{
id: "active-users",
name: "Active Users",
type: "system",
activeFilters: [
{
f: "status",
v: {
type: ColumnFilterType.SINGLE_SELECT,
data: "active",
},
},
],
sorting: [{ id: "lastLogin", desc: true }],
},
];
<DataTableProvider systemSegments={systemSegments}></DataTableProvider>;
Segments saved by users with personal or team scope:
// Personal segment (scope: "USER")
// Team segment (scope: "TEAM")
// Segment selector dropdown
<DataTableSegment.Select />
// Save current state as segment
<DataTableSegment.SaveButton />
// Segment management (rename, duplicate, delete)
// Available in the segment dropdown submenu
Traditional page-based pagination is the recommended approach:
<DataTableWrapper paginationMode="standard" totalRowCount={totalCount} table={table} />
Features:
Why Standard Mode is Preferred: Standard pagination provides a more predictable and stable user experience. It avoids the complexity and potential issues associated with virtualized infinite scrolling.
Alternative infinite scroll mode with known limitations:
<DataTableWrapper
paginationMode="infinite"
hasNextPage={hasNextPage}
fetchNextPage={fetchNextPage}
isFetching={isFetching}
table={table}
/>
Features:
Known Issues:
Container and utility components for table toolbars:
// Toolbar container
<DataTableToolbar.Root>
<DataTableToolbar.SearchBar />
<DataTableToolbar.CTA color="primary" StartIcon="plus">
Add User
</DataTableToolbar.CTA>
</DataTableToolbar.Root>
// Search input with debounced updates
<DataTableToolbar.SearchBar className="max-w-48" />
// Clear filters button (auto-hides when no filters)
<DataTableToolbar.ClearFiltersButton />
// Custom action button
<DataTableToolbar.CTA
color="secondary"
StartIcon="download"
onClick={handleExport}
>
Export
</DataTableToolbar.CTA>
For bulk actions when rows are selected:
{
numberOfSelectedRows > 0 && (
<DataTableSelectionBar.Root>
<p>{t("number_selected", { count: numberOfSelectedRows })}</p>
<DataTableSelectionBar.Button color="destructive" icon="trash-2" onClick={handleBulkDelete}>
Delete Selected
</DataTableSelectionBar.Button>
</DataTableSelectionBar.Root>
);
}
The DataTable system is designed for server-side filtering, sorting, and pagination with standard pagination. This approach is necessary because we only fetch a limited number of items per page, unlike the previous infinite scrolling approach that cached large amounts of data and could filter on the client side.
// Hooks are imported from ~/data-table/hooks/ within apps/web
import { useDataTable } from "~/data-table/hooks/useDataTable";
import { useColumnFilters } from "~/data-table/hooks/useColumnFilters";
// Get current table state for API calls
const { limit, offset, sorting } = useDataTable();
const columnFilters = useColumnFilters();
// Use in your API call
const { data } = trpc.users.list.useQuery({
limit,
offset,
sorting,
filters: columnFilters,
});
For Prisma-based backends, extract individual filters and build typed where conditions:
// From packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts
const roleFilter = filters.find((filter) => filter.id === "role") as
| TypedColumnFilter<ColumnFilterType.MULTI_SELECT>
| undefined;
const teamFilter = filters.find((filter) => filter.id === "teams") as
| TypedColumnFilter<ColumnFilterType.MULTI_SELECT>
| undefined;
const lastActiveAtFilter = filters.find((filter) => filter.id === "lastActiveAt") as
| TypedColumnFilter<ColumnFilterType.DATE_RANGE>
| undefined;
const whereClause: Prisma.MembershipWhereInput = {
user: {
...(teamFilter && {
teams: {
some: {
team: makeWhereClause({
columnName: "name",
filterValue: teamFilter.value,
}),
},
},
}),
...(lastActiveAtFilter &&
makeWhereClause({
columnName: "lastActiveAt",
filterValue: lastActiveAtFilter.value,
})),
},
teamId: organizationId,
...(roleFilter &&
makeWhereClause({
columnName: "role",
filterValue: roleFilter.value,
})),
};
For performance-critical queries, use raw SQL with makeSqlCondition:
// From packages/lib/server/service/InsightsRoutingBaseService.ts
async getFilterConditions(): Promise<Prisma.Sql | null> {
const conditions: Prisma.Sql[] = [];
const columnFilters = this.filters.columnFilters || [];
// Convert columnFilters array to object for easier access
const filtersMap = columnFilters.reduce((acc, filter) => {
acc[filter.id] = filter;
return acc;
}, {} as Record<string, TypedColumnFilter<ColumnFilterType>>);
// Extract booking status order filter
const bookingStatusOrder = filtersMap["bookingStatusOrder"];
if (bookingStatusOrder && isMultiSelectFilterValue(bookingStatusOrder.value)) {
const statusCondition = makeSqlCondition(bookingStatusOrder.value);
if (statusCondition) {
conditions.push(Prisma.sql`rfrd."bookingStatusOrder" ${statusCondition}`);
}
}
// Extract booking UID filter
const bookingUid = filtersMap["bookingUid"];
if (bookingUid && isTextFilterValue(bookingUid.value)) {
const uidCondition = makeSqlCondition(bookingUid.value);
if (uidCondition) {
conditions.push(Prisma.sql`rfrd."bookingUid" ${uidCondition}`);
}
}
// Join all conditions with AND
return conditions.reduce((acc, condition, index) => {
if (index === 0) return condition;
return Prisma.sql`(${acc}) AND (${condition})`;
});
}
For complex cases where you need to manipulate filter data before sending to the backend, extract the logic into a separate hook:
// apps/web/modules/insights/hooks/useInsightsRoutingParameters.ts
export function useInsightsRoutingParameters() {
const { scope, selectedTeamId } = useInsightsOrgTeams();
// Get date range filter and manipulate it
const createdAtRange = useFilterValue("createdAt", ZDateRangeFilterValue)?.data;
const startDate = useChangeTimeZoneWithPreservedLocalTime(
useMemo(() => {
return dayjs(createdAtRange?.startDate ?? getDefaultStartDate().toISOString())
.startOf("day")
.toISOString();
}, [createdAtRange?.startDate])
);
// Get other column filters excluding the manipulated ones
const columnFilters = useColumnFilters({
exclude: ["createdAt"],
});
return {
scope,
selectedTeamId,
startDate,
endDate,
columnFilters,
};
}
useColumnFilters() - Get applied filters for backend requests (import from ~/data-table/hooks/useColumnFilters)useDataTable() - Get limit, offset, sorting for pagination (import from ~/data-table/hooks/useDataTable)useFilterValue(columnId, schema) - Get specific filter value with validation (import from ~/data-table/hooks/useFilterValue)apps/web/modules/)The DataTable system provides utility functions for both Prisma and raw SQL approaches:
makeWhereClause() - Converts filter values to Prisma where clause objectsmakeSqlCondition() - Converts filter values to raw SQL conditionsmakeOrderBy() - Converts sorting state to Prisma orderBy formatAccess the DataTable context:
const { activeFilters, sorting, columnVisibility, pageIndex, pageSize, searchTerm, selectedSegment } =
useDataTable();
Get processed column filters for API calls:
const columnFilters = useColumnFilters();
// Returns: ColumnFilter[] ready for backend consumption
// With exclusions (useful when manipulating specific filters)
const columnFilters = useColumnFilters({
exclude: ["createdAt", "dateRange"]
});
Extract filterable columns from table definition:
const filterableColumns = useFilterableColumns(table);
Create custom filter implementations:
function CustomStatusFilter({ column }: { column: Column<any> }) {
const { updateFilter } = useDataTable();
return (
<Select
onValueChange={(value) =>
updateFilter(column.id, {
type: ColumnFilterType.SINGLE_SELECT,
data: value,
})
}>
</Select>
);
}
Use portals for toolbar actions:
const { ctaContainerRef } = useDataTable();
{
ctaContainerRef.current &&
createPortal(
<div className="flex gap-2">
<Button>Custom Action</Button>
</div>,
ctaContainerRef.current
);
}
From apps/web/modules/users/components/UserTable/UserListTable.tsx:
<DataTableWrapper<UserTableUser>
testId="user-list-data-table"
table={table}
isPending={isPending}
totalRowCount={data?.meta?.totalRowCount}
paginationMode="standard"
ToolbarLeft={
<>
<DataTableToolbar.SearchBar />
<DataTableFilters.ColumnVisibilityButton table={table} />
<DataTableFilters.FilterBar table={table} />
</>
}
ToolbarRight={
<>
<DataTableFilters.ClearFiltersButton />
<DataTableSegment.SaveButton />
<DataTableSegment.Select />
</>
}>
{numberOfSelectedRows > 0 && (
<DataTableSelectionBar.Root>
<p>{t("number_selected", { count: numberOfSelectedRows })}</p>
<DeleteBulkUsers
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
onRemove={() => table.toggleAllPageRowsSelected(false)}
/>
</DataTableSelectionBar.Root>
)}
</DataTableWrapper>
From apps/web/modules/bookings/components/BookingsList.tsx (hooks/providers imported from ~/data-table/):
<DataTableWrapper
className="mb-6"
tableContainerRef={tableContainerRef}
table={table}
testId={`${status}-bookings`}
bodyTestId="bookings"
headerClassName="hidden"
isPending={query.isPending}
totalRowCount={query.data?.totalCount}
variant="compact"
paginationMode="standard"
ToolbarLeft={<DataTableFilters.FilterBar table={table} />}
ToolbarRight={
<>
<DataTableFilters.ClearFiltersButton />
<DataTableSegment.SaveButton />
<DataTableSegment.Select />
</>
}
LoaderView={<SkeletonLoader />}
EmptyView={
<EmptyScreen
Icon="calendar"
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
description={t("no_status_bookings_yet_description")}
/>
}
/>
From apps/web/modules/ee/teams/components/MemberList.tsx:
<DataTableWrapper
testId="team-member-list-container"
table={table}
tableContainerRef={tableContainerRef}
isPending={isPending}
paginationMode="infinite"
hasNextPage={hasNextPage}
fetchNextPage={fetchNextPage}
isFetching={isFetching}
ToolbarLeft={
<>
<DataTableToolbar.SearchBar />
<DataTableFilters.FilterBar table={table} />
</>
}
ToolbarRight={
<>
<DataTableFilters.ClearFiltersButton />
<DataTableSegment.SaveButton />
<DataTableSegment.Select />
</>
}>
{numberOfSelectedRows > 0 && (
<DataTableSelectionBar.Root>
<TeamListBulkAction table={table} />
<MassAssignAttributesBulkAction table={table} filters={columnFilters} />
</DataTableSelectionBar.Root>
)}
</DataTableWrapper>
// Filter value types
type FilterValue =
| SingleSelectFilterValue
| MultiSelectFilterValue
| TextFilterValue
| NumberFilterValue
| DateRangeFilterValue;
// Active filter structure
type ActiveFilter = {
f: string; // field/column ID
v?: FilterValue; // filter value
};
// Segment types
type SegmentIdentifier = { id: string; type: "system" } | { id: number; type: "user" };
// Column filter metadata
type ColumnFilterMeta = {
type: ColumnFilterType;
icon?: IconName;
dateRangeOptions?: DateRangeFilterOptions;
textOptions?: TextFilterOptions;
};
// Single select
type SingleSelectFilterValue = {
type: ColumnFilterType.SINGLE_SELECT;
data: string | number;
};
// Multi select
type MultiSelectFilterValue = {
type: ColumnFilterType.MULTI_SELECT;
data: Array<string | number>;
};
// Text filter
type TextFilterValue = {
type: ColumnFilterType.TEXT;
data: {
operator: TextFilterOperator;
operand: string;
};
};
// Number filter
type NumberFilterValue = {
type: ColumnFilterType.NUMBER;
data: {
operator: NumberFilterOperator;
operand: number;
};
};
// Date range filter
type DateRangeFilterValue = {
type: ColumnFilterType.DATE_RANGE;
data: {
startDate: string | null;
endDate: string | null;
preset: string;
};
};
// System segment (predefined)
type SystemFilterSegment = {
id: string;
name: string;
type: "system";
activeFilters: ActiveFilters;
sorting?: SortingState;
columnVisibility?: Record<string, boolean>;
columnSizing?: Record<string, number>;
perPage?: number;
searchTerm?: string | null;
};
// User segment (saved by users)
type UserFilterSegment = FilterSegmentOutput & {
type: "user";
};
// Combined segment type
type CombinedFilterSegment = SystemFilterSegmentInternal | UserFilterSegment;
// ✅ Good: Memoize column definitions
const columns = useMemo(
() => [
{
id: "name",
header: "Name",
accessorKey: "name",
meta: { type: ColumnFilterType.TEXT },
},
],
[]
);
// ✅ Good: Extract filter logic
const useUserFilters = () => {
const columnFilters = useColumnFilters();
return useMemo(() => transformFiltersForAPI(columnFilters), [columnFilters]);
};
// ✅ Good: Separate concerns
function UserTableContainer() {
const filters = useUserFilters();
const { data, isPending } = useUsers(filters);
return (
<DataTableProvider>
<UserTable data={data} isPending={isPending} />
</DataTableProvider>
);
}
function useTableData() {
const columnFilters = useColumnFilters();
const { sorting, pageIndex, pageSize, searchTerm } = useDataTable();
const queryParams = useMemo(
() => ({
filters: columnFilters,
sorting,
page: pageIndex,
limit: pageSize,
search: searchTerm,
}),
[columnFilters, sorting, pageIndex, pageSize, searchTerm]
);
return useQuery({
queryKey: ["table-data", queryParams],
queryFn: () => fetchData(queryParams),
});
}
function useStatusFilterOptions() {
const { data: statuses } = useStatuses();
return useMemo(
() =>
statuses?.map((status) => ({
label: status.name,
value: status.id,
section: status.category,
})) || [],
[statuses]
);
}
const BOOKING_SEGMENTS: SystemFilterSegment[] = [
{
id: "upcoming",
name: "Upcoming",
type: "system",
activeFilters: [
{
f: "status",
v: { type: ColumnFilterType.SINGLE_SELECT, data: "confirmed" },
},
{
f: "startTime",
v: {
type: ColumnFilterType.DATE_RANGE,
data: { preset: "future", startDate: null, endDate: null },
},
},
],
sorting: [{ id: "startTime", desc: false }],
},
];
This guide covers the complete DataTable system. For specific implementation details, refer to:
packages/features/data-table/lib/apps/web/modules/data-table/apps/web/modules/data-table/components/apps/web/modules/ (bookings, insights, users, etc.)