DEV Community

Cover image for Let's create Data Table. Part 6: Column filtering
Dima Vyshniakov
Dima Vyshniakov

Posted on • Edited on

Let's create Data Table. Part 6: Column filtering

This is a single article from a series about creating of an advanced Data table component using React, TanStack Table 8, Tailwind CSS and Headless UI.

In the previous episode, we've extended table view functionality with sorting. This time, we are going to allow users to filter table columns.

Table cell filtering applies provided criteria to all cells of the certain column and shows the result to the user.

Here is the demo of the feature.

Table filtering demo

Create filter UI

This feature involves several UI elements. We need to create the following components:

  • Dialog. The component to show modal content over the main flow.

  • Various inputs field. Required to capture user filter changes.

  • Button. To be able to set filter.

Filter UI

Reusable TableDialog component

We are going to create the TableDialog component using Headless UI. We use Tailwind CSS stone-* colors to match already used in the table. Component is placed in src/DataTable/dialogs/TableDialog.tsx file.

Here are 3 goals we'll achieve with the TableDialog component

  1. Reusability: the component is reusable and accepts various content (children). It also has an optional title for the header text, an open prop to control the modal state, and an onClose callback for closing the dialog.

  2. Backdrop: We use the DialogBackdrop component from Headless with the classes fixed inset-0 bg-stone-600/30 backdrop-blur-sm to create a semi-transparent backdrop that blurs the underlying content.

  3. Transitions: DialogPanel component uses the classes transition duration-300 ease-out data-[closed]:opacity-0 to create fade in/out transitions for the dialog window.

Here is the code:

import { FC, ReactNode } from 'react';
import {
  Dialog,
  DialogPanel,
  DialogTitle,
  DialogBackdrop,
} from '@headlessui/react';
import classNames from 'classnames';

export type Props = {
  title?: string;
  children: ReactNode;
  open?: boolean;
  onClose?: () => void;
};

export const TableDialog: FC<Props> = ({
  title,
  children,
  open = false,
  onClose = () => {},
}) => {
  return (
    <Dialog
      open={open}
      as="div"
      className="relative z-50 focus:outline-none"
      onClose={onClose}
    >
      {/* Create a backdrop */}
      <DialogBackdrop className="..." />
      <div className="fixed inset-0 z-10 w-screen overflow-y-auto">
        <div className="flex min-h-full items-center justify-center">
          <DialogPanel
            // required to enable transition
            transition
            className={classNames(
              // Tailwind classes
              // ...
            )}
          >
            {title && (
              <DialogTitle
                title={title}
                as="h3"
              >
                {title}
              </DialogTitle>
            )}
            {children}
          </DialogPanel>
        </div>
      </div>
    </Dialog>
  );
};

Enter fullscreen mode Exit fullscreen mode

Filter Dialog User eXperience

To implement modal component logic, we have to implement manageable state for the column filter dialog.

The hook, located at src/DataTable/features/useFilterDialogState.ts, is responsible for controlling the Filter Dialog state. It has two distinct React states: one to track whether the dialog is open or closed, and another to store the ID of the currently selected column. This separation enables the openDialog function to effectively handle both actions: opening the dialog and recording the selected column's ID for subsequent filtering operations.

import { useState, useCallback } from 'react';

export const useFilterDialogState = () => {
  const [open, setOpen] = useState(false);
  const [columnId, setColumnId] = useState<string>();

  const openDialog = useCallback((selectedId: string) => {
    setColumnId(selectedId);
    setOpen(true);
  }, []);

  const closeDialog = useCallback(() => {
    setOpen(false);
  }, []);

  return { openDialog, isOpen: open, closeDialog, selectedId: columnId };
};
Enter fullscreen mode Exit fullscreen mode

This hook will be placed within the src/DataTable/DataTable.tsx component. We'll save the openDialog callback into the TanStack Table Meta configuration, following the same approach we used for the locale setting.

