Fusion UI LogoFusion UI Logo
Fusion UI LogoFusion UI Logo
/
Getting Started
Concepts
Components
Example Apps
Getting Started
Concepts
Components
Example Apps
Fusion UI LogoFusion UI Logo
Getting Started
Concepts
Components
Example Apps
Overview
Actions
Button
Button Group
Segmented Button
Deprecated
Split Button
Toggle Button
Click To Copy
Toolbar
Navigation
Main Nav
Side Menu
Page Header
Tabs
Breadcrumbs
On-Page Menu
Link
Command Menu
Application Switcher
Context Switcher
Account Menu
Menu Bar
Deprecated
Inputs & Forms
Form
Form Actions
Deprecated
Form Divider
Form Field
Form Repeat
Form Spacing
Input
Prefix & Suffix
Rich Text Input
Autocomplete
Select
Date / Time Input
Calendar
Slider
Selection
Checkbox
Radio
Switch
Switch Select
Chip
Chip Choice
Chip Filter
Chip Input
Chip Sortable
Visual Select
Pricing Plan Select
Feedback & Loading
Banner
Inline Message
Snackbar
Inline Snackbar
Loading Spinner
Skeleton
Text Skeleton
Contextual Information
Badge
Status
Tag
Tooltip
Info
Hotkey
Icon
Icon Sizer
Containers & Overlays
Tile
Tile Content
Tile Footer
Tile Header
Annotated Section
Modal
Sheet
Popover
Context Menu
Data Display
Data Table
List
Timeline
Data Visualization
KPI Card
Ratio Card
Bar Chart
Line Chart
Pie Chart
Heatmap Calendar
Layout
Container
Stack Layout
Flow Layout
Internals
Blanket
Expansion Panel
Menu List Base
Menu Group
Search Bar
Components
...
Data Table

Data Table

Sortable, filterable and selectable table for large record sets.

Overview

DataTable shows records in rows and columns: searchable, sortable, filterable and selectable. Rows are virtualized, so large data sets stay fast.

