Back

Guide (WIP)

As of: March 17, 2025

This helps you to get started with the data-table and define the columns, filters, search params, sheet fields and more.

If you have any questions, feature request please open an issue on GitHub. This is a work in progress and we are happy to hear from you.

Remember, we are using the shadcn/ui components. Some modifications still live in src/components/custom/* but will eventually be migrating back to shadcn/ui to be fully compliant.

The infinite scrollable data-table is mainly composed of the following parts:

You will have full control over the data-table and the components that are rendered. Feel free to extend, modify or remove components as you see fit.

Table

Table Columns

The columns.tsx file is our known source of truth for the columns. If you are familiar with shadcn, you should feel right at home. Check out the shadcn/ui docs for more information.

Extending Tanstack Table Types

We extend the meta types mainly for styling and filter functions. That way, we can avoid changing the data-table components itself.

import "@tanstack/react-table";
 
declare module "@tanstack/react-table" {
  interface TableMeta<TData extends unknown> {
    /**
     * Function to style the row based on values, e.g. color-coding.
     */
    getRowClassName?: (row: Row<TData>) => string;
  }
 
  interface ColumnMeta {
    /**
     * Class name to style the header cell.
     */
    headerClassName?: string;
    /**
     * Class name to style the cell.
     */
    cellClassName?: string;
    /**
     * Label for the column when neither `id` nor `header` can be used.
     */
    label?: string;
  }
 
  interface FilterFns {
    /**
     * Filter function to filter the data based on a date range.
     */
    inDateRange?: FilterFn<any>;
    /**
     * Filter function to filter the data based on an array of values.
     */
    arrSome?: FilterFn<any>;
  }
 
  interface ColumnFiltersOptions<TData extends RowData> {
    filterFns?: Record<string, FilterFn<TData>>;
  }
}

Table Rows

The more data you have, the more you need to think about how to render the rows, especially in an infinitely scrollable data-table.

We currently memoize with a custom deps function to avoid re-rendering the rows when the data is the same.

const MemoizedRow = React.memo(
  ({ row, selected }: { row: Row<unknown>; selected: boolean }) => {
    useQueryState("live", searchParamsParser.live);
    return /* ... */
  },
  (prev, next) => prev.row.id === next.row.id && prev.selected === next.selected
) as typeof Row;

The row re-renders only if the id or selected state of the row changes, and with the useQueryState hook, we force a re-render when the live param changes (making the getRowClassName prop being called to set an opacity to the row).

At a certain point we need to think adding @tanstack/react-virtual to the mix.

Filters

Right now, we need to define the filters in two places:

The main reason for this is that we want to keep the filterFields as the source of truth for the filters. The searchParams are used to filter the data on the server side and to build the search command. Both are synced.

It would be great if we could merge both values, the filterFields and the searchParams together and have a single source of truth. - Or make both types extend a single type for better type safety. Hopefully, we can achieve this in the future.

filterFields

The filterFields array within the constants.tsx file is what defines the left sidebar controls of the data-table and the cmdk input items.

The input, checkbox, slider and timerange types are currently supported.

All types extend the Base type which defines the following properties:

export type Base<TData> = {
  /**
   * The label of the filter field.
   */
  label: string;
  /**
   * The value of the filter field - same as the column `id`
   */
  value: keyof TData;
  /**
   * Defines if the accordion in the filter bar is open by default
   */
  defaultOpen?: boolean;
  /**
   * Defines if the command input is disabled for this field
   */
  commandDisabled?: boolean;
};

To support the cmdk input, we need to define the options property for all types.

export type Option = {
  label: string;
  value: string | boolean | number | undefined;
};

Input

For the input type, there is nothing to define.

Within the Controls sidebar, it will be rendered as a simple text input field. The cmdk input will list all the defined options once the value is selected.

export type Input = {
  type: "input";
  options?: Option[];
};

Limitations: Spaces cannot be used in the input value as they get interpreted as a separator in the cmdk input (and vice versa). An issue #27 is open to tackle this.

Checkbox

The checkbox type will be rendered the options as list of checkboxes in the Controls sidebar. If there are more than 5 options, a search input will be included on top of the list to filter the options. The cmdk input will list all the defined options once the value is selected. You can also define a custom component to be rendered for each option.

export type Checkbox = {
  type: "checkbox";
  component?: (props: Option) => JSX.Element | null;
  options?: Option[];
};

Currently, only the checkbox type has a component prop. This is used to render a custom checkbox component.

Slider

The slider type will be rendered as a slider in the Controls sidebar and have a min and max value input field. The cmdk input will list all the defined options once the value is selected.

export type Slider = {
  type: "slider";
  min: number;
  max: number;
  options?: Option[];
};

Timerange

The timerange type will be rendered as a timerange input field in the Controls sidebar. The cmdk input does not yet support the timerange input, please disable the cmdk input for the timerange filter field via commandDisabled: true.

A DatePreset can be defined to preset the timerange values. It allows you easily select a range of dates (e.g. last 7 days, last 30 days, last 3 months, last year, etc.).

export type DatePreset = {
  label: string;
  from: Date;
  to: Date;
  shortcut: string;
};
 
export type Timerange = {
  type: "timerange";
  options?: Option[]; // required for TS
  presets?: DatePreset[];
};

Search Params

The searchParams array within the search-params.ts file is what defines the search params that are used to filter the data in the data-table.

