UILab

Data Table

Powerful data table built using TanStack Table.

Experimental

Things may change quickly

NameEmailStatusCreated At
Alicealice@gmail.comConfirmed2024-01-21
Bobbob@gmail.comIs Pending2024-01-30
Cisnecisne@gmail.comIs Pending2024-01-30
Charliecharlie@gmail.comSuccess2024-02-01
"use client"

import * as React from "react";
import { MailIcon, PlusIcon, RotateCcw, SearchIcon } from "lucide-react";
import { Badge, Button, InputGroup, InputGroupAddon, InputGroupInput, Tooltip, TooltipContent, TooltipTrigger } from "uilab-core";

Introduction

Every data table is unique and it depends on your use case and business logic. It's hard to have one data table that fits every single data display. Some needs to be simple and some needs to have complex filters and paginations (e.g. fintech stuff).

We have made 2 types of data tables using Tanstack Table which is a headless UI. Capability of this package is endless so you can change certain code to fit your use case.

Tanstack Router's type-safe routing feature is super useful for filters and pagination, so we will be using it for demonstrations below.

Installation

npx shadcn add @uilab-core/data-table

Basic Table

Pretty basic and simple table. Made this for smaller data that does not need pagination or any other feature.

KeyValueEnvironment
API_KEY1234567890Production
DB_PASSWORDpassword123Staging
SECRET_KEYsecretkey123Development
"use client"

import { BasicDataTable } from "@/components/data-table";
import { CellContext, ColumnDef } from "@tanstack/react-table";
import { Badge } from "uilab-core";

File structure

Start by creating the following file structure:

products
├── index.tsx
└── -components
    └── products-table
        ├── columns.tsx
        └── index.tsx

Column definitions

-components/products-table/columns.tsx
export default [
  {
    accessorKey: "name",
    header: "Name"
  },
  {
    accessorKey: "email",
    header: "Email"
  },
  {
    accessorKey: "status",
    header: "Status",
    cell: ({ row }: CellContext<DataItem, unknown>) => (
      <Badge variant="outline">{row.getValue('status')}</Badge>
    )
  },
  {
    accessorKey: "createdAt",
    header: "Created At"
  }
] as Array<ColumnDef<DataItem>>

Render the table

Prepare our table component to render on page

-components/products-table/index.tsx
import columns from "./columns"
import BasicDataTable from "@/components/data-table"

export function Table() {
  const { data, isFetching } = useQuery(dataQuery())

  return (
    <BasicDataTable
      columns={columns}
      data={data}
      isFetching={isFetching}
    />
  );
}

Advanced Table

NameEmailStatusCreated At
Alicealice@gmail.comConfirmed2024-01-21
Bobbob@gmail.comIs Pending2024-01-30
Cisnecisne@gmail.comIs Pending2024-01-30
Charliecharlie@gmail.comSuccess2024-02-01
"use client"

import * as React from "react";
import { MailIcon, PlusIcon, RotateCcw, SearchIcon } from "lucide-react";
import { Badge, Button, InputGroup, InputGroupAddon, InputGroupInput, Tooltip, TooltipContent, TooltipTrigger } from "uilab-core";

File structure

Start by creating the following file structure:

products
├── index.tsx
└── -components
    ├── index.tsx
    └── table
        ├── columns.tsx
        ├── filter.tsx
        └── index.tsx

Validate search params

Page needs to validate if given search params is valid or not. We are using zod library for validation.

products/index.tsx
import { createFileRoute } from "@tanstack/react-router"
import * as z from "zod"
import { DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE } from "@/lib/constants"
import { Table } from "./-components/table"

export const productsSearchSchema = z.object({
  pageIndex: z.number().catch(DEFAULT_PAGE_INDEX).optional(),
  pageSize: z.number().catch(DEFAULT_PAGE_SIZE).optional(),
})

export const Route = createFileRoute("/products/")({
  component: ProductsPage,
  validateSearch: productsSearchSchema.parse,
})

function ProductsPage() {
  return <Table />
}

Filter component

Reminder

Filter form components should be controlled components, so when sharing the url, the values are instantly filled into the form components. Ease of use.

products/-components/table/filter.tsx
import { FormProvider, useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { productsSearchSchema } from "../../"

export default function Filter() {
  const { filters, setFilters, resetFilters } = useFilters(PATH)
  const form = useForm({
    resolver: zodResolver(productsSearchSchema),
    values: filters,
  })

  const handleSubmit = form.handleSubmit((items: SearchSchema) => {
    setFilters(items)
  })

  const handleReset = () => {
    resetFilters().then(() => {
      form.reset()
      form.clearErrors()
    })
  }

  return (
    <FormProvider {...form}>
      <form
        className="flex flex-wrap items-end gap-2 rounded-lg border border-dashed p-2"
        onSubmit={handleSubmit}
      >
        <InputGroup>
          <InputGroupAddon>
            <MailIcon />
          </InputGroupAddon>
          <InputGroupInput
            placeholder="Filter emails..."
            value={emailFilter}
            onChange={(e) => setEmailFilter(e.target.value)}
          />
        </InputGroup>
        <Button variant="outline" size="icon" onClick={handleReset}>
          <RotateCcw />
        </Button>
        <Button type="submit">
          <SearchIcon />
          {t("search")}
        </Button>
      </form>
    </FormProvider>
  )
}

Column definition

products/-components/table/columns.tsx
export default [
  {
    accessorKey: "name",
    header: "Name"
  },
  {
    accessorKey: "email",
    header: "Email"
  },
  {
    accessorKey: "status",
    header: "Status",
    cell: ({ row }: CellContext<DataItem, unknown>) => (
      <Badge variant="outline">{row.getValue('status')}</Badge>
    )
  },
  {
    accessorKey: "createdAt",
    header: "Created At"
  }
] as Array<ColumnDef<DataItem>>

Render the table

products/-components/table/index.tsx
import { AdvancedDataTable } from "@/components/data-table"
import { useFilters } from "@/hooks/use-filter"
import { DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE } from "@/lib/constants"
import Filter from "./filter"
import columns from "./columns"

export function Table() {
  // This filter will be using `/products` page's validated search params.
  const { filters, setFilters } = useFilters("/products/")

  const paginationState = {
    pageIndex: filters.pageIndex ?? DEFAULT_PAGE_INDEX,
    pageSize: filters.pageSize ?? DEFAULT_PAGE_SIZE,
  }

  const { data, isFetching, refetch } = useQuery(
    dataQuery({
      ...filters,
      page: paginationState.pageIndex.toString(),
      size: paginationState.pageSize.toString(),
    }),
  )

  return (
    <AdvancedDataTable
      columns={COLUMNS}
      data={data}
      refetch={refetch}
      isFetching={isFetching}
      leftHeader={<Filter />}
      // For example: You can add sheet components with trigger
      rightHeader={<Button><PlusIcon />Add</Button>}
      pagination={paginationState}
    />
  );
}

On this page