const DataTable: FC<Props> = ({ tableData, locale = 'en-US' }) => {

  // Initialize filter dialog state
  const {
    openDialog: openFilterDialog,
    isOpen,
    closeDialog,
    selectedId,
  } = useFilterDialogState();

  const table = useReactTable({
    meta: {
      openFilterDialog,
      //...
    },
    //...
   }
  //..
  return (
    <Fragment>
      <FilterDialog
        selectedColumn={selectedId}
        isOpen={isOpen}
        onClose={closeDialog}
        tableContext={table}
      />
      {/*...*/}
    </Fragment>
}

Enter fullscreen mode Exit fullscreen mode

We extend src/DataTable/declarations.d.ts:

import '@tanstack/react-table';
import { Row } from './types.ts';

declare module '@tanstack/react-table' {
  interface TableMeta<TData extends Row> {
     openFilterDialog: (columnId: string) => void;
     //...
  }
  //...
}

Enter fullscreen mode Exit fullscreen mode

We can now programmatically control the Filter Dialog by invoking callbacks to open and close it manually. Finally, we'll integrate a filter action into our menu. This involves calling the openFilterDialog function previously defined within the Table Meta configuration.

export const useColumnActions = (
  context: HeaderContext<Row, unknown>,
): ColumnAction[] => {
  //...

  // Get column filter state using TanStack table API
  const isFiltered = context.column.getIsFiltered();

  return useMemo<ColumnAction[]>(
    () => [
      //...
      {
        label: !isFiltered ? 'Apply filter' : 'Edit/Remove filter',
        icon: <Icon name="funnel" className="text-lg" />,
        onClick: () => {
          context.table.options.meta?.openFilterDialog(context.column.id);
        },
      },
    ],
    [context.column, context.table, isFiltered, isPinned, isSorted],
  );
};
Enter fullscreen mode Exit fullscreen mode

Create inputs

Previously, we created 4 different Content types:

enum ContentTypes {
  Text = 'Text',
  Number = 'Number',
  Date = 'Date',
  Country = 'Country',
}
Enter fullscreen mode Exit fullscreen mode

Now we'll support each of them with the relevant input to capture user filter values.

Input component interface

We are going to create 4 different input field components in the src/DataTable/inputs folder.

Input component interface

  • label: A string representing the text label displayed above the input field.

  • className: An optional string for applying custom CSS classes to the component.

  • placeholder: An optional string providing a text hint to the user within the input field.

  • value: An optional property representing the current value of the filter.

  • onChange: A function that handles changes to the input field's value.

  • icon: An optional string specifying the name of the icon to be displayed to the left of the input field.

Text input

Here is the TextInput component code. Headless Field, Input, and Label, components are used to ensure accessibility requirements are met.

import { FC, useCallback, ChangeEvent } from 'react';
import { Field, Input, Label } from '@headlessui/react';
import classNames from 'classnames';
import { Icon } from '../../Icon.tsx';

export const TextField: FC<Props> = ({
  label,
  className,
  placeholder,
  value,
  onChange,
  icon = 'text-t',
}) => {
  const handleChange = useCallback(
    (event: ChangeEvent<HTMLInputElement>) => {
      onChange(event.target.value);
    },
    [onChange],
  );
  return (
    <Field className="flex flex-col gap-1.5">
      <Label
        className={classNames(
          'text-white/70 cursor-pointer text-sm font-semibold ',
        )}
      >
        {label}
      </Label>
      <div className="flex flex-row items-center gap-2">
        <Icon
          name={icon}
          className="shrink-0 text-2xl text-white/80"
          variant="fill"
        />
        <Input
          onChange={handleChange}
          value={value}
          type="text"
          placeholder={placeholder}
          className={classNames(
            // Tailwind classes
            className,
          )}
        />
      </div>
    </Field>
  );
};
Enter fullscreen mode Exit fullscreen mode

Numeric input

Numeric input demo

Similar to Text input, Numeric input utilizes the Headless UI Input component, with some adjustments for number display and basic validation.

  • Display: Tailwind classes text-right tabular-nums are applied to align numbers to the right and enhance their visual presentation in tables.

  • Validation: The pattern attribute is used to restrict user input to valid decimal numbers. The provided pattern [+-]?(?:0|[1-9]\d*)(?:\.\d+)?" allows positive, negative numbers, zero, and decimals. This article explains the topic in detail.

Date input

Date input expects the value to be a valid Date ISO string. We set the type attribute of HTMLInputComponent to date and utilize event.target.valueAsDate property to capture input value as a Date object.

To be able to display selected date, we need to format our ISO string value into yyyy-mm-dd format.

Here is how to do it. Notice, that we are reversing date formatted using hardcoded en-GB locale, which is dd/mm/yyyy.

const value = valueProp
    ? new Date(valueProp)
        .toLocaleDateString('en-GB')
        .split('/')
        .reverse()
        .join('-')
    : '';
Enter fullscreen mode Exit fullscreen mode

Combo input

For the Country content type, we'll pick a Headless UI Combobox input due to its suitability for handling enumerable data like a limited list of countries.

The code is located at src/DataTable/inputs/ComboField.tsx.

Here is the demo of country selection:

Demo of country selection

To have a full list of country names linked to the country codes, we are storing in the table, we are going to create a locale-aware mocking function src/mocks/createCountryList.ts:

import { countryCodes } from './countryCodes.ts';

export const createCountryList = (locale = 'en-US') =>
  countryCodes
    .map((code) => ({
      value: code,
      // use a standard browser built-in object to get
      // a locale-aware country name from region code
      label: new Intl.DisplayNames(locale, { type: 'region' }).of(code) || '',
    }))
    .sort(({ label: leftLabel }, { label: rightLabel }) =>
      // use a standard browser built-in object to compare country names
      new Intl.Collator(locale).compare(leftLabel, rightLabel),
    );

Enter fullscreen mode Exit fullscreen mode

Input composition

The currently visible input type now depends on the selected column content type.

const filterInput = useMemo(() => {
    const countryOptions = createCountryList(tableContext.options.meta?.locale);
    return selectedColumn
      ? {
          [ContentTypes.Text]: (
            <TextField
              label="Cell contains:"
              placeholder="Enter text (case insensitive)"
              onChange={setNextFilterValue}
              value={nextFilterValue as string}
            />
          ),
          [ContentTypes.Number]: (
            <NumericField
              label="Number is bigger than:"
              placeholder="-123.456"
              onChange={setNextFilterValue}
              value={nextFilterValue as string}
            />
          ),
          [ContentTypes.Date]: (
            <DateField
              label="Filter dates after:"
              placeholder="Pick date"
              onChange={setNextFilterValue}
              value={nextFilterValue as string}
            />
          ),
          [ContentTypes.Country]: (
            <ComboField
              value={nextFilterValue as string}
              icon="globe-stand"
              options={countryOptions}
              label="Select country"
              placeholder="Start typing or expand"
              onChange={setNextFilterValue}
            />
          ),
        }[selectedColumn.contentType]
      : null;
  }, [nextFilterValue, selectedColumn, tableContext.options.meta?.locale]);
Enter fullscreen mode Exit fullscreen mode

Button component

We also need a basic button to capture user clicks. The component (src/DataTable/inputs/Button.tsx) needs to support a disabled state. This is because we want to prevent users from setting an empty filter and instead encourage them to use a reset function.

We'll use Headless Button and use Tailwind disabled: helper to manage styles.

Apply and reset filter(s)

The filtering feature requires a list of columns that can be filtered. Not all columns may be filterable (we'll explore this further in next chapters).

The src/DataTable/features/useFilterableColumns.ts hook retrieves the table's column configuration using the TanStack Table API. It then transforms this raw column data into a new array specifically designed for the Filter Dialog. This array contains only the necessary information for each filterable column. Finally, the hook removes any columns from the array where filtering is explicitly disabled, as well as the filterable prop itself.

import { Table } from '@tanstack/react-table';
import { useMemo } from 'react';
import { Row, ContentTypes } from '../types.ts';

/**
 * Column definition to use in Dialogs
 */
export type Column = {
  id: string;
  title: string;
  contentType: keyof typeof ContentTypes;
};

export const useFilterableColumns = (table: Table<Row>): Column[] => {
  return useMemo(
    () =>
      table
        .getAllColumns()
        .map((column) => {
          return {
            title: column.columnDef.meta?.title as string,
            contentType: column.columnDef.meta
              ?.contentType as keyof typeof ContentTypes,
            id: column.id,
            filterable: column?.getCanFilter(),
            filterValue: column?.getFilterValue(),
          };
        })
        // 
        .filter(({ filterable }) => filterable)
        .map(({ filterable, ...restProps }) => ({
          ...restProps,
        })),
    [table],
  );
};

Enter fullscreen mode Exit fullscreen mode

The src/DataTable/dialogs/FilterDialog.tsx component implements the following filtering logic. It obtains a list of all filterable columns by utilizing the useFilterableColumns.ts hook. The component determines the currently selected column from the list of available filterable columns. The current filter value for the selected column is retrieved using the TanStack Table API. A state variable is created to capture the next filter value. An effect is implemented to ensure that the state variable remains synchronized with the selectedColumn prop, effectively updating the filter value whenever the selected column changes

export const FilterDialog: FC<Props> = ({
  isOpen,
  selectedColumn: selectedColumnProp,
  onClose = () => {},
  tableContext,
}) => {
  const columns = useFilterableColumns(tableContext);

  const selectedColumn = useMemo(
    () => columns.find(({ id }) => id === selectedColumnProp),
    [columns, selectedColumnProp],
  );

  const filterValue =
    selectedColumnProp &&
    (tableContext.getColumn(selectedColumnProp)?.getFilterValue() as
      | string
      | undefined);

  const [nextFilterValue, setNextFilterValue] = useState<string>('');

  useEffect(() => {
    setNextFilterValue(filterValue || '');
  }, [
    filterValue,
    // This dependency ensures that the state is cleared after Dialog dismissed
    selectedColumn,
  ]);
  //...
}
Enter fullscreen mode Exit fullscreen mode

We bind set and reset functions to the corresponding Filter Dialog buttons.

const handleReset = useCallback(() => {
  tableContext.getColumn(selectedColumnProp as string)?.setFilterValue('');
  onClose();
}, [onClose, selectedColumnProp, tableContext]);

const handleSetFilter = useCallback(() => {
  tableContext
    .getColumn(selectedColumnProp as string)
    ?.setFilterValue(nextFilterValue);
  onClose();
}, [nextFilterValue, onClose, selectedColumnProp, tableContext]);
Enter fullscreen mode Exit fullscreen mode

Implement data filtering

TanStack Table offers extensive Filtering API support.

Column definitions at src/DataTable/columnsConfig.tsx have to be extended with filterFn property dictating which filter function should be applied to the column data. Here, we can choose among TanStack built-in functions or create our own.

To enable data filtering, you need to extend the column definitions in src/DataTable/columnsConfig.tsx with the filterFn property. This property specifies the filter function to be applied to the column data. You can choose from TanStack built-in functions or create our own

  • includesString for Text content type (built-in function).

  • isBiggerNumber for Number content type (custom function).

  • isAfterDate for Date content type (custom function).

Custom filters

We put custom filter functions inside src/DataTable/features/filterFns.ts.

import { FilterFn } from '@tanstack/react-table';

import { Row } from '../types.ts';

export const isBiggerNumber: FilterFn<Row[]> = (
  row,
  columnId,
  filterValue: number,
) => {
  return row.getValue<number>(columnId) > filterValue;
};

export const isAfterDate: FilterFn<Row[]> = (
  row,
  columnId,
  filterValue: Date,
) => {
  const cellDate = new Date(row.getValue(columnId));
  return cellDate.getTime() > filterValue.getTime();
};

isAfterDate.resolveFilterValue = (filterValue) => {
  return new Date(filterValue);
};

Enter fullscreen mode Exit fullscreen mode

Apply Filtered Row Model

Next step, we import and apply the Filtered Row Model to the table, register custom filter functions and initialize the Filter Dialog state.

import {
  getFilteredRowModel,
} from '@tanstack/react-table';
import { useFilterDialogState } from './features/useFilterDialogState.ts';
import { isBiggerNumber, isAfterDate } from './features/filterFns.ts';

const DataTable: FC<Props> = ({ tableData, locale = 'en-US' }) => {
  // ...

  const {
    openDialog: openFilterDialog,
    isOpen,
    closeDialog,
    selectedId,
  } = useFilterDialogState();

  const table = useReactTable({
    meta: {
      // save reference for open filter dialog callback
      openFilterDialog,
    },
    getFilteredRowModel: getFilteredRowModel(),
    filterFns: {
      // set the custom filter function for numeric content
      isBiggerNumber,
      // set the custom filter function for dates
      isAfterDate
    },
    //...
  });
  return (
    <Fragment>
     <FilterDialog
        selectedColumn={selectedId}
        isOpen={isOpen}
        onClose={closeDialog}
        tableContext={table}
      />
     {/*...*/}
    </Fragment>
  )
}
Enter fullscreen mode Exit fullscreen mode

Style filtered cells

We have to conditionally change the style of filtered cells, making them more visible.

<td
  key={cell.id}
  className={classNames(
    // ... rest classes
    {
      'font-normal': !cell.column.getIsFiltered(),
      'font-semibold italic': cell.column.getIsFiltered(),
    },
  )}
>
  {flexRender(
    cell.column.columnDef.cell,
    cell.getContext(),
  )}
</td>
Enter fullscreen mode Exit fullscreen mode

Handle no results

Sometimes the filtering will provide an empty result to the user. We can handle this case by showing warning and button to reset filters.

No results demo


const handleResetFilters = useCallback(() => {
  table.resetColumnFilters();
}, [table]);

// ...

return (
  <Fragment>
    {/*...*/}
    {rows.length === 0 && (
      <div className="flex items-center justify-center gap-3 p-3 font-medium">
        <div>No data to render.</div>
          <Button
            icon="funnel-x"
            title="Reset all filters"
            onClick={handleResetFilters}
          />
        </div>
    )}
  </Fragment>
)
Enter fullscreen mode Exit fullscreen mode

Mouse over row highlight

The last step of this exercise will be adding the highlight to table rows when the user is hovering them. This enhancement aims to further improve our table focusing capabilities.
Here is the demo.

Row highlight demo

We implement this feature using the Tailwind group: helper, which translates parent states to children.

<tr className="group">
  <td className="group-hover:!bg-cyan-100" />
</tr>
Enter fullscreen mode Exit fullscreen mode

Demo

Here is a working demo of this exercise.

To be continued...

Top comments (0)