Introduction
Every data table or datagrid I've created has been unique. They all behave differently, have specific sorting and filtering requirements, and work with different data sources.
It doesn't make sense to combine all of these variations into a single component. If we do that, we'll lose the flexibility that headless UI provides.
So instead of a data-table component, I thought it would be more helpful to provide a guide on how to build your own.
We'll start with the basic <Table />
component and build a complex data table from scratch.
Tip: If you find yourself using the same table in multiple places in your app, you can always extract it into a reusable component.
Table of Contents
This guide will show you how to use TanStack Table and the <Table />
component to build your own custom data table. We'll cover the following topics:
Installation
Add the <Table />
component to your project:
npx shadcn-solid@latest add table button dropdown-menu textfield checkbox
Add tanstack/solid-table
dependency:
npm install @tanstack/solid-table
Prerequisites
We are going to build a table to show tasks. Here's what our data looks like:
type Task = {
id : string ;
code : string ;
title : string ;
status : "todo" | "in-progress" | "done" | "cancelled" ;
label : "bug" | "feature" | "enhancement" | "documentation" ;
};
export const tasks : Task [] = [
{
id: "ptL0KpX_yRMI98JFr6B3n" ,
code: "TASK-33" ,
title: "We need to bypass the redundant AI interface!" ,
status: "todo" ,
label: "bug"
},
{
id: "RsrTg_SmBKPKwbUlr7Ztv" ,
code: "TASK-59" ,
title:
"Overriding the capacitor won't do anything, we need to generate the solid state JBOD pixel!" ,
status: "in-progress" ,
label: "feature"
}
// ...
];
Copy
Project Structure
Start by creating the following file structure:
src
└── routes
├── _components
│ ├── columns.tsx
│ └── data-table.tsx
└── index.tsx
Copy
columns.tsx
will contain our column definitions.
data-table.tsx
will contain our <DataTable />
component.
index.tsx
is where we'll fetch data and render our table.
Basic Table
Let's start by building a basic table.
Column Definitions First, we'll define our columns.
src/routes/_components/columns.tsx import type { ColumnDef } from "@tanstack/solid-table" ;
// This type is used to define the shape of our data.
// You can use a Zod or Validbot schema here if you want.
export type Task = {
id : string ;
code : string ;
title : string ;
status : "todo" | "in-progress" | "done" | "cancelled" ;
label : "bug" | "feature" | "enhancement" | "documentation" ;
};
export const columns : ColumnDef < Task >[] = [
{
accessorKey: "code" ,
header: "Task"
},
{
accessorKey: "title" ,
header: "Title"
},
{
accessorKey: "status" ,
header: "Status"
}
];
Copy <DataTable />
componentNext, we'll create a <DataTable />
component to render our table.
src/routes/_components/data-table.tsx import type { ColumnDef } from "@tanstack/solid-table" ;
import { flexRender, createSolidTable, getCoreRowModel } from "@tanstack/solid-table" ;
import { For, Show, splitProps, Accessor } from "solid-js" ;
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table" ;
type Props < TData , TValue > = {
columns : ColumnDef < TData , TValue >[];
data : Accessor < TData [] | undefined >;
};
export const DataTable = < TData , TValue >(props : Props < TData , TValue >) => {
const [local] = splitProps (props, [ "columns" , "data" ]);
const table = createSolidTable ({
get data () {
return local. data () || [];
},
columns: local.columns,
getCoreRowModel: getCoreRowModel ()
});
return (
< div class= "rounded-md border" >
< Table >
< TableHeader >
< For each={ table. getHeaderGroups () }>
{ headerGroup => (
< TableRow >
< For each={ headerGroup.headers }>
{ header => {
return (
< TableHead >
{ header.isPlaceholder
? null
: flexRender (header.column.columnDef.header, header. getContext ()) }
</ TableHead >
);
} }
</ For >
</ TableRow >
) }
</ For >
</ TableHeader >
< TableBody >
< Show
when={ table. getRowModel ().rows?.length }
fallback={
< TableRow >
< TableCell colSpan={ local.columns.length } class= "h-24 text-center" >
No results.
</ TableCell >
</ TableRow >
}
>
< For each={ table. getRowModel ().rows }>
{ row => (
< TableRow data-state={ row. getIsSelected () && "selected" }>
< For each={ row. getVisibleCells () }>
{ cell => (
< TableCell >
{ flexRender (cell.column.columnDef.cell, cell. getContext ()) }
</ TableCell >
) }
</ For >
</ TableRow >
) }
</ For >
</ Show >
</ TableBody >
</ Table >
</ div >
);
};
Copy Render the table Finally, we'll render our table in our index page.
src/routes/index.tsx import type { Task } from "./_components/columns" ;
import { columns } from "./_components/columns" ;
import { DataTable } from "./_components/data-table" ;
import type { RouteDefinition } from "@solidjs/router" ;
import { cache, createAsync } from "@solidjs/router" ;
const getData = cache ( async () : Promise < Task []> => {
// Fetch data from your API here.
return [
{
id: "ptL0KpX_yRMI98JFr6B3n" ,
code: "TASK-33" ,
title: "We need to bypass the redundant AI interface!" ,
status: "todo" ,
label: "bug"
}
// ...
];
}, "data" );
export const route : RouteDefinition = {
load : () => getData ()
};
const Home = () => {
const data = createAsync (() => getData ());
return (
< div class= "w-full space-y-2.5" >
< DataTable columns={ columns } data={ data } />
</ div >
);
};
export default Home;
Copy
Row Actions
Update our columns definition to add a new actions
column. The actions
cell returns a <Dropdown />
component.
src/routes/_components/columns.tsx //...
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu" ;
export const columns : ColumnDef < Task >[] = [
// ...
{
id: "actions" ,
cell : () => (
< DropdownMenu placement= "bottom-end" >
< DropdownMenuTrigger class= "flex items-center justify-center" >
< svg xmlns= "http://www.w3.org/2000/svg" class= "size-4" viewBox= "0 0 24 24" >
< path
fill= "none"
stroke= "currentColor"
stroke-linecap= "round"
stroke-linejoin= "round"
stroke-width= "2"
d= "M4 12a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0"
/>
</ svg >
</ DropdownMenuTrigger >
< DropdownMenuContent >
< DropdownMenuItem > Edit </ DropdownMenuItem >
< DropdownMenuItem > Delete </ DropdownMenuItem >
</ DropdownMenuContent >
</ DropdownMenu >
)
}
// ...
];
Copy
You can access the row data using row.original
in the cell
function. Use this to handle actions for your row eg. use the id
to make a DELETE call to your API.
Next, we'll add pagination to our table.
Update <DataTable>
src/routes/_components/data-table.tsx //...
import {
//...
getPaginationRowModel
} from "@tanstack/solid-table" ;
export const DataTable = < TData , TValue >(props : Props < TData , TValue >) => {
const table = createSolidTable ({
//...
getPaginationRowModel: getPaginationRowModel ()
});
// ...
};
Copy This will automatically paginate your rows into pages of 10. See the pagination docs for more information on customizing page size and implementing manual pagination.
We can add pagination controls to our table using the <Button />
component and the table.previousPage()
, table.nextPage()
API methods.
src/routes/_components/data-table.tsx //...
import { Button } from "@/components/ui/button"
export const DataTable = < TData , TValue >(props : Props < TData , TValue >) {
//...
return (
<div>
<div class = "rounded-md border" >...
</div>
<div class = "flex items-center justify-end space-x-2 py-4" >
<Button
variant = "outline"
size = "sm"
onClick = {() => table.previousPage()}
disabled = {!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant = "outline"
size = "sm"
onClick = {() => table.nextPage()}
disabled = {!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)
}
Copy
Sorting
Update <DataTable>
src/routes/_components/data-table.tsx //...
import {
//...
getSortedRowModel
} from "@tanstack/solid-table" ;
export const DataTable = < TData , TValue >(props : Props < TData , TValue >) => {
const [sorting, setSorting] = createSignal < SortingState >([]);
const table = createSolidTable ({
//...
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel (),
state: {
get sorting () {
return sorting ();
}
}
});
// ...
};
Copy Update the status
header cell to add sorting controls.
src/routes/_components/columns.tsx //...
import { Button } from "@/components/ui/button" ;
export const columns : ColumnDef < Task >[] = [
// ...
{
accessorKey: "status" ,
header : ({ column }) => {
return (
< Button
variant= "ghost"
onClick={ () => column. toggleSorting (column. getIsSorted () === "asc" ) }
>
Status
< svg
xmlns= "http://www.w3.org/2000/svg"
class= "ml-2 size-4"
aria-hidden= "true"
viewBox= "0 0 24 24"
>
< path
fill= "none"
stroke= "currentColor"
stroke-linecap= "round"
stroke-linejoin= "round"
stroke-width= "2"
d= "M12 5v14m4-4l-4 4m-4-4l4 4"
/>
</ svg >
</ Button >
);
}
}
// ...
];
Copy
Filtering
Add filter to the title. See the filtering docs for more information on customizing filters.
Update <DataTable>
src/routes/_components/data-table.tsx //...
import {
//...
getFilteredRowModel
} from "@tanstack/solid-table" ;
import { TextFieldRoot, TextField } from "~/components/ui/textfield" ;
export const DataTable = < TData , TValue >(props : Props < TData , TValue >) => {
//...
const [columnFilters, setColumnFilters] = createSignal < ColumnFiltersState >([]);
const table = createSolidTable ({
//...
getFilteredRowModel: getFilteredRowModel (),
onColumnFiltersChange: setColumnFilters,
state: {
//...
get columnFilters () {
return columnFilters ();
}
}
});
< div >
< div class= "flex items-center py-4" >
< TextFieldRoot >
< TextField
placeholder= "Filter title..."
value={ (table. getColumn ( "title" )?. getFilterValue () as string ) ?? "" }
onInput={ event => table. getColumn ( "title" )?. setFilterValue (event.currentTarget.value) }
class= "max-w-sm"
/>
</ TextFieldRoot >
</ div >
< div class= "rounded-md border" > ... </ div >
</ div > ;
};
Copy
Visibility
Adding column visibility using visibility API.
Update <DataTable>
src/routes/_components/data-table.tsx //...
import { As } from "@kobalte/core" ;
import { Button } from "~/components/ui/button" ;
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu" ;
import type { DropdownMenuTriggerProps } from "@kobalte/core/dropdown-menu" ;
export const DataTable = < TData , TValue >(props : Props < TData , TValue >) => {
//...
const [columnVisibility, setColumnVisibility] = createSignal < VisibilityState >({});
const table = createSolidTable ({
//...
onColumnVisibilityChange: setColumnVisibility,
state: {
//...
get columnVisibility () {
return columnVisibility ();
}
}
});
< div >
< div class= "flex items-center py-4" >
//...
< DropdownMenu >
< DropdownMenuTrigger as={ (props : DropdownMenuTriggerProps ) => (
< Button
{... props }
variant= "outline"
class= "ml-auto"
>
Columns
</ Button >
) }
/>
< DropdownMenuContent >
< For each={ table. getAllColumns (). filter (column => column. getCanHide ()) }>
{ item => (
< DropdownMenuCheckboxItem
class= "capitalize"
checked={ item. getIsVisible () }
onChange={ value => item. toggleVisibility ( !! value) }
>
{ item.id }
</ DropdownMenuCheckboxItem >
) }
</ For >
</ DropdownMenuContent >
</ DropdownMenu >
</ div >
< div class= "rounded-md border" > ... </ div >
</ div > ;
};
Copy
Row Selection
Next, we're going to add row selection to our table.
Update column definitions src/routes/_components/columns.tsx //...
import { Checkbox, CheckboxControl } from "@/components/ui/checkbox" ;
export const columns : ColumnDef < Task >[] = [
{
id: "select" ,
header : ({ table }) => (
< Checkbox
indeterminate={ table. getIsSomePageRowsSelected () }
checked={ table. getIsAllPageRowsSelected () }
onChange={ value => table. toggleAllPageRowsSelected ( !! value) }
aria-label= "Select all"
>
< CheckboxControl />
</ Checkbox >
),
cell : ({ row }) => (
< Checkbox
checked={ row. getIsSelected () }
onChange={ value => row. toggleSelected ( !! value) }
aria-label= "Select row"
>
< CheckboxControl />
</ Checkbox >
),
enableSorting: false ,
enableHiding: false
}
// ...
];
Copy Update <DataTable>
src/routes/_components/data-table.tsx //...
export const DataTable = < TData , TValue >(props : Props < TData , TValue >) => {
//...
const [rowSelection, setRowSelection] = createSignal ({});
const table = createSolidTable ({
//...
onRowSelectionChange: setRowSelection,
state: {
// ...
get rowSelection () {
return rowSelection ();
}
}
});
< div > ... </ div > ;
};
Copy Show selected rows You can show the number of selected rows using the table.getFilteredSelectedRowModel()
API.
< div class= "text-muted-foreground flex-1 text-sm" >
{ table. getFilteredSelectedRowModel ().rows.length } of { table. getFilteredRowModel ().rows.length }{ " " }
row(s) selected.
</ div >
Copy