Name
Gender
Age
City
"use client";
import {
Button,
ButtonGroup,
ColumnConfig,
DataTable,
DataTableChip,
IconAdd,
IconArrowDown,
IconArrowUp,
IconFileCopy,
IconEdit,
IconMore2Alt,
IconDeleteBin5,
Link,
LinkProps,
Menu,
RowConfig,
SimpleTableOptions,
useSimpleTable,
useToolbar,
} from "@brynlabs/fusion-ui";
import React, {ReactNode, useCallback, useMemo, useState} from "react";
export const extractTextFromElement = (obj: ReactNode): string => {
if (typeof obj === "string") return obj;
if (React.isValidElement(obj)) {
// @ts-expect-error children are allowed to be undefined
return extractTextFromElement(obj.props.children);
}
if (Array.isArray(obj)) {
return obj.map(e => extractTextFromElement(e)).join(" ");
}
return "";
};
export type User = {
name: string | React.ReactElement<LinkProps>;
gender: string;
age: string | React.ReactElement<LinkProps>;
birthday: string;
height: string;
weight: string;
eyeColor: string;
city: string | React.ReactElement<LinkProps>;
origin: string;
marriageStatus: string;
alive: "Alive" | "Deceased";
};
type DataTableTestWrapperProps = {
withMaleFilterButton?: boolean;
};
const data: User[] = [
{
name: "Max Müller",
gender: "Male",
age: "29",
birthday: "1996-03-14",
height: "182",
weight: "78",
eyeColor: "Blue",
city: "Berlin",
origin: "German",
marriageStatus: "Single",
alive: "Alive",
},
{
name: "Sophia Schmidt",
gender: "Female",
age: "24",
birthday: "2001-07-22",
height: "168",
weight: "60",
eyeColor: "Green",
city: "Berlin",
origin: "German",
marriageStatus: "Single",
alive: "Alive",
},
{
name: "Emma Johnson",
gender: "Female",
age: "31",
birthday: "1994-11-30",
height: "170",
weight: "65",
eyeColor: "Brown",
city: (
<Link
key="newyork"
link={{title: "New York City", href: "https://en.wikipedia.org/wiki/New_York_City"}}
linkStyle="secondary">
New York
</Link>
),
origin: "American",
marriageStatus: "Married",
alive: "Alive",
},
{
name: "Luca Rossi",
gender: "Male",
age: "27",
birthday: "1998-05-18",
height: "176",
weight: "72",
eyeColor: "Brown",
city: "Rome",
origin: "Italian",
marriageStatus: "Widowed",
alive: "Alive",
},
{
name: "Marie Dubois",
gender: "Female",
age: "63",
birthday: "1942-09-12",
height: "165",
weight: "58",
eyeColor: "Blue",
city: (
<Link
key="france"
link={{title: "France", href: "https://en.wikipedia.org/wiki/France"}}
linkStyle="secondary">
French
</Link>
),
origin: "French",
marriageStatus: "Married",
alive: "Deceased",
},
{
name: (
<Link
key="jamesanderson"
link={{
title: "James Anderson",
href: "https://en.wikipedia.org/wiki/James_Anderson_(cricketer)",
}}
linkStyle="secondary">
James Anderson
</Link>
),
gender: "Male",
age: "35",
birthday: "1990-01-09",
height: "185",
weight: "85",
eyeColor: "Green",
city: "New York",
origin: "American",
marriageStatus: "Divorced",
alive: "Alive",
},
{
name: "Anna Kowalski",
gender: "Female",
age: "29",
birthday: "1996-06-25",
height: "172",
weight: "63",
eyeColor: "Hazel",
city: "Berlin",
origin: "Polish",
marriageStatus: "Single",
alive: "Alive",
},
{
name: "Carlos García",
gender: "Male",
age: "40",
birthday: "1985-02-03",
height: "180",
weight: "82",
eyeColor: "Brown",
city: "Madrid",
origin: "Spanish",
marriageStatus: "Married",
alive: "Alive",
},
{
name: "Emily Clark",
gender: "Female",
age: "26",
birthday: "1999-04-11",
height: "169",
weight: "59",
eyeColor: "Blue",
city: "Toronto",
origin: "Canadian",
marriageStatus: "Single",
alive: "Alive",
},
{
name: "Kenji Tanaka",
gender: "Male",
age: "32",
birthday: "1993-08-19",
height: "174",
weight: "68",
eyeColor: "Brown",
city: "Tokyo",
origin: "Japanese",
marriageStatus: "Married",
alive: "Alive",
},
{
name: "Max Müller",
gender: "Male",
age: (
<Link
key="24"
link={{title: "24", href: "https://en.wikipedia.org/wiki/24_(number)"}}
linkStyle="secondary">
24
</Link>
),
birthday: "2001-01-10",
height: "185",
weight: "85",
eyeColor: "Blue",
city: "Hamburg",
origin: "German",
marriageStatus: "Single",
alive: "Alive",
},
];
const usersToAdd: User[] = [
{
name: "Olivia Brown",
gender: "Female",
age: "28",
birthday: "1997-02-18",
height: "171",
weight: "62",
eyeColor: "Brown",
city: "London",
origin: "British",
marriageStatus: "Single",
alive: "Alive",
},
{
name: "Noah Wilson",
gender: "Male",
age: "34",
birthday: "1991-10-05",
height: "183",
weight: "81",
eyeColor: "Blue",
city: "Dublin",
origin: "Irish",
marriageStatus: "Married",
alive: "Alive",
},
{
name: "Mia Svensson",
gender: "Female",
age: "30",
birthday: "1995-06-12",
height: "167",
weight: "57",
eyeColor: "Green",
city: "Stockholm",
origin: "Swedish",
marriageStatus: "Single",
alive: "Alive",
},
{
name: "Ethan Novak",
gender: "Male",
age: "41",
birthday: "1984-09-27",
height: "179",
weight: "76",
eyeColor: "Hazel",
city: "Prague",
origin: "Czech",
marriageStatus: "Divorced",
alive: "Alive",
},
];
const modalText = {
errorMessages: {
noDataFound: "No data was found",
noDataFoundInSearch: "No searched results",
noDataFoundInFilter: "No filtered results",
noVisibleColumns: "No visible columns",
},
editView: {
title: "Adjust columns",
activeContainerTitle: "Visible Columns",
inactiveContainerTitle: "Hidden Columns",
activeContainerEmptyMessage: "No columns are visible",
inactiveContainerEmptyMessage: "No columns are hidden",
showAllButton: "Show all columns",
applyButton: "Apply",
abortViewText: {
title: "Discard changes?",
description:
"You have unsaved changes. If you continue without saving, these changes will be lost.",
confirmButton: "Discard",
cancelButton: "Cancel",
},
},
filterView: {
title: "Adjust Filters",
searchPlaceholder: "Type to search ...",
min: "Minimum",
max: "Maximum",
applyButton: "Apply",
resetButton: "Reset Filters",
abortViewText: {
title: "Discard changes?",
description:
"You have unsaved changes. If you continue without saving, these changes will be lost.",
confirmButton: "Discard",
cancelButton: "Cancel",
},
},
};
export const DataTableExample = ({withMaleFilterButton = false}: DataTableTestWrapperProps) => {
const [users, setUsers] = useState<User[]>(data);
const [nextMockUserIndex, setNextMockUserIndex] = useState(0);
const addUser = useCallback(() => {
const nextUser = usersToAdd[nextMockUserIndex % usersToAdd.length];
if (!nextUser) return;
setUsers(prev => [...prev, nextUser]);
setNextMockUserIndex(prev => prev + 1);
}, [nextMockUserIndex]);
const tableOptions = useMemo<SimpleTableOptions<User>>(() => {
const columns: ColumnConfig<User>[] = [
{key: "name", label: "Name", render: row => row.name},
{
key: "gender",
render: row => row.gender,
label: "Gender",
filter: {type: "multiSelect", getValue: row => row.gender},
},
{
key: "age",
render: row => row.age,
label: "Age",
sortFunction: (a, b) =>
Number(extractTextFromElement(a.age)) - Number(extractTextFromElement(b.age)),
filter: {
type: "range",
min: 0,
max: 100,
showTicks: true,
histogram: Array.from(
{length: 10},
(_, bucket) =>
users.filter(user => {
const age = Number(extractTextFromElement(user.age));
return Math.min(Math.floor(age / 10), 9) === bucket;
}).length,
),
getValue: row => Number(extractTextFromElement(row.age)),
},
},
{
key: "birthday",
render: row => row.birthday,
label: "Birthday",
filter: {
type: "custom",
filterFunction: (row, value) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return true;
const typedValue = value as {from?: string; to?: string};
if (typedValue.from && row.birthday < typedValue.from) return false;
return !(typedValue.to && row.birthday > typedValue.to);
},
},
},
{key: "height", render: row => row.height, label: "Height"},
{key: "weight", render: row => row.weight, label: "Weight"},
{
key: "eyeColor",
render: row => row.eyeColor,
label: "Eye Color",
filter: {type: "multiSelect", getValue: row => row.eyeColor},
},
{
key: "city",
render: row => row.city,
label: "City",
filter: {type: "multiSelect", getValue: row => extractTextFromElement(row.city)},
},
{
key: "origin",
render: row => row.origin,
label: "Origin",
filter: {type: "multiSelect", getValue: row => row.origin},
},
{
key: "marriageStatus",
render: row => row.marriageStatus,
label: "Marriage Status",
filter: {type: "multiSelect", getValue: row => row.marriageStatus},
},
{
key: "alive",
render: row => row.alive,
label: "Alive",
filter: {
type: "boolean",
trueLabel: "Alive",
falseLabel: "Deceased",
getValue: row => row.alive === "Alive",
},
},
];
return {
columns,
searchIndexRow: row => columns.map(column => extractTextFromElement(column.render(row))),
initialColumns: ["name", "gender", "age", "city"],
};
}, [users]);
const table = useSimpleTable<User>(users, tableOptions);
const deleteRow = useCallback((rowIndex: number) => {
setUsers(prev => prev.filter((_, index) => index !== rowIndex));
}, []);
const deleteSelectedRows = useCallback(() => {
setUsers(prev => prev.filter((_, index) => !table.selectionBehavior.selection[index]));
}, [table.selectionBehavior.selection]);
const selectedCount = table.selectionBehavior.selectionSize;
const getRowContextMenu = useCallback(
(rowIndex: number): Menu => [
{
items: [
{
title: "Duplicate",
icon: <IconFileCopy />,
callback: () => undefined,
},
{
title: "Download file",
icon: <IconArrowDown />,
callback: () => undefined,
},
{
title: "Delete",
icon: <IconDeleteBin5 />,
callback: () => deleteRow(rowIndex),
},
],
},
],
[deleteRow],
);
const rows: RowConfig<User>[] = useMemo(
() =>
table.rows.map(item => ({
...item,
buttons: [
{icon: <IconEdit />, action: () => undefined},
{icon: <IconMore2Alt />, contextMenu: getRowContextMenu(item.index)},
],
})),
[getRowContextMenu, table.rows],
);
const dataTableToolbarConfig = useMemo(
() => ({
segments: [
{
segment: [
{section: `${selectedCount} selected`, hasDivider: true},
{section: `Total: ${users.length}`},
],
},
{
segment: [
{
section: (
<ButtonGroup>
<Button
buttonStyle="ghost"
size="large"
color="neutral"
secondaryIcon={<IconArrowUp />}>
Status
</Button>
<Button
buttonStyle="ghost"
size="large"
color="neutral"
secondaryIcon={<IconFileCopy />}>
Duplicate
</Button>
<Button
buttonStyle="ghost"
size="large"
color="neutral"
secondaryIcon={<IconArrowDown />}>
Export
</Button>
</ButtonGroup>
),
hasDivider: true,
hasSidePadding: false,
},
{
section: (
<Button
buttonStyle="flat"
size="large"
color="critical"
primaryIcon={<IconDeleteBin5 />}
onClick={deleteSelectedRows}>
Delete
</Button>
),
hasSidePadding: false,
},
],
},
],
}),
[deleteSelectedRows, selectedCount, users.length],
);
const dataTableToolbar = useToolbar(dataTableToolbarConfig);
const columnsWithContextMenu: ColumnConfig<User>[] = useMemo(() => {
const hiddenCategories = table.columns.filter(
col => table.visibleColumns.findIndex(visible => col.key === visible) == -1,
);
return table.columns.map(column => ({
...column,
contextMenu: [
{
items: [
{
title: "Hide column",
callback: () =>
table.setVisibleColumns(prev => prev.filter(prevKey => prevKey !== column.key)),
},
{
icon: <IconAdd />,
title: "Show column to the right",
children:
hiddenCategories.length > 0
? hiddenCategories.map(category => ({
title: category.label,
callback: () =>
table.setVisibleColumns(prev => {
const clickedIndex = prev.findIndex(category => category === column.key);
if (clickedIndex === -1) return prev;
return [
...prev.slice(0, clickedIndex + 1),
category.key,
...prev.slice(clickedIndex + 1),
];
}),
}))
: [
{
title: "No hidden columns",
callback: () => undefined,
},
],
},
],
},
],
}));
}, [table]);
const handleRemoveFilterChip = useCallback(
(chip: DataTableChip) => {
const filterKey = chip.filterKey;
const column = table.columns.find(column => String(column.key) === filterKey);
if (!column?.filter) return;
const currentValue = table.filters.values[filterKey];
if (column.filter.type === "multiSelect") {
const nextValues = (Array.isArray(currentValue) ? currentValue : []).filter(
value => value !== chip.itemValue,
);
if (nextValues.length === 0) {
table.filters.clearFilterValue(filterKey);
} else {
table.filters.setFilterValue(filterKey, nextValues);
}
return;
}
table.filters.clearFilterValue(filterKey);
},
[table.filters, table.columns],
);
return (
<div
style={{
height: "500px",
maxWidth: "1200px",
}}>
{withMaleFilterButton && (
<div style={{display: "flex", gap: "8px"}}>
<Button
onClick={() => table.filters.setFilterValue("gender", ["Male"])}
buttonStyle="flat"
color="neutral">
Set Male Filter
</Button>
</div>
)}
<DataTable<User>
{...table}
onRowClick={(data, index) => console.log(data, index)}
columns={columnsWithContextMenu}
rows={rows}
onFilterChipRemove={handleRemoveFilterChip}
toolbar={dataTableToolbar}
modalText={modalText}
storageId={"data_table_example"}
buttons={
<Button onClick={addUser} buttonStyle="flat" color="primary" primaryIcon={<IconAdd />}>
Add User
</Button>
}
/>
</div>
);
};

