Docs
Data Table

Data Table

Utils and components for building client side data tables.

Loading...

Installation

Install the following dependencies:

pnpm add zustand immer

Add the following components/libraries:

Create lib/data-table/core.tsx

/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { Loader2 } from "lucide-react";
import type { SortDescriptor } from "react-aria-components";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
 
import type { ArrayItem, Slice } from "@/lib/types";
 
/* -------------------------------------------------------------------------- */
/*                                 PAGINATION                                 */
/* -------------------------------------------------------------------------- */
 
export type PaginationLimit = 20 | 50 | "all";
 
export interface PaginationState {
  value: {
    page: number;
    limit: 20 | 50 | "all";
  };
  set: ({ page, limit }: { page?: number; limit?: PaginationLimit }) => void;
}
 
export interface PaginationSlice {
  pagination: PaginationState;
}
 
export function createPaginationSlice(defaultValues?: {
  page?: number;
  limit?: PaginationLimit;
}) {
  const slice: Slice<PaginationSlice> = (set) => ({
    pagination: {
      value: {
        page: defaultValues?.page ?? 1,
        limit: defaultValues?.limit ?? 20,
      },
      set: ({ page, limit }) => {
        set((state) => {
          if (page) {
            state.pagination.value.page = page;
          }
 
          if (limit) {
            state.pagination.value.limit = limit;
          }
        });
      },
    },
  });
 
  return slice;
}
 
/* -------------------------------------------------------------------------- */
/*                                   SORTING                                  */
/* -------------------------------------------------------------------------- */
 
export type SortingDirection = "asc" | "desc";
 
export interface SortingState<T extends readonly string[]> {
  value: {
    key: T[number] | undefined;
    direction: SortingDirection;
  };
  set: (args: { key?: T[number]; direction?: SortingDirection }) => void;
}
 
export type SortingAlgorithms<
  DataArray extends any[],
  T extends readonly string[],
> = {
  [key in T[number]]: (
    a: ArrayItem<DataArray>,
    b: ArrayItem<DataArray>,
  ) => {
    asc: number;
    desc: number;
  };
};
 
export interface SortingSlice<
  DataArray extends any[],
  T extends readonly string[],
> {
  sorting: SortingState<T> & {
    sortingAlgorithms: SortingAlgorithms<DataArray, T>;
  };
}
 
export const createSortingSlice = <
  DataArray extends any[],
  T extends readonly string[],
>(sortingAlgorithms: {
  [key in T[number]]: (
    a: ArrayItem<DataArray>,
    b: ArrayItem<DataArray>,
  ) => {
    asc: number;
    desc: number;
  };
}) => {
  const slice: Slice<SortingSlice<DataArray, T>> = (set) => ({
    sorting: {
      value: {
        direction: "asc",
        key: undefined,
      },
      set: ({ key, direction }) => {
        set((state) => {
          if (key) {
            // @ts-expect-error Don't know how to fix this, but should theoratically be fine 🥶
            state.sorting.value.key = key;
          }
 
          if (direction) {
            state.sorting.value.direction = direction;
          }
        });
      },
      sortingAlgorithms,
    },
  });
 
  return slice;
};
 
export type InferSortingSlice<T> = T extends Slice<infer U> ? U : never;
 
/* -------------------------------------------------------------------------- */
/*                                    STORE                                   */
/* -------------------------------------------------------------------------- */
 
/**
 * This store manages all the data filtering, sorting and pagination.
 *
 * You have to manually provide the generics for this function.
 *
 * 1. Generic type
 * Declare the types of all filters you want to use.
 *
 * 2. Generic Type
 * Declare the type of the data array you want to filter.
 *
 * 3. Generic Type
 * Declare all keys for which you want to sort the table. You can choose
 * any name you want because the mapping to the actual data is done in the
 * function function parameters.
 *
 * @param filterSlice Define the store.
 * @param sortingAlgorithms Define which sorting algorithms should be used for the declared keys.
 * @returns
 */
export const createDataStore = <
  TFilterSlice extends Record<string, { value: unknown; set: unknown }>,
  TDataArray extends any[] = [],
  const TKeys extends readonly string[] = [],
>(
  filterSlice: Slice<TFilterSlice>,
  sortingAlgorithms: {
    [key in TKeys[number]]: (
      a: ArrayItem<TDataArray>,
      b: ArrayItem<TDataArray>,
    ) => {
      asc: number;
      desc: number;
    };
  },
) => {
  const paginationSlice = createPaginationSlice();
  const sortingSlice = createSortingSlice<TDataArray, TKeys>(sortingAlgorithms);
 
  return create<
    TFilterSlice & PaginationSlice & InferSortingSlice<typeof sortingSlice>
  >()(
    immer((...args) => ({
      ...filterSlice(...(args as [any, any, any])),
      ...paginationSlice(...(args as [any, any, any])),
      ...sortingSlice(...(args as [any, any, any])),
    })),
  );
};
 
