EmpireUI
Get Pro
← Blog9 min read#data table#filter#react

Data Table Filters in React: Column Filter, Global Search, URL State

Build production-grade React data table filters — column-level filters, global search, and URL-synced state — using TanStack Table v8 and React Router.

developer writing React code on a dark monitor screen

Why Table Filtering Is Harder Than It Looks

Filtering sounds trivial. You've done it before — slap a controlled <input> on top of a useState-backed array, call .filter() on every keystroke, done. But then product asks for column-specific filters. And a global search. And "make sure the filters survive a page refresh." Suddenly you're wiring four different pieces of state into a URL string while keeping the table rendering fast enough that nobody notices.

This article covers that full arc. We'll start with TanStack Table v8 (released in 2023, still the undisputed standard in 2026), build column-level filters and a global search box, then sync everything to the URL so a filtered view is bookmarkable and shareable. No hand-wavy pseudocode — actual copy-pasteable components throughout.

Worth noting: if you're coming from react-table v7, TanStack Table v8 is a complete rewrite. The headless philosophy is the same, but the API is fully TypeScript-first and the column definition model changed significantly. Don't try to port v7 code — just rewrite from scratch, it's faster.

One more thing — if you're building a UI that needs a table like this inside a polished admin dashboard or a styled component library context, check out Empire UI. A lot of its patterns translate cleanly to filter UI work.

Setting Up TanStack Table With Column Definitions

Install the package. That's it for deps — TanStack Table has zero runtime dependencies.

npm install @tanstack/react-table

Your column definitions are where the real work happens. Each column can declare its own filterFn, which tells TanStack Table how to match cell values against the current filter string. There are built-ins (includesString, equalsString, arrIncludes, inNumberRange) and you can write custom ones.

import {
  createColumnHelper,
  getCoreRowModel,
  getFilteredRowModel,
  useReactTable,
  type ColumnFiltersState,
} from '@tanstack/react-table';

type User = {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'editor' | 'viewer';
  joinedAt: string;
};

const columnHelper = createColumnHelper<User>();

const columns = [
  columnHelper.accessor('name', {
    header: 'Name',
    filterFn: 'includesString', // built-in, case-insensitive substring match
  }),
  columnHelper.accessor('email', {
    header: 'Email',
    filterFn: 'includesString',
  }),
  columnHelper.accessor('role', {
    header: 'Role',
    filterFn: 'equalsString', // exact match — good for dropdowns
  }),
  columnHelper.accessor('joinedAt', {
    header: 'Joined',
    enableColumnFilter: false, // opt out a column entirely
  }),
];

Honestly, the filterFn per column is the feature that makes TanStack Table worth the learning curve. Having a dropdown filter for role use equalsString while a text input for email uses includesString — that kind of per-column control is what you'd spend a week building by hand.

Column Filters: Per-Column Input Components

TanStack Table holds column filter state as an array of { id: string; value: unknown } objects. You read the current filter for a column via column.getFilterValue() and write it via column.setFilterValue(). That's the full API surface for column-level filtering.

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

// Generic text filter input — drop this under any column header
function ColumnTextFilter({ column }: { column: any }) {
  const value = (column.getFilterValue() ?? '') as string;
  return (
    <input
      type="text"
      value={value}
      onChange={(e) => column.setFilterValue(e.target.value || undefined)}
      placeholder={`Filter ${column.id}…`}
      className="mt-1 w-full rounded border border-gray-200 px-2 py-1 text-sm"
    />
  );
}

// Select filter for enum columns like 'role'
function ColumnSelectFilter({
  column,
  options,
}: {
  column: any;
  options: string[];
}) {
  const value = (column.getFilterValue() ?? '') as string;
  return (
    <select
      value={value}
      onChange={(e) => column.setFilterValue(e.target.value || undefined)}
      className="mt-1 w-full rounded border border-gray-200 px-2 py-1 text-sm"
    >
      <option value="">All</option>
      {options.map((o) => (
        <option key={o} value={o}>
          {o}
        </option>
      ))}
    </select>
  );
}

Notice the e.target.value || undefined pattern — passing undefined instead of an empty string tells TanStack Table to clear the filter entirely, which matters for performance. When a filter value is undefined, TanStack Table skips running that column's filterFn altogether rather than running it against every row.

In practice, I render these filter inputs directly beneath each <th> in a second <tr> inside <thead>. You can also put them in a toolbar above the table — personal preference, but the "filter row under headers" pattern reduces cognitive overhead because the filter is always physically adjacent to the column it controls.