Custom actions

Columns can hold action components instead of checkboxes, together with onRowClick.

Action
Name
Gender
Age
Birthday
Height
Weight
Eye Color
City
Origin
Marriage Status
Alive
"use client";
import {
Button,
ButtonGroup,
ColumnConfig,
DataTable,
DataTableChip,
IconAdd,
IconArrowDown,
IconArrowUp,
IconFileCopy,
IconEdit,
IconMore2Alt,
IconThumbDown,
IconThumbUp,
IconDeleteBin5,
Link,
LinkProps,
Menu,
RowConfig,
SimpleTableOptions,
SplitButton,
ToggleButton,
useSimpleTable,
useToolbar,
} from "@brynlabs/fusion-ui";
import React, {ReactNode, useCallback, useMemo, useState} from "react";
export const extractTextFromElement = (obj: ReactNode): string => {
if (typeof obj === "string") return obj;
if (React.isValidElement(obj)) {
// @ts-expect-error children are allowed to be undefined
return extractTextFromElement(obj.props.children);
}
if (Array.isArray(obj)) {
return obj.map(e => extractTextFromElement(e)).join(" ");
}
return "";
};
export type User = {
name: string | React.ReactElement<LinkProps>;
gender: string;
age: string | React.ReactElement<LinkProps>;
birthday: string;
height: string;
weight: string;
eyeColor: string;
city: string | React.ReactElement<LinkProps>;
origin: string;
marriageStatus: string;
alive: "Alive" | "Deceased";
};
type DataTableTestWrapperProps = {
withMaleFilterButton?: boolean;
};
const data: User[] = [
{
name: "Max Müller",
gender: "Male",
age: "29",
birthday: "1996-03-14",
height: "182",
weight: "78",
eyeColor: "Blue",
city: "Berlin",
origin: "German",
marriageStatus: "Single",
alive: "Alive",
},
{
name: "Sophia Schmidt",
gender: "Female",
age: "24",
birthday: "2001-07-22",
height: "168",
weight: "60",
eyeColor: "Green",
city: "Berlin",
origin: "German",
marriageStatus: "Single",
alive: "Alive",
},
{
name: "Emma Johnson",
gender: "Female",
age: "31",
birthday: "1994-11-30",
height: "170",
weight: "65",
eyeColor: "Brown",
city: (
<Link
key="newyork"
link={{title: "New York City", href: "https://en.wikipedia.org/wiki/New_York_City"}}
linkStyle="secondary">
New York
</Link>
),
origin: "American",
marriageStatus: "Married",
alive: "Alive",
},
{
name: "Luca Rossi",
gender: "Male",
age: "27",
birthday: "1998-05-18",
height: "176",
weight: "72",
eyeColor: "Brown",
city: "Rome",
origin: "Italian",
marriageStatus: "Widowed",
alive: "Alive",
},
{
name: "Marie Dubois",
gender: "Female",
age: "63",
birthday: "1942-09-12",
height: "165",
weight: "58",
eyeColor: "Blue",
city: (
<Link
key="france"
link={{title: "France", href: "https://en.wikipedia.org/wiki/France"}}
linkStyle="secondary">
French
</Link>
),
origin: "French",
marriageStatus: "Married",
alive: "Deceased",
},
{
name: (
<Link
key="jamesanderson"
link={{
title: "James Anderson",
href: "https://en.wikipedia.org/wiki/James_Anderson_(cricketer)",
}}
linkStyle="secondary">
James Anderson
</Link>
),
gender: "Male",
age: "35",
birthday: "1990-01-09",
height: "185",
weight: "85",
eyeColor: "Green",
city: "New York",
origin: "American",
marriageStatus: "Divorced",
alive: "Alive",
},
{
name: "Anna Kowalski",
gender: "Female",
age: "29",
birthday: "1996-06-25",
height: "172",
weight: "63",
eyeColor: "Hazel",
city: "Berlin",
origin: "Polish",
marriageStatus: "Single",
alive: "Alive",
},
{
name: "Carlos García",
gender: "Male",
age: "40",
birthday: "1985-02-03",
height: "180",
weight: "82",
eyeColor: "Brown",
city: "Madrid",
origin: "Spanish",
marriageStatus: "Married",
alive: "Alive",
},
{
name: "Emily Clark",
gender: "Female",
age: "26",
birthday: "1999-04-11",
height: "169",
weight: "59",
eyeColor: "Blue",
city: "Toronto",
origin: "Canadian",
marriageStatus: "Single",
alive: "Alive",
},
{
name: "Kenji Tanaka",
gender: "Male",
age: "32",
birthday: "1993-08-19",
height: "174",
weight: "68",
eyeColor: "Brown",
city: "Tokyo",
origin: "Japanese",
marriageStatus: "Married",
alive: "Alive",
},
{
name: "Max Müller",
gender: "Male",
age: (
<Link
key="24"
link={{title: "24", href: "https://en.wikipedia.org/wiki/24_(number)"}}
linkStyle="secondary">
24
</Link>
),
birthday: "2001-01-10",
height: "185",
weight: "85",
eyeColor: "Blue",
city: "Hamburg",
origin: "German",
marriageStatus: "Single",
alive: "Alive",
},
];
const usersToAdd: User[] = [
{
name: "Olivia Brown",
gender: "Female",
age: "28",
birthday: "1997-02-18",
height: "171",
weight: "62",
eyeColor: "Brown",
city: "London",
origin: "British",
marriageStatus: "Single",
alive: "Alive",
},
{
name: "Noah Wilson",
gender: "Male",
age: "34",
birthday: "1991-10-05",
height: "183",
weight: "81",
eyeColor: "Blue",
city: "Dublin",
origin: "Irish",
marriageStatus: "Married",
alive: "Alive",
},
{
name: "Mia Svensson",
gender: "Female",
age: "30",
birthday: "1995-06-12",
height: "167",
weight: "57",
eyeColor: "Green",
city: "Stockholm",
origin: "Swedish",
marriageStatus: "Single",
alive: "Alive",
},
{
name: "Ethan Novak",
gender: "Male",
age: "41",
birthday: "1984-09-27",
height: "179",
weight: "76",
eyeColor: "Hazel",
city: "Prague",
origin: "Czech",
marriageStatus: "Divorced",
alive: "Alive",
},
];
const modalText = {
errorMessages: {
noDataFound: "No data was found",
noDataFoundInSearch: "No searched results",
noDataFoundInFilter: "No filtered results",
noVisibleColumns: "No visible columns",
},
editView: {
title: "Adjust columns",
activeContainerTitle: "Visible Columns",
inactiveContainerTitle: "Hidden Columns",
activeContainerEmptyMessage: "No columns are visible",
inactiveContainerEmptyMessage: "No columns are hidden",
showAllButton: "Show all columns",
applyButton: "Apply",
abortViewText: {
title: "Discard changes?",
description:
"You have unsaved changes. If you continue without saving, these changes will be lost.",
confirmButton: "Discard",
cancelButton: "Cancel",
},
},
filterView: {
title: "Adjust Filters",
searchPlaceholder: "Type to search ...",
min: "Minimum",
max: "Maximum",
applyButton: "Apply",
resetButton: "Reset Filters",
abortViewText: {
title: "Discard changes?",
description:
"You have unsaved changes. If you continue without saving, these changes will be lost.",
confirmButton: "Discard",
cancelButton: "Cancel",
},
},
};
export const DataTableCustomButtonExample = ({
withMaleFilterButton = false,
}: DataTableTestWrapperProps) => {
const [users, setUsers] = useState<User[]>(data);
const [nextMockUserIndex, setNextMockUserIndex] = useState(0);
const addUser = useCallback(() => {
const nextUser = usersToAdd[nextMockUserIndex % usersToAdd.length];
if (!nextUser) return;
setUsers(prev => [...prev, nextUser]);
setNextMockUserIndex(prev => prev + 1);
}, [nextMockUserIndex]);
const tableOptions = useMemo<SimpleTableOptions<User>>(() => {
const columns: ColumnConfig<User>[] = [
{
key: "customAction",
label: "Action",
initialWidth: 120,
isSortable: false,
hasContextMenu: false,
render: () => (
<SplitButton>
<ToggleButton
active={false}
onChange={() => {
console.log("Positive clicked");
}}
color="success"
primaryIcon={<IconThumbUp />}
/>
<ToggleButton
active={false}
onChange={() => {
console.log("Negative clicked");
}}
color="critical"
primaryIcon={<IconThumbDown />}
/>
</SplitButton>
),
},
{key: "name", label: "Name", render: row => row.name},
{
key: "gender",
render: row => row.gender,
label: "Gender",
filter: {type: "multiSelect", getValue: row => row.gender},
},
{
key: "age",
render: row => row.age,
label: "Age",
sortFunction: (a, b) =>
Number(extractTextFromElement(a.age)) - Number(extractTextFromElement(b.age)),
filter: {type: "range", min: 0, max: 100, getValue: row => Number(row.age)},
},
{
key: "birthday",
render: row => row.birthday,
label: "Birthday",
filter: {
type: "custom",
filterFunction: (row, value) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return true;
const typedValue = value as {from?: string; to?: string};
if (typedValue.from && row.birthday < typedValue.from) return false;
return !(typedValue.to && row.birthday > typedValue.to);
},
},
},
{key: "height", render: row => row.height, label: "Height"},
{key: "weight", render: row => row.weight, label: "Weight"},
{
key: "eyeColor",
render: row => row.eyeColor,
label: "Eye Color",
filter: {type: "multiSelect", getValue: row => row.eyeColor},
},
{
key: "city",
render: row => row.city,
label: "City",
filter: {type: "multiSelect", getValue: row => extractTextFromElement(row.city)},
},
{
key: "origin",
render: row => row.origin,
label: "Origin",
filter: {type: "multiSelect", getValue: row => row.origin},
},
{
key: "marriageStatus",
render: row => row.marriageStatus,
label: "Marriage Status",
filter: {type: "multiSelect", getValue: row => row.marriageStatus},
},
{
key: "alive",
render: row => row.alive,
label: "Alive",
filter: {
type: "boolean",
trueLabel: "Alive",
falseLabel: "Deceased",
getValue: row => row.alive === "Alive",
},
},
];
return {
columns,
searchIndexRow: row => columns.map(column => extractTextFromElement(column.render(row))),
initialColumns: [
"customAction",
"name",
"gender",
"age",
"birthday",
"height",
"weight",
"eyeColor",
"city",
"origin",
"marriageStatus",
"alive",
],
};
}, []);
const table = useSimpleTable<User>(users, tableOptions);
const deleteRow = useCallback((rowIndex: number) => {
setUsers(prev => prev.filter((_, index) => index !== rowIndex));
}, []);
const deleteSelectedRows = useCallback(() => {
setUsers(prev => prev.filter((_, index) => !table.selectionBehavior.selection[index]));
}, [table.selectionBehavior.selection]);
const selectedCount = table.selectionBehavior.selectionSize;
const getRowContextMenu = useCallback(
(rowIndex: number): Menu => [
{
items: [
{
title: "Duplicate",
icon: <IconFileCopy />,
callback: () => undefined,
},
{
title: "Download file",
icon: <IconArrowDown />,
callback: () => undefined,
},
{
title: "Delete",
icon: <IconDeleteBin5 />,
callback: () => deleteRow(rowIndex),
},
],
},
],
[deleteRow],
);
const rows: RowConfig<User>[] = useMemo(
() =>
table.rows.map(item => ({
...item,
buttons: [
{icon: <IconEdit />, action: () => undefined},
{icon: <IconMore2Alt />, contextMenu: getRowContextMenu(item.index)},
],
})),
[getRowContextMenu, table.rows],
);
const dataTableToolbarConfig = useMemo(
() => ({
segments: [
{
segment: [
{section: `${selectedCount} selected`, hasDivider: true},
{section: `Total: ${users.length}`},
],
},
{
segment: [
{
section: (
<ButtonGroup>
<Button
buttonStyle="ghost"
size="large"
color="neutral"
secondaryIcon={<IconArrowUp />}>
Status
</Button>
<Button
buttonStyle="ghost"
size="large"
color="neutral"
secondaryIcon={<IconFileCopy />}>
Duplicate
</Button>
<Button
buttonStyle="ghost"
size="large"
color="neutral"
secondaryIcon={<IconArrowDown />}>
Export
</Button>
</ButtonGroup>
),
hasDivider: true,
hasSidePadding: false,
},
{
section: (
<Button
buttonStyle="flat"
size="large"
color="critical"
primaryIcon={<IconDeleteBin5 />}
onClick={deleteSelectedRows}>
Delete
</Button>
),
hasSidePadding: false,
},
],
},
],
}),
[deleteSelectedRows, selectedCount, users.length],
);
const dataTableToolbar = useToolbar(dataTableToolbarConfig);
const columnsWithContextMenu: ColumnConfig<User>[] = useMemo(() => {
const hiddenCategories = table.columns.filter(
col => table.visibleColumns.findIndex(visible => col.key === visible) == -1,
);
return table.columns.map(column => ({
...column,
contextMenu:
column.hasContextMenu === false
? undefined
: [
{
items: [
{
title: "Hide column",
callback: () =>
table.setVisibleColumns(prev =>
prev.filter(prevKey => prevKey !== column.key),
),
},
{
icon: <IconAdd />,
title: "Show column to the right",
children:
hiddenCategories.length > 0
? hiddenCategories.map(category => ({
title: category.label,
callback: () =>
table.setVisibleColumns(prev => {
const clickedIndex = prev.findIndex(
category => category === column.key,
);
if (clickedIndex === -1) return prev;
return [
...prev.slice(0, clickedIndex + 1),
category.key,
...prev.slice(clickedIndex + 1),
];
}),
}))
: [
{
title: "No hidden columns",
callback: () => undefined,
},
],
},
],
},
],
}));
}, [table]);
const handleRemoveFilterChip = useCallback(
(chip: DataTableChip) => {
const filterKey = chip.filterKey;
const column = table.columns.find(column => String(column.key) === filterKey);
if (!column?.filter) return;
const currentValue = table.filters.values[filterKey];
if (column.filter.type === "multiSelect") {
const nextValues = (Array.isArray(currentValue) ? currentValue : []).filter(
value => value !== chip.itemValue,
);
if (nextValues.length === 0) {
table.filters.clearFilterValue(filterKey);
} else {
table.filters.setFilterValue(filterKey, nextValues);
}
return;
}
table.filters.clearFilterValue(filterKey);
},
[table.filters, table.columns],
);
return (
<div
style={{
height: "500px",
maxWidth: "1200px",
}}>
{withMaleFilterButton && (
<div style={{display: "flex", gap: "8px"}}>
<Button
onClick={() => table.filters.setFilterValue("gender", ["Male"])}
buttonStyle="flat"
color="neutral">
Set Male Filter
</Button>
</div>
)}
<DataTable<User>
{...table}
hasCheckboxes={false}
onRowClick={(data, index) => console.log(data, index)}
columns={columnsWithContextMenu}
rows={rows}
onFilterChipRemove={handleRemoveFilterChip}
toolbar={dataTableToolbar}
modalText={modalText}
storageId={"data_table_example"}
buttons={
<Button onClick={addUser} buttonStyle="flat" color="primary" primaryIcon={<IconAdd />}>
Add User
</Button>
}
/>
</div>
);
};