/* -------------------------------------------------------------------------- */
/*                             SORTING ALGORITHMS                             */
/* -------------------------------------------------------------------------- */
 
export const defaultStringSort = (a: string, b: string) => ({
  asc: a.localeCompare(b),
  desc: b.localeCompare(a),
});
 
export const defaultNumberSort = (a: number, b: number) => ({
  asc: a - b,
  desc: b - a,
});
 
export const defaultDateSort = (a: Date, b: Date) => ({
  asc: a.getTime() - b.getTime(),
  desc: b.getTime() - a.getTime(),
});
 
export const defaultBooleanSort = (a: boolean, b: boolean) => ({
  asc: a === b ? 0 : a ? 1 : -1,
  desc: a === b ? 0 : a ? -1 : 1,
});
 
/* -------------------------------------------------------------------------- */
/*                               MAIN FUNCTIONS                               */
/* -------------------------------------------------------------------------- */
 
export function sortAndPaginate<DataType, T extends string[]>(
  data: DataType[],
  state?: {
    pagination?: PaginationState;
    sorting?: SortingState<T> & {
      sortingAlgorithms: SortingAlgorithms<DataType[], T>;
    };
  },
): {
  data: DataType[];
  total: number;
} {
  const total = data.length;
  let dataCopy = [...data];
 
  if (dataCopy.length === 0) {
    return {
      data,
      total: 0,
    };
  }
 
  if (state?.sorting && state.sorting.value.key) {
    dataCopy = dataCopy.sort((a, b) => {
      if (!state.sorting?.value.key) {
        throw new Error("Undefined key.");
      }
 
      const algo = state.sorting.sortingAlgorithms[state.sorting.value.key];
      return algo(a, b)[state.sorting.value.direction];
    });
  }
 
  if (state?.pagination && state.pagination.value.limit !== "all") {
    dataCopy = dataCopy.slice(
      (state.pagination.value.page - 1) * state.pagination.value.limit,
      state.pagination.value.page * state.pagination.value.limit,
    );
  }
 
  return {
    data: dataCopy,
    total,
  };
}
 
/**
 * Obligatory dependencies:
 * - data
 * - pagination.value
 * - sorting.value
 *
 * You also have to declare the filter values from the store as dependencies.
 */
export const filterData = <
  TData extends any[],
  TFilteredData extends {
    result: TData;
  },
>(config: {
  data: TData | undefined;
  store: {
    pagination: PaginationState;
    sorting: SortingState<any>;
  };
  filter: (data: TData) => TFilteredData;
}):
  | (Omit<TFilteredData, "result"> & {
      result: {
        data: TData;
        total: number;
      };
    })
  | null => {
  if (config.data) {
    const { result, ...filtered } = config.filter(config.data);
 
    return {
      ...filtered,
      result: sortAndPaginate(result, {
        pagination: config.store.pagination,
        sorting: config.store.sorting as any,
      }),
    } as any;
  }
 
  return null;
};
 
export const getDataTableProps = <
  TAllKeys extends readonly string[],
  TKeys extends TAllKeys[number],
>(state: {
  value: {
    key: TKeys | undefined;
    direction: SortingDirection;
  };
  set: (args: { key?: TKeys; direction?: SortingDirection }) => void;
}): {
  onSortChange: (descriptor: SortDescriptor) => void;
  sortDescriptor: SortDescriptor;
} => {
  return {
    onSortChange: (descriptor) => {
      state.set({
        key: descriptor.column as any,
        direction: descriptor.direction === "ascending" ? "asc" : "desc",
      });
    },
    sortDescriptor: {
      column: state.value.key,
      direction: state.value.direction === "asc" ? "ascending" : "descending",
    },
  };
};
 
export const renderEmptyState = (text: string, isPending?: boolean) => () => {
  if (isPending) {
    return <Loader2 className="h-6 w-full animate-spin" />;
  }
 
  return <>{text}</>;
};

(Optional) Create lib/data-table/table-sorting-column.tsx

"use client";
 
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
 
import type { TableColumnProps } from "@/components/ui/table";
import { TableColumn } from "@/components/ui/table";
import type { SortingDirection } from "@/lib/data-table/core";
 
export interface TableSortingColumnProps<
  TAllKeys extends readonly string[],
  TKeys extends TAllKeys[number],
  TData extends object,
> extends Omit<TableColumnProps<TData>, "allowsSorting"> {
  state: {
    value: {
      key: TKeys | undefined;
      direction: SortingDirection;
    };
    set: (args: { key?: TKeys; direction?: SortingDirection }) => void;
  };
  sortBy: TKeys;
  text: string;
}
 