It mainly includes the same fields as the filterFields array - but defined with the nuqs parser functions.

Make sure to have both the filterFields and the searchParams in sync.

Details Sheet

The sheetFields array within the constants.tsx file is what defines the components inside of the Sheet component that is rendered when you select a row in the data-table.

Yet to be implemented: only trigger a selection if the sheetFields is defined.

By default, we will wrap every key-value pair with the DataTableSheetRowAction component that will allow you to filter the data-table based on the selected row. Based on the type, we will support different filter selections.

We should use the same types for the filterFields and the sheetFields to avoid confusion and create a readonly: boolean type to avoid that the value is not filterable from the sheet.

We allow you to override the default behavior by providing a custom component.

When loading the sheet and if the data is not available yet, we will show a skeleton component.

export type SheetField<TData, TMeta = Record<string, unknown>> = {
  /**
   * The id of the field - same as the column `id`
   */
  id: keyof TData;
  /**
   * The label of the field
   */
  label: string;
  /**
   * The type of the field
   */
  type: "readonly" | "input" | "checkbox" | "slider" | "timerange";
  /**
   * The custom component to be rendered
   */
  component?: (
    props: TData & {
      metadata?: {
        totalRows: number;
        filterRows: number;
        totalRowsFetched: number;
      } & TMeta;
    }
  ) => JSX.Element | null | string;
  /**
   * A condition to check if the field should be shown
   */
  condition?: (props: TData) => boolean;
  /**
   * The class name of the field
   */
  className?: string;
  /**
   * The class name of the skeleton
   */
  skeletonClassName?: string;
};

API

We use @tanstack/react-query and its useInfiniteQuery method to fetch the data.

To share the same search options between the client and the server, we use the same searchParams array from the search-params.ts file within our route.ts file. Based on the filters, we query the mock data and return the results.

It will be up to you to implement the actual API endpoint and query the data from your database.

You API has to return the following data:

export async function GET(req: Request) {
    // ....
    return Response.json({ data, meta } satisfies {
        data: ColumnSchema[], // TODO: defined ColumnSchema
        meta: InfiniteQueryMeta<LogsMeta>,
        nextCursor: number | null, // timestamp of the last item in the data array
        prevCursor: number | null, // timestamp of the first item in the data array - used for live mode
    })
}

We initially started with size and start, making size*start the offset but it has some drawbacks when it comes to live mode. Instead, we use the cursor to mark a timestamp and the direction to determine the direction of the pagination - "prev" for live mode or "next" for load more. If you don't need live mode, you can ignore the prevCursor and fetchPreviousPage (including the LiveButton component).

To parse the response, we use superjson to serialize and deserialize the data. This is especially useful when using Date objects or other non-serializable values.

The meta object allows us to return additional data that is not part of the data array. That can be data for the chart like chartData, or statistics to be used in the Sheet component like currentPercentiles.

export type InfiniteQueryMeta<TMeta = Record<string, unknown>> = {
  /**
   * The total number of rows in the database after filtering the timerange
   */
  totalRowCount: number;
  /**
   * The number of rows in the data-table after applying the filters
   */
  filterRowCount: number;
  /**
   * The data for the timeline chart
   */
  chartData: BaseChartSchema[];
  /**
   * The facets for every filter field
   */
  facets: Record<string, FacetMetadataSchema>;
  /**
   * The custom metadata for the data-table
   */
  metadata?: TMeta;
};
 
type FacetMetadataSchema = {
  rows: { value: any; total: number }[];
  total: number;
  min: number;
  max: number;
};
 
// as for our example, TMeta is defined as LogsMeta
type LogsMeta = {
  currentPercentiles: Record<Percentile, number>;
};

More

Timeline Chart

The timeline chart helps you to visualize the data over time. It utilizes the shadcn component (checkout out the docs) and renders the bars based on the the amount of data items you defined.

type BaseChartSchema = {
    timestamp: number;
    [key: string]: number;
}
 
interface TimelineChartProps<TChart extends BaseChartSchema> = {
    /**
     * The data from `chartData` in the `meta` object
     */
    data: TChart[];
    // ...
}

As default, the data keys are coming from the level field that takes "success", "warning", "error" as values.

The TimelineChart component is currently not configurable (especially the labels/tooltips) via props but will be in the future. You'll have to update the component yourself to adapt to your needs.

Live Mode

The live mode is a feature that allows you to see the data in real-time.

We append the live param to the search params parser (not the filter fields) to make trigger a new fetch every 4 seconds. Thanks to the search params, the live mode retains after a page reload.

The fetchPreviousPage from the the useInfiniteQuery is being used. The newly fetched data will be appended to the beginning of the data array. (contrary to the fetchNextPage which appends to the end of the data array)

For the demo, we add some mock data in the future to see the live mode in action. At some point, no more can be fetcheed anymore. You'll need to refresh the page or wait for the cache to miss.

Debugging

We use react-scan to for performance debugging. It will show you the components that are being re-rendered. You can enable it by setting the NEXT_PUBLIC_REACT_SCAN environment variable to true and is automatically enabled in the development environment.

For tanstack query, we use the ReactQueryDevtools to see the queries and mutations. It automatically shows up in the development environment.

Tanstack table debug logs can be enabled by setting the NEXT_PUBLIC_TABLE_DEBUG environment variable to true.

# .env.local
NEXT_PUBLIC_REACT_SCAN=true
NEXT_PUBLIC_TABLE_DEBUG=true