Server side data

useServerTable loads the visible pages lazily, here with React Query. Search and filters run on the server.

Update a company to write the saved row directly into React Query’s page cache.

Use the table filters to select departments or an age range. Results are filtered by the mock server. The age distribution is fetched separately from a mock histogram endpoint.

ID
Name
Email
Avatar
Job Title
Department
Firma
Street
City
Country
ZIP Code
Phone
Bio
Age
Created At
Actions
"use client";
import {Button, DataTable, LoadPageRequest, Page, useServerTable} from "@brynlabs/fusion-ui";
import {
QueryObserver,
queryOptions,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import React, {useCallback} from "react";
import {
fetchPeople,
fetchPeopleAgeHistogram,
peopleDepartments,
Person,
updatePerson,
} from "@/app/(docs)/components/data-table/mock-people-api";
const peopleQueryKey = ["server-table-people"] as const;
const peoplePageOptions = (request: LoadPageRequest) =>
queryOptions({
queryKey: [...peopleQueryKey, request],
queryFn: () => fetchPeople(request),
staleTime: Infinity,
});
const modalText = {
errorMessages: {
noDataFound: "No data was found",
noDataFoundInSearch: "No searched results",
noDataFoundInFilter: "No filtered results",
noVisibleColumns: "No visible columns",
},
editView: {
title: "Adjust columns",
activeContainerTitle: "Visible Columns",
inactiveContainerTitle: "Hidden Columns",
activeContainerEmptyMessage: "No columns are visible",
inactiveContainerEmptyMessage: "No columns are hidden",
showAllButton: "Show all columns",
applyButton: "Apply",
abortViewText: {
title: "Discard changes?",
description:
"You have unsaved changes. If you continue without saving, these changes will be lost.",
confirmButton: "Discard",
cancelButton: "Cancel",
},
},
filterView: {
title: "Adjust Filters",
searchPlaceholder: "Type to search ...",
min: "Minimum",
max: "Maximum",
applyButton: "Apply",
resetButton: "Reset Filters",
abortViewText: {
title: "Discard changes?",
description:
"You have unsaved changes. If you continue without saving, these changes will be lost.",
confirmButton: "Discard",
cancelButton: "Cancel",
},
},
};
export const DataTableServerExample = () => {
const queryClient = useQueryClient();
const ageHistogram = useQuery({
queryKey: ["server-table-people-age-histogram"],
queryFn: fetchPeopleAgeHistogram,
staleTime: Infinity,
});
const update = useMutation({
mutationFn: (person: Person) =>
updatePerson(person.id, {company: `${person.company} (updated)`}),
onSuccess: person => {
// Update every cached page containing this person, without reloading the table.
queryClient.setQueriesData<Page<Person>>(
{queryKey: peopleQueryKey},
page =>
page && {
...page,
content: page.content.map(row => (row.id === person.id ? person : row)),
},
);
},
});
const table = useServerTable<Person>({
columns: [
{key: "id", render: row => row.id, label: "ID"},
{key: "name", render: row => row.name, label: "Name"},
{key: "email", render: row => row.email, label: "Email"},
{key: "avatar", render: row => row.avatar, label: "Avatar"},
{key: "jobTitle", render: row => row.jobTitle, label: "Job Title"},
{key: "department", render: row => row.department, label: "Department"},
{
key: "company",
render: row => row.company,
label: "Firma",
},
{key: "address.street", render: row => row.address.street, label: "Street"},
{key: "address.city", render: row => row.address.city, label: "City"},
{key: "address.country", render: row => row.address.country, label: "Country"},
{key: "address.zip", render: row => row.address.zip, label: "ZIP Code"},
{key: "phone", render: row => row.phone, label: "Phone"},
{key: "bio", render: row => row.bio, label: "Bio"},
{key: "age", render: row => row.age, label: "Age"},
{key: "createdAt", render: row => row.createdAt, label: "Created At"},
{
key: "actions",
label: "Actions",
render: person => (
<Button disabled={update.isPending} onClick={() => update.mutate(person)}>
Update company
</Button>
),
},
],
filters: [
{
key: "department",
type: "multiSelect",
options: peopleDepartments,
getValue: row => row.department,
},
{
key: "age",
type: "range",
min: ageHistogram.data?.min ?? 18,
max: ageHistogram.data?.max ?? 80,
histogram: ageHistogram.data?.histogram,
showTicks: true,
getValue: row => row.age,
},
],
initialColumns: [
"id",
"name",
"email",
"avatar",
"jobTitle",
"department",
"company",
"address.street",
"address.city",
"address.country",
"address.zip",
"phone",
"bio",
"age",
"createdAt",
"actions",
],
loadPage: useCallback(
request => queryClient.fetchQuery(peoplePageOptions(request)),
[queryClient],
),
subscribeToPage: useCallback(
(request, onPage) => {
// Only visible pages have active observers and refetch on invalidation.
const observer = new QueryObserver(queryClient, peoplePageOptions(request));
const unsubscribe = observer.subscribe(result => {
if (result.data) onPage(result.data);
});
// A fresh cached query may not emit on subscribe. Apply changes made while offscreen.
const current = observer.getCurrentResult();
if (current.data) onPage(current.data);
return unsubscribe;
},
[queryClient],
),
});
return (
<>
<p>Update a company to write the saved row directly into React Query’s page cache.</p>
<p>
Use the table filters to select departments or an age range. Results are filtered by the
mock server. The age distribution is fetched separately from a mock histogram endpoint.
</p>
<Button
onClick={async () => {
// Default invalidation refetches every observed page, including offscreen pages.
// Only mark them stale; reload detaches old observers and loads the visible range.
await queryClient.invalidateQueries({queryKey: peopleQueryKey, refetchType: "none"});
await table.reload();
}}>
Reload from server
</Button>
{update.isError && <p>Could not update the company. Please try again.</p>}
<div
style={{
height: "500px",
maxWidth: "1200px",
}}>
<DataTable<Person>
{...table}
modalText={modalText}
storageId={"data_table_server_example"}
/>
</div>
</>
);
};
On this page
  • Overview
  • Custom actions
  • Server side data