<thead>
  <tr>
    {table.getFlatHeaders().map((header) => (
      <th key={header.id} className="px-4 py-2 text-left text-sm font-semibold">
        {flexRender(header.column.columnDef.header, header.getContext())}
      </th>
    ))}
  </tr>
  <tr>
    {table.getFlatHeaders().map((header) => (
      <th key={header.id} className="px-4 pb-2">
        {header.column.getCanFilter() ? (
          header.column.id === 'role' ? (
            <ColumnSelectFilter
              column={header.column}
              options={['admin', 'editor', 'viewer']}
            />
          ) : (
            <ColumnTextFilter column={header.column} />
          )
        ) : null}
      </th>
    ))}
  </tr>
</thead>

Global Search Across All Columns

Column filters are precise. Global search is fast and forgiving. You want both. TanStack Table gives you a globalFilter state alongside columnFilters, and they compose — both filters apply simultaneously, ANDed together. So you can have a global search for "johnson" while also filtering role to "admin" and get exactly the admins with "johnson" anywhere in their row.

export function DataTable({ data }: { data: User[] }) {
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
  const [globalFilter, setGlobalFilter] = useState('');

  const table = useReactTable({
    data,
    columns,
    state: {
      columnFilters,
      globalFilter,
    },
    onColumnFiltersChange: setColumnFilters,
    onGlobalFilterChange: setGlobalFilter,
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    // globalFilterFn applies to every column that has enableGlobalFilter !== false
    globalFilterFn: 'includesString',
  });

  return (
    <div className="space-y-3">
      {/* Global search bar */}
      <input
        type="search"
        value={globalFilter}
        onChange={(e) => setGlobalFilter(e.target.value)}
        placeholder="Search everything…"
        className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm"
      />

      {/* table markup here */}
    </div>
  );
}

Quick aside: if your dataset is large (10 000+ rows), you'll want to debounce both filters. Wrap setGlobalFilter and setColumnFilters in a 150 ms debounce — useDeferredValue from React 18 also works well here if you want to keep the input snappy while the filter computation lags slightly behind.

import { useDeferredValue, useState } from 'react';

const [rawSearch, setRawSearch] = useState('');
const globalFilter = useDeferredValue(rawSearch); // React defers heavy rerender

// pass globalFilter (deferred) to the table, rawSearch to the input value

For server-side filtering — where your dataset lives in a database and you only fetch matching rows — the same state shape works. You'd set manualFiltering: true on the table config and fire off a fetch whenever columnFilters or globalFilter changes. TanStack Table won't run its client-side filter logic; it just manages the state and lets you drive the data fetch.

Syncing Filter State to the URL

This is the part developers usually skip and then regret. Your users filter down to "admin role, joined after 2025, name contains 'smith'" and then — they copy the URL to share with a colleague. The colleague opens it and gets an unfiltered table. Oops.

URL state sync means using the URL's search params as the source of truth for filter state, so every filter change produces a shareable, bookmarkable URL. With React Router v6 (or Next.js useSearchParams), this is about 30 lines of code.

import { useSearchParams } from 'react-router-dom'; // or 'next/navigation'
import { useCallback, useMemo } from 'react';
import type { ColumnFiltersState } from '@tanstack/react-table';

function useTableUrlState() {
  const [searchParams, setSearchParams] = useSearchParams();

  // Deserialise from URL on mount
  const columnFilters = useMemo<ColumnFiltersState>(() => {
    const raw = searchParams.get('filters');
    if (!raw) return [];
    try {
      return JSON.parse(decodeURIComponent(raw));
    } catch {
      return [];
    }
  }, [searchParams]);

  const globalFilter = searchParams.get('q') ?? '';

  // Write back to URL — replace, not push, so back button doesn't re-filter
  const setColumnFilters = useCallback(
    (updater: ColumnFiltersState | ((old: ColumnFiltersState) => ColumnFiltersState)) => {
      const next = typeof updater === 'function' ? updater(columnFilters) : updater;
      setSearchParams(
        (prev) => {
          const params = new URLSearchParams(prev);
          if (next.length === 0) {
            params.delete('filters');
          } else {
            params.set('filters', encodeURIComponent(JSON.stringify(next)));
          }
          return params;
        },
        { replace: true }
      );
    },
    [columnFilters, setSearchParams]
  );

  const setGlobalFilter = useCallback(
    (value: string) => {
      setSearchParams(
        (prev) => {
          const params = new URLSearchParams(prev);
          if (!value) {
            params.delete('q');
          } else {
            params.set('q', value);
          }
          return params;
        },
        { replace: true }
      );
    },
    [setSearchParams]
  );

  return { columnFilters, globalFilter, setColumnFilters, setGlobalFilter };
}

Swap your local useState calls for this hook and you're done. The table now reads from the URL on every render, writes back on every filter change, and uses replace: true so the browser's Back button doesn't force the user to undo filters one by one. That last detail matters more than you'd think in practice.

Look, one gotcha: if you're on Next.js App Router (Next 13+), useSearchParams must be used inside a <Suspense> boundary or you'll get a build error. Wrap your table component in <Suspense fallback={<TableSkeleton />}> and you're fine.

Putting It All Together: The Full Table Component

Here's the complete, wired-up component. It uses the URL state hook, column + global filters, the filter input sub-components, and a clean table render — all in one file you can drop into a project.

// DataTable.tsx — full wired-up example
import {
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  useReactTable,
} from '@tanstack/react-table';
import { useDeferredValue, useState } from 'react';
import { useTableUrlState } from './useTableUrlState';
import { columns } from './columns';
import type { User } from './types';

export function DataTable({ data }: { data: User[] }) {
  const { columnFilters, globalFilter, setColumnFilters, setGlobalFilter } =
    useTableUrlState();

  const [rawSearch, setRawSearch] = useState(globalFilter);
  const deferredSearch = useDeferredValue(rawSearch);

  // Keep URL in sync with deferred value only
  // (avoids URL thrashing on every keystroke)
  useState(() => {
    setGlobalFilter(deferredSearch);
  });

  const table = useReactTable({
    data,
    columns,
    state: { columnFilters, globalFilter: deferredSearch },
    onColumnFiltersChange: setColumnFilters,
    onGlobalFilterChange: setGlobalFilter,
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    globalFilterFn: 'includesString',
  });

  return (
    <div className="space-y-4">
      <div className="flex items-center justify-between">
        <input
          type="search"
          value={rawSearch}
          onChange={(e) => setRawSearch(e.target.value)}
          placeholder="Search everything…"
          className="w-72 rounded-lg border px-3 py-2 text-sm"
        />
        <span className="text-sm text-gray-500">
          {table.getFilteredRowModel().rows.length} results
        </span>
      </div>

      <div className="overflow-x-auto rounded-xl border">
        <table className="w-full text-sm">
          <thead className="bg-gray-50">
            <tr>
              {table.getFlatHeaders().map((h) => (
                <th key={h.id} className="px-4 py-3 text-left font-semibold">
                  {flexRender(h.column.columnDef.header, h.getContext())}
                </th>
              ))}
            </tr>
            <tr>
              {table.getFlatHeaders().map((h) => (
                <th key={h.id} className="px-4 pb-2">
                  {h.column.getCanFilter() && <ColumnTextFilter column={h.column} />}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {table.getRowModel().rows.map((row) => (
              <tr key={row.id} className="border-t hover:bg-gray-50">
                {row.getVisibleCells().map((cell) => (
                  <td key={cell.id} className="px-4 py-3">
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

That's the complete picture. Column filters, global search, URL state, deferred rendering, result count — production-ready out of the box. From here you'd layer in sorting (getSortedRowModel), pagination (getPaginationRowModel), and maybe a reset-filters button that wipes both columnFilters and q from the URL.

If you want the whole thing styled beautifully without writing the CSS from scratch, browse the Empire UI component library. The table components there follow exactly this TanStack Table pattern and ship with dark mode, responsive overflow handling, and proper focus states at 48px touch targets — no 300 lines of Tailwind to write yourself.

FAQ

Can I use TanStack Table filters with server-side data?

Yes — set manualFiltering: true in the table config and TanStack Table skips client-side filtering entirely. You read columnFilters and globalFilter from state, fire your API call when they change, and update the data prop with the server response.

How do I reset all filters at once?

Call table.resetColumnFilters() and table.setGlobalFilter(''), then clear the URL params if you're syncing state there. Wire both calls to a "Clear filters" button that only renders when at least one filter is active — table.getState().columnFilters.length > 0 || globalFilter.

Why use `replace: true` when writing filter state to the URL?

Without it, every filter keystroke pushes a new history entry, so the browser Back button steps through every intermediate filter state instead of going back to the previous page. replace: true keeps the history stack clean.

What's the difference between `filterFn: 'includesString'` and `filterFn: 'equalsString'`?

includesString is a case-insensitive substring match — great for text search inputs. equalsString requires an exact case-insensitive match — use it for enum columns driven by a <select>, where partial matching would be wrong.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Data Table in React: Sort, Filter, Pagination With TanStack TableProduct Filter Sidebar in React: Price Range, Multi-Select, URL StateTanStack Table in React: Sorting, Filtering, Pagination in 100 LinesURL State in React: nuqs, useSearchParams and the Right Patterns