Search docs

Jump to any table example

Discord
ShadTable

A collection of composable table components built on shadcn/ui and TanStack Table.

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.

npx shadcn add https://shad-table.dev/r/filter-state-shape-table.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
Optimize bundle size for docs siteEngineeringMediumDoneJul 30, 2026
Coordinate partner co-marketing postMarketingLowDoneAug 10, 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(", ")}`;
}