Search docs

Jump to any table example

Discord—

Shadcn Filter State Shape

A normalized ActiveFilter[] array as the single source of truth for multi-select and date-range filters — the toolbar, the removable chips row, and TanStack Table's columnFilters all read from (and write to) the same shape.

Use this when several UI pieces (toolbar, chips, saved views) need to read and write the same filters without drifting out of sync.

$ npx shadcn add https://www.shad-table.dev/r/filter-state-shape-table-v9.json
TitleCategoryPriorityStatusDue date
Redesign onboarding flowDesignHighIn ProgressAug 5, 2026
Fix pagination bug on exportEngineeringHighOpenAug 8, 2026
Write Q3 newsletter draftMarketingLowDoneJul 28, 2026
Audit component color tokensDesignMediumOpenAug 15, 2026
Migrate auth to new session storeEngineeringHighIn ProgressAug 20, 2026
Plan launch landing page copyMarketingMediumOpenAug 1, 2026
Ship dark mode for settings pageDesignMediumDoneJul 22, 2026
Add rate limiting to public APIEngineeringHighOpenAug 25, 2026
Set up A/B test for pricing pageMarketingLowIn ProgressAug 12, 2026
Review accessibility on data tableDesignMediumOpenAug 18, 2026

How it works

1.One normalized filter shape drives everything

ActiveFilter is a small discriminated union — dateRange or multiSelect, each tagged with the columnId it targets. It's the single source of truth for the toolbar controls, the chips row, and the table's columnFilters, so none of those three can drift out of sync with each other.

src/components/filter-state-shape/type.ts
export type DateRangeFilter = {
type: "dateRange";
columnId: string;
from?: Date;
to?: Date;
};
export type MultiSelectFilter = {
type: "multiSelect";
columnId: string;
values: string[];
};
export type ActiveFilter = DateRangeFilter | MultiSelectFilter;

2.columnFilters is derived, not owned by the table

The table never manages its own columnFilters state. It's computed from `filters` on every render, so TanStack Table just reads whatever the ActiveFilter[] array currently says — there's nowhere else column filter state could live.

src/components/filter-state-shape/data-table.tsx
const columnFilters = useMemo<ColumnFiltersState>(
() =>
filters.map((f) =>
f.type === 'multiSelect'
? { id: f.columnId, value: f.values }
: { id: f.columnId, value: { from: f.from, to: f.to } },
),
[filters],
)

3.Chips read the same array they remove from

ActiveFilterChips takes the exact ActiveFilter[] array and renders one chip per entry, formatting a dateRange differently from a multiSelect. Removing a chip just filters that columnId out of `filters` — the derived columnFilters and the toolbar controls update for free.

src/components/filter-state-shape/active-filter-chips.tsx
function formatFilterLabel(f: ActiveFilter) {
if (f.type === "dateRange") {
return `${f.columnId}: ${f.from ? format(f.from, "MMM d") : "?"} - ${f.to ? format(f.to, "MMM d") : "?"}`;
}
return `${f.columnId}: ${f.values.join(", ")}`;
}

Using TanStack Table v8? See the v8 Filter State Shape.