export const TableSortingColumn = <
  TAllKeys extends readonly string[],
  TKeys extends TAllKeys[number],
  TData extends object,
>({
  state,
  sortBy,
  text,
  ...props
}: TableSortingColumnProps<TAllKeys, TKeys, TData>) => {
  return (
    <TableColumn id={sortBy} allowsSorting {...props}>
      <div className="flex items-center">
        <span className={"mr-2"}>{text}</span>
        {state.value.key === sortBy ? (
          state.value.direction === "asc" ? (
            <ArrowUp aria-hidden="true" className="h-4 w-4" />
          ) : (
            <ArrowDown aria-hidden="true" className="h-4 w-4" />
          )
        ) : (
          <ArrowUpDown aria-hidden="true" className="h-4 w-4" />
        )}
      </div>
    </TableColumn>
  );
};

(Optional) Create lib/data-table/paginator.tsx

"use client";
 
import { useState } from "react";
import { Slot } from "@radix-ui/react-slot";
import {
  ChevronLeft,
  ChevronRight,
  ChevronsLeft,
  ChevronsRight,
} from "lucide-react";
 
import { Button } from "@/components/ui/button";
import type { SelectOption, SelectOptions } from "@/components/ui/select";
import { Select } from "@/components/ui/select";
import type { PaginationLimit, PaginationState } from "@/lib/data-table/core";
import { cn } from "@/lib/utils";
 
const selectLimitOptions: SelectOptions<PaginationLimit> = [
  {
    label: "20",
    value: 20,
  },
  {
    label: "50",
    value: 50,
  },
  {
    label: "Alle",
    value: "all",
  },
];
 
const PageButton = ({
  disabled,
  onClick,
  icon,
}: {
  disabled: boolean;
  onClick: () => void;
  icon: React.ReactNode;
}) => {
  return (
    <Button
      variant="ghost"
      isDisabled={disabled}
      className="group flex rounded-full enabled:hover:bg-muted"
      onClick={onClick}
    >
      <Slot className="h-4 w-4 text-muted-foreground group-disabled:text-gray-300">
        {icon}
      </Slot>
    </Button>
  );
};
 
export const Paginator = ({
  state,
  total,
  className,
}: {
  state: PaginationState;
  total: number | undefined;
  className?: string;
}) => {
  const pagination = state.value;
  const currentLimit = selectLimitOptions.find(
    (option) => option.value === state.value.limit,
  );
 
  if (!currentLimit) {
    throw new Error("Invalid limit, this shouldn't happen.");
  }
 
  const [limit, setLimit] =
    useState<SelectOption<PaginationLimit>>(currentLimit);
 
  if (!total || total <= 0) {
    return null;
  }
 
  const isFirstPage = pagination.page === 1;
  const isLastPage =
    pagination.limit === "all" ||
    pagination.page === Math.ceil(total / pagination.limit);
  const canGoBack = pagination.limit !== "all" && pagination.page !== 1;
  const canGoForward =
    pagination.limit !== "all" && pagination.limit * pagination.page <= total;
 
  const goToFirstPage = () => state.set({ page: 1 });
  const goToPreviousPage = () =>
    canGoBack && state.set({ page: pagination.page - 1 });
  const goToNextPage = () =>
    canGoForward && state.set({ page: pagination.page + 1 });
  const goToLastPage = () =>
    !isLastPage &&
    pagination.limit !== "all" &&
    state.set({ page: Math.ceil(total / pagination.limit) });
 
  return (
    <div
      className={cn(
        "flex h-16 min-h-[4rem] items-center justify-between px-4",
        className,
      )}
    >
      <div className="flex items-center">
        <PageButton
          disabled={isFirstPage}
          onClick={goToFirstPage}
          icon={<ChevronsLeft />}
        />
        <PageButton
          disabled={isFirstPage}
          onClick={goToPreviousPage}
          icon={<ChevronLeft />}
        />
        <div className="mx-3 space-x-2 text-sm text-muted-foreground">
          <span>{pagination.page}</span>
          <span>von</span>
          <span>
            {pagination.limit === "all"
              ? 1
              : Math.ceil(total / pagination.limit)}
          </span>
        </div>
        <PageButton
          disabled={!canGoForward}
          onClick={goToNextPage}
          icon={<ChevronRight />}
        />
        <PageButton
          disabled={isLastPage}
          onClick={goToLastPage}
          icon={<ChevronsRight />}
        />
      </div>
      <div>
        <Select
          className="w-14"
          options={selectLimitOptions}
          value={limit}
          onChange={setLimit}
          onSelect={(option) => state.set({ limit: option.value })}
        />
      </div>
    </div>
  );
};

Update the import paths to match